diff --git a/.changeset/query-root-nonnull.md b/.changeset/query-root-nonnull.md new file mode 100644 index 000000000..8e9c97101 --- /dev/null +++ b/.changeset/query-root-nonnull.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +`Query.root` is now non-null and returns the namespace's Root Registry — preferring the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry. diff --git a/.changeset/unified-domain-model.md b/.changeset/unified-domain-model.md new file mode 100644 index 000000000..c4052ab80 --- /dev/null +++ b/.changeset/unified-domain-model.md @@ -0,0 +1,24 @@ +--- +"enssdk": minor +"@ensnode/ensdb-sdk": minor +"@ensnode/ensnode-sdk": minor +"ensindexer": minor +"ensapi": minor +--- + +Unify `v1Domain` + `v2Domain` into a single polymorphic `domain` table discriminated by a `type` enum (`"ENSv1Domain"` | `"ENSv2Domain"`), and make Registry polymorphic across concrete ENSv1 (mainnet Registry, Basenames Registry, Lineanames Registry), ENSv1 Virtual (per-parent-domain virtual Registry managed by each ENSv1 domain that has children), and ENSv2 Registries. + +### Breaking schema + id format changes + +- `ENSv1DomainId` is now CAIP-shaped: `${ENSv1RegistryId}/${node}` (was `Node`). Every ENSv1 Domain is addressable through a concrete Registry, so bare `node` values no longer identify a Domain by themselves. +- `RegistryId` is a union of `ENSv1RegistryId`, `ENSv1VirtualRegistryId`, and `ENSv2RegistryId`. New id constructors: `makeENSv1RegistryId`, `makeENSv2RegistryId`, `makeENSv1VirtualRegistryId`. `makeENSv1DomainId` now takes `(AccountId, Node)`. `makeRegistryId` is retained as a union-returning helper for callsites that can't narrow. +- `domains` table: replaces `v1_domains` + `v2_domains`. Adds `type`, nullable `tokenId` (non-null iff ENSv2), nullable `node` (non-null iff ENSv1), nullable `rootRegistryOwnerId` (v1 only). `parentId` removed; parent relationships flow through `registryCanonicalDomain` for both v1 and v2. +- `registries` table: adds `type` enum column and nullable `node` (non-null iff `ENSv1VirtualRegistry`). Unique `(chainId, address)` index becomes a plain index so virtual Registries can share their concrete parent's `(chainId, address)`. +- `registryCanonicalDomain.domainId` is typed as the unified `DomainId`. + +### GraphQL + +- `Registry` becomes a GraphQL interface with `ENSv1Registry`, `ENSv1VirtualRegistry`, and `ENSv2Registry` implementations. `ENSv1VirtualRegistry` exposes `node: Node!`. +- `Domain` interface gains `parent: Domain` (resolved via the canonical-path dataloader); `ENSv1Domain` exposes `node: Node!` and `rootRegistryOwner`; `ENSv2Domain` exposes `tokenId`, `registry`, `subregistry`, `permissions`. +- `Query.registry(by: { contract })` now DB-looks up the concrete Registry by `(chainId, address, type IN (ENSv1Registry, ENSv2Registry))`. Virtual Registries are not addressable via `AccountId` alone. + diff --git a/AGENTS.md b/AGENTS.md index 674d9db83..b92765790 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,8 @@ Runnable commands for validating changes; lint and format with Biome. - Use `describe`/`it` blocks with `expect` assertions. - Use `vi.mock()` for module mocking and `vi.fn()` for function stubs. - Each app and package has its own `vitest.config.ts`. +- Prefer the `await expect(...).resolves.*` format over await-then-expect. +- Prefer `await expect(...).resolves.toMatchObject({})` over expecting individual properties, if it is more concise. ## Documentation & DRY @@ -81,3 +83,5 @@ Fail fast and loudly on invalid inputs. 1. `pnpm -F typecheck` 2. `pnpm lint` 3. `pnpm test --project [--project ]` + 4. If OpenAPI Specs were affected, run `pnpm generate:openapi` + 5. If the Omnigraph GraphQL Schema was affected, run `pnpm generate:gqlschema` diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index a1ae5ac11..18e37a891 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -1,8 +1,8 @@ import DataLoader from "dataloader"; import { getUnixTime } from "date-fns"; -import type { CanonicalPath, ENSv1DomainId, ENSv2DomainId } from "enssdk"; +import type { CanonicalPath, DomainId } from "enssdk"; -import { getV1CanonicalPath, getV2CanonicalPath } from "./lib/get-canonical-path"; +import { getCanonicalPath } from "./lib/get-canonical-path"; /** * A Promise.catch handler that provides the thrown error as a resolved value, useful for Dataloaders. @@ -10,14 +10,9 @@ import { getV1CanonicalPath, getV2CanonicalPath } from "./lib/get-canonical-path const errorAsValue = (error: unknown) => error instanceof Error ? error : new Error(String(error)); -const createV1CanonicalPathLoader = () => - new DataLoader(async (domainIds) => - Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))), - ); - -const createV2CanonicalPathLoader = () => - new DataLoader(async (domainIds) => - Promise.all(domainIds.map((id) => getV2CanonicalPath(id).catch(errorAsValue))), +const createCanonicalPathLoader = () => + new DataLoader(async (domainIds) => + Promise.all(domainIds.map((id) => getCanonicalPath(id).catch(errorAsValue))), ); /** @@ -28,7 +23,6 @@ const createV2CanonicalPathLoader = () => export const context = () => ({ now: BigInt(getUnixTime(new Date())), loaders: { - v1CanonicalPath: createV1CanonicalPathLoader(), - v2CanonicalPath: createV2CanonicalPathLoader(), + canonicalPath: createCanonicalPathLoader(), }, }); 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 index c14e8a0e2..a85346b4e 100644 --- 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 @@ -2,45 +2,46 @@ import config from "@/config"; import { sql } from "drizzle-orm"; -import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getRootRegistryIds } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -import { lazy } from "@/lib/lazy"; /** - * The maximum depth to traverse the ENSv2 namegraph in order to construct the set of Canonical - * Registries. + * The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries. * - * Note that the set of Canonical Registries in the ENSv2 Namegraph is a _tree_, enforced by the - * requirement that each Registry maintain a reverse-pointer to its Canonical Domain, a form of - * 'edge authentication': if the reverse-pointer doesn't agree with the forward-pointer, the edge - * is not traversed, making cycles within the direced graph impossible. + * 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. * - * So while technically not necessary, including the depth constraint avoids the possibility of an - * infinite runaway query in the event that the indexed namegraph is somehow corrupted or otherwise - * introduces a canonical cycle. + * 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; -// lazy() defers construction until first use so that this module can be -// imported without env vars being present (e.g. during OpenAPI generation). -const getENSV2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); - /** - * Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all - * Canonical Registries. A Canonical Registry is an ENSv2 Registry that is the Root Registry or the - * (sub)Registry of a Domain in a Canonical Registry. + * Builds a recursive CTE that traverses forward from every top-level Root Registry configured for + * the namespace (all concrete ENSv1Registries plus the ENSv2 Root when defined) 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. + * + * Both ENSv1 and ENSv2 Domains set `subregistryId` (ENSv1 Domains to their managed ENSv1 + * VirtualRegistry, ENSv2 Domains to their declared Subregistry), so a single recursive step over + * `domain.subregistryId` covers both lineages. * * TODO: could this be optimized further, perhaps as a materialized view? */ export const getCanonicalRegistriesCTE = () => { - // if ENSv2 is not defined, return an empty set with identical structure to below - if (!getENSV2RootRegistryId()) { - return ensDb - .select({ id: sql`registry_id`.as("id") }) - .from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`) - .as("canonical_registries"); - } + 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({ @@ -53,15 +54,17 @@ export const getCanonicalRegistriesCTE = () => { sql` ( WITH RECURSIVE canonical_registries AS ( - SELECT ${getENSV2RootRegistryId()}::text AS registry_id, 0 AS depth + ${rootsUnion} UNION ALL - SELECT rcd.registry_id, cr.depth + 1 - FROM ${ensIndexerSchema.registryCanonicalDomain} rcd - JOIN ${ensIndexerSchema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id - JOIN canonical_registries cr ON cr.registry_id = parent.registry_id + -- Filter nulls at the recursive step so terminal Domains (no subregistry declared) don't + -- emit null rows into the CTE and don't spawn dead-end recursion branches. + 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 WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} + AND d.subregistry_id IS NOT NULL ) - SELECT registry_id FROM canonical_registries + 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 b70ca8953..49650dab2 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,6 +1,6 @@ import { and, eq, sql } from "drizzle-orm"; -import { alias, unionAll } from "drizzle-orm/pg-core"; -import type { DomainId, NormalizedAddress } from "enssdk"; +import { alias } from "drizzle-orm/pg-core"; +import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -10,73 +10,51 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; export type BaseDomainSet = ReturnType; /** - * Universal base domain set: all v1 and v2 domains with consistent metadata. + * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * - * Returns {domainId, ownerId, registryId, parentId, labelHash, sortableLabel} where: - * - registryId is NULL for v1 domains (all v1 domains are canonical) - * - v1 parentId comes directly from the v1Domain.parentId column - * - v2 parentId is derived via canonical registry traversal: look up the canonical domain - * for this domain's registry (via registryCanonicalDomain), then verify the reverse pointer - * (parent.subregistryId = child.registryId). See getV2CanonicalPath for the recursive version. - * - sortableLabel is the domain's own interpreted label, used for NAME ordering, which can be - * overridden by future layers. + * 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. * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ export function domainsBase() { - const v2ParentDomain = alias(ensIndexerSchema.v2Domain, "v2ParentDomain"); + const parentDomain = alias(ensIndexerSchema.domain, "parentDomain"); - return unionAll( + return ( ensDb .select({ - domainId: sql`${ensIndexerSchema.v1Domain.id}`.as("domainId"), - ownerId: sql`${ensIndexerSchema.v1Domain.ownerId}`.as("ownerId"), - registryId: sql`NULL::text`.as("registryId"), - parentId: sql`${ensIndexerSchema.v1Domain.parentId}`.as("parentId"), - labelHash: sql`${ensIndexerSchema.v1Domain.labelHash}`.as("labelHash"), + 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"), + labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( "sortableLabel", ), }) - .from(ensIndexerSchema.v1Domain) - .leftJoin( - ensIndexerSchema.label, - eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.v1Domain.labelHash), - ), - ensDb - .select({ - domainId: sql`${ensIndexerSchema.v2Domain.id}`.as("domainId"), - ownerId: sql`${ensIndexerSchema.v2Domain.ownerId}`.as("ownerId"), - registryId: sql`${ensIndexerSchema.v2Domain.registryId}`.as("registryId"), - parentId: sql`${v2ParentDomain.id}`.as("parentId"), - labelHash: sql`${ensIndexerSchema.v2Domain.labelHash}`.as("labelHash"), - sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( - "sortableLabel", - ), - }) - .from(ensIndexerSchema.v2Domain) - // derive v2 parentId via canonical registry traversal: - // 1. find the canonical domain for this domain's registry + .from(ensIndexerSchema.domain) + // parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain. + // The `parentDomain.subregistryId = domain.registryId` clause performs edge authentication. .leftJoin( ensIndexerSchema.registryCanonicalDomain, - eq( - ensIndexerSchema.registryCanonicalDomain.registryId, - ensIndexerSchema.v2Domain.registryId, - ), + eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), ) - // 2. verify the reverse pointer: parent.id = rcd.domainId AND parent.subregistryId = child.registryId .leftJoin( - v2ParentDomain, + parentDomain, and( - eq(v2ParentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), - eq(v2ParentDomain.subregistryId, ensIndexerSchema.v2Domain.registryId), + eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), + eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId), ), ) + // join label for labelHash/sortableLabel .leftJoin( ensIndexerSchema.label, - eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.v2Domain.labelHash), - ), - ).as("baseDomains"); + eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.domain.labelHash), + ) + .as("baseDomains") + ); } /** 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 bc5d63213..72918f826 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 @@ -1,4 +1,4 @@ -import { eq, isNotNull, isNull, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { ensDb } from "@/lib/ensdb/singleton"; @@ -7,12 +7,6 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** * Filter a base domain set to only include Canonical Domains. - * - * All v1Domains are Canonical (registryId IS NULL). - * v2Domains are Canonical iff their registryId is reachable from the ENSv2 Root Registry. - * - * Uses LEFT JOIN with canonical registries CTE: v1 domains pass through (registryId IS NULL), - * v2 domains must match a canonical registry. */ export function filterByCanonical(base: BaseDomainSet) { const canonicalRegistries = getCanonicalRegistriesCTE(); @@ -20,12 +14,6 @@ export function filterByCanonical(base: BaseDomainSet) { return ensDb .select(selectBase(base)) .from(base) - .leftJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId)) - .where( - or( - isNull(base.registryId), // v1 domains are always canonical - isNotNull(canonicalRegistries.id), // v2 domains must be in a canonical registry - ), - ) + .innerJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId)) .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 30b1d8239..d950569c8 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 @@ -1,9 +1,7 @@ import { eq, like, Param, sql } from "drizzle-orm"; -import { alias, unionAll } from "drizzle-orm/pg-core"; +import { alias } from "drizzle-orm/pg-core"; import { type DomainId, - type ENSv1DomainId, - type ENSv2DomainId, interpretedLabelsToLabelHashPath, type LabelHashPath, parsePartialInterpretedName, @@ -19,165 +17,90 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; const FILTER_BY_NAME_MAX_DEPTH = 8; /** - * Compose a query for v1Domains that have the specified children path. + * Compose a query for Domains (ENSv1 or ENSv2) that have the specified children path. * * For a search like "sub1.sub2.paren": * - concrete = ["sub1", "sub2"] - * - partial = 'paren' - * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] + * - partial = "paren" + * - labelHashPath = [labelhash("sub2"), labelhash("sub1")] * - * We find v1Domains matching the concrete path and return both: - * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check + * We find Domains matching the concrete path and return both: + * - 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 to find the head. - * This is more efficient than starting from all domains and traversing down. + * Algorithm: Start from the deepest child (leaf) and traverse UP via {@link registryCanonicalDomain}. */ -function v1DomainsByLabelHashPath(labelHashPath: LabelHashPath) { +function domainsByLabelHashPath(labelHashPath: LabelHashPath) { // If no concrete path, return all domains (leaf = head = self) // Postgres will optimize this simple subquery when joined if (labelHashPath.length === 0) { return ensDb .select({ - leafId: sql`${ensIndexerSchema.v1Domain.id}`.as("leafId"), - headId: sql`${ensIndexerSchema.v1Domain.id}`.as("headId"), + leafId: sql`${ensIndexerSchema.domain.id}`.as("leafId"), + headId: sql`${ensIndexerSchema.domain.id}`.as("headId"), }) - .from(ensIndexerSchema.v1Domain) - .as("v1_path"); + .from(ensIndexerSchema.domain) + .as("domain_path"); } // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; - // Use a recursive CTE starting from the deepest child and traversing UP - // The query: - // 1. Starts with domains matching the leaf labelHash (deepest child) - // 2. Recursively joins parents, verifying each ancestor's labelHash - // 3. Returns both the leaf (for result/ownership) and head (for partial match) + // Recursive CTE starting from the deepest child and traversing UP via registryCanonicalDomain. + // 1. Start with domains matching the leaf labelHash (deepest child) + // 2. Recursively join parents via rcd, 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. return ensDb .select({ // https://github.com/drizzle-team/drizzle-orm/issues/1242 - leafId: sql`v1_path_check.leaf_id`.as("leafId"), - headId: sql`v1_path_check.head_id`.as("headId"), + leafId: sql`domain_path_check.leaf_id`.as("leafId"), + headId: sql`domain_path_check.head_id`.as("headId"), }) .from( sql`( WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) + -- 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. SELECT d.id AS leaf_id, - d.parent_id AS current_id, + parent.id AS current_id, 1 AS depth - FROM ${ensIndexerSchema.v1Domain} d - WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] - - UNION ALL - - -- Recursive step: traverse UP, verifying each ancestor's labelHash - SELECT - upward_check.leaf_id, - pd.parent_id AS current_id, - upward_check.depth + 1 - FROM upward_check - JOIN ${ensIndexerSchema.v1Domain} pd - ON pd.id = upward_check.current_id - WHERE upward_check.depth < ${pathLength} - AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] - ) - SELECT leaf_id, current_id AS head_id - FROM upward_check - WHERE depth = ${pathLength} - ) AS v1_path_check`, - ) - .as("v1_path"); -} - -/** - * Compose a query for v2Domains that have the specified children path. - * - * For a search like "sub1.sub2.paren": - * - concrete = ["sub1", "sub2"] - * - partial = 'paren' - * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] - * - * We find v2Domains matching the concrete path and return both: - * - 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 registryCanonicalDomain. - * For v2, parent relationship is: domain.registryId -> registryCanonicalDomain -> parent domainId - */ -function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { - // If no concrete path, return all domains (leaf = head = self) - // Postgres will optimize this simple subquery when joined - if (labelHashPath.length === 0) { - return ensDb - .select({ - leafId: sql`${ensIndexerSchema.v2Domain.id}`.as("leafId"), - headId: sql`${ensIndexerSchema.v2Domain.id}`.as("headId"), - }) - .from(ensIndexerSchema.v2Domain) - .as("v2_path"); - } - - // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 - const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; - const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; - - // Use a recursive CTE starting from the deepest child and traversing UP - // The query: - // 1. Starts with domains matching the leaf labelHash (deepest child) - // 2. Recursively joins parents via registryCanonicalDomain, verifying each ancestor's labelHash - // 3. Returns both the leaf (for result/ownership) and head (for partial match) - return ensDb - .select({ - // https://github.com/drizzle-team/drizzle-orm/issues/1242 - leafId: sql`v2_path_check.leaf_id`.as("leafId"), - headId: sql`v2_path_check.head_id`.as("headId"), - }) - .from( - sql`( - WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) - -- and get their parent via registryCanonicalDomain - -- Note: JOIN (not LEFT JOIN) is intentional - we only match domains - -- with a complete canonical path to the searched FQDN - SELECT - d.id AS leaf_id, - rcd.domain_id AS current_id, - 1 AS depth - FROM ${ensIndexerSchema.v2Domain} d + FROM ${ensIndexerSchema.domain} d JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd ON rcd.registry_id = d.registry_id - JOIN ${ensIndexerSchema.v2Domain} rcd_parent - ON rcd_parent.id = rcd.domain_id AND rcd_parent.subregistry_id = d.registry_id + JOIN ${ensIndexerSchema.domain} parent + ON parent.id = rcd.domain_id AND parent.subregistry_id = d.registry_id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL - -- Recursive step: traverse UP via registryCanonicalDomain - -- Note: JOIN (not LEFT JOIN) is intentional - see base case comment + -- Recursive step: traverse UP via registryCanonicalDomain, verifying each ancestor's + -- labelHash. The np.subregistry_id = pd.registry_id clause performs edge authentication. SELECT upward_check.leaf_id, - rcd.domain_id AS current_id, + np.id AS current_id, upward_check.depth + 1 FROM upward_check - JOIN ${ensIndexerSchema.v2Domain} pd + JOIN ${ensIndexerSchema.domain} pd ON pd.id = upward_check.current_id JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd ON rcd.registry_id = pd.registry_id - JOIN ${ensIndexerSchema.v2Domain} rcd_parent - ON rcd_parent.id = rcd.domain_id AND rcd_parent.subregistry_id = pd.registry_id + JOIN ${ensIndexerSchema.domain} np + ON np.id = rcd.domain_id AND np.subregistry_id = pd.registry_id WHERE upward_check.depth < ${pathLength} AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] ) SELECT leaf_id, current_id AS head_id FROM upward_check WHERE depth = ${pathLength} - ) AS v2_path_check`, + ) AS domain_path_check`, ) - .as("v2_path"); + .as("domain_path"); } /** @@ -213,35 +136,16 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { .as("baseDomains"); } - // Build path traversal CTEs for both v1 and v2 domains + // Build path traversal CTE over the unified `domain` table. const labelHashPath = interpretedLabelsToLabelHashPath(concrete); - const v1Path = v1DomainsByLabelHashPath(labelHashPath); - const v2Path = v2DomainsByLabelHashPath(labelHashPath); + const pathResults = domainsByLabelHashPath(labelHashPath); - // Union path results into a single set of {leafId, headId} - const pathResults = unionAll( - ensDb - .select({ - leafId: sql`${v1Path.leafId}`.as("leafId"), - headId: sql`${v1Path.headId}`.as("headId"), - }) - .from(v1Path), - ensDb - .select({ - leafId: sql`${v2Path.leafId}`.as("leafId"), - headId: sql`${v2Path.headId}`.as("headId"), - }) - .from(v2Path), - ).as("pathResults"); - - // Aliases for head domain lookup (to get headLabelHash for label join) - const v1HeadDomain = alias(ensIndexerSchema.v1Domain, "v1HeadDomain"); - const v2HeadDomain = alias(ensIndexerSchema.v2Domain, "v2HeadDomain"); + // 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. - // LEFT JOINs on head domains: exactly one will match (v1 or v2). return ensDb .select({ ...selectBase(base), @@ -250,12 +154,8 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { }) .from(base) .innerJoin(pathResults, eq(pathResults.leafId, base.domainId)) - .leftJoin(v1HeadDomain, eq(v1HeadDomain.id, pathResults.headId)) - .leftJoin(v2HeadDomain, eq(v2HeadDomain.id, pathResults.headId)) - .leftJoin( - headLabel, - sql`${headLabel.labelHash} = COALESCE(${v1HeadDomain.labelHash}, ${v2HeadDomain.labelHash})`, - ) + .leftJoin(headDomain, eq(headDomain.id, pathResults.headId)) + .leftJoin(headLabel, eq(headLabel.labelHash, headDomain.labelHash)) .where( // TODO: determine if it's necessary to additionally escape user input for LIKE operator // NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts index 10c489889..1e7f8210d 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts @@ -7,9 +7,6 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** * Filter a base domain set to children of a specific parent domain. - * - * Works uniformly for v1 and v2 domains because the base domain set derives - * parentId for both: v1 from the parentId column, v2 via canonical registry traversal. */ export function filterByParent(base: BaseDomainSet, parentId: DomainId) { return ensDb diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts index 8156f3b9a..4ee3394fe 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts @@ -6,10 +6,7 @@ import { ensDb } from "@/lib/ensdb/singleton"; import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** - * Filter a base domain set to domains belonging to a specific registry. - * - * Only v2 domains have a non-NULL registryId, so this effectively filters to v2 domains - * in the given registry. + * Filter a base domain set to domains belonging to a specific Registry. */ export function filterByRegistry(base: BaseDomainSet, registryId: RegistryId) { 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 1fc25c60f..5b268f995 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -1,85 +1,25 @@ import config from "@/config"; import { sql } from "drizzle-orm"; -import { - type CanonicalPath, - type DomainId, - ENS_ROOT_NODE, - type ENSv1DomainId, - type ENSv2DomainId, - type RegistryId, -} from "enssdk"; +import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; -import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getRootRegistryIds } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -import { lazy } from "@/lib/lazy"; const MAX_DEPTH = 16; -// lazy() defers construction until first use so that this module can be -// imported without env vars being present (e.g. during OpenAPI generation). -const getENSv2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); /** - * Provide the canonical parents for an ENSv1 Domain. + * Provide the canonical parents for a Domain via reverse traversal of the namegraph. * - * i.e. reverse traversal of the nametree + * 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). */ -export async function getV1CanonicalPath(domainId: ENSv1DomainId): Promise { - const result = await ensDb.execute(sql` - WITH RECURSIVE upward AS ( - -- Base case: start from the target domain - SELECT - d.id AS domain_id, - d.parent_id, - d.label_hash, - 1 AS depth - FROM ${ensIndexerSchema.v1Domain} d - WHERE d.id = ${domainId} - - UNION ALL - - -- Step upward: domain -> parent domain - SELECT - pd.id AS domain_id, - pd.parent_id, - pd.label_hash, - upward.depth + 1 - FROM upward - JOIN ${ensIndexerSchema.v1Domain} pd - ON pd.id = upward.parent_id - WHERE upward.depth < ${MAX_DEPTH} - ) - SELECT * - FROM upward - ORDER BY depth; - `); - - const rows = result.rows as { domain_id: ENSv1DomainId; parent_id: ENSv1DomainId }[]; - - if (rows.length === 0) { - throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`); - } - - // v1Domains are canonical if the TLD's parent is ENS_ROOT_NODE (ENS_ROOT_NODE itself does not exist in the index) - const tld = rows[rows.length - 1]; - const isCanonical = tld.parent_id === ENS_ROOT_NODE; - - if (!isCanonical) return null; - - return rows.map((row) => row.domain_id); -} - -/** - * Provide the canonical parents for an ENSv2 Domain. - * - * i.e. reverse traversal of the namegraph via registry_canonical_domains - */ -export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise { - const rootRegistryId = getENSv2RootRegistryId(); - - // if the ENSv2 Root Registry is not defined, null - if (!rootRegistryId) return null; +export async function getCanonicalPath(domainId: DomainId): Promise { + const rootRegistryIds = getRootRegistryIds(config.namespace); const result = await ensDb.execute(sql` WITH RECURSIVE upward AS ( @@ -87,26 +27,26 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise registry -> canonical parent domain + -- Step upward: domain -> current registry's canonical domain (parent). + -- Recursion terminates naturally: roots have no registryCanonicalDomain entry, so the + -- JOIN on rcd fails when we reach one. MAX_DEPTH guards against corrupted state. The + -- pd.subregistry_id = upward.registry_id clause performs edge authentication. SELECT pd.id AS domain_id, pd.registry_id, - pd.label_hash, upward.depth + 1 FROM upward JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd ON rcd.registry_id = upward.registry_id - JOIN ${ensIndexerSchema.v2Domain} pd + JOIN ${ensIndexerSchema.domain} pd ON pd.id = rcd.domain_id AND pd.subregistry_id = upward.registry_id - WHERE upward.registry_id != ${rootRegistryId} - AND upward.depth < ${MAX_DEPTH} + WHERE upward.depth < ${MAX_DEPTH} ) SELECT * FROM upward @@ -119,8 +59,9 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise maybeGetENSv2RootRegistryId(config.namespace)); - const tracer = trace.getTracer("get-domain-by-interpreted-name"); const logger = makeLogger("get-domain-by-interpreted-name"); -const v1Logger = makeLogger("get-domain-by-interpreted-name:v1"); -const v2Logger = makeLogger("get-domain-by-interpreted-name:v2"); /** * Domain lookup by Interpreted Name via forward traversal of the namegraph. @@ -67,47 +56,39 @@ export async function getDomainIdByInterpretedName( name: InterpretedName, ): Promise { return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, async () => { - // Domains addressable in v2 are preferred, but v1 lookups are cheap, so just do them both ahead of time - const rootRegistryId = _maybeGetENSv2RootRegistryId(); - - const [v1DomainId, v2DomainId] = await Promise.all([ - withActiveSpanAsync(tracer, "v1_getDomainId", {}, () => - v1_getDomainIdByInterpretedName(name), + // Traverse from every top-level Root Registry in parallel. ENSv1 is multi-rooted on disk — + // each concrete ENSv1Registry (ENSRoot, Basenames, Lineanames) owns its own subtree and is + // not linked to the others at the indexed-namegraph level. + const roots = getRootRegistryIds(config.namespace); + const v2Root = maybeGetENSv2RootRegistryId(config.namespace); + + const results = await Promise.all( + roots.map((root) => + withActiveSpanAsync(tracer, "traverseFromRoot", { root }, () => + traverseFromRoot(root, name), + ), ), - // only resolve v2Domain if ENSv2 Root Registry is defined - rootRegistryId - ? withActiveSpanAsync(tracer, "v2_getDomainId", {}, () => - v2_getDomainIdByInterpretedName(rootRegistryId, name), - ) - : null, - ]); - - logger.debug({ v1DomainId, v2DomainId }); - - // prefer v2Domain over v1Domain - return v2DomainId || v1DomainId || null; - }); -} - -/** - * Retrieves the ENSv1DomainId for the provided `name`, if exists. - */ -async function v1_getDomainIdByInterpretedName(name: InterpretedName): Promise { - const domainId = makeENSv1DomainId(namehashInterpretedName(name)); + ); - const domain = await ensDb.query.v1Domain.findFirst({ where: (t, { eq }) => eq(t.id, domainId) }); - const exists = domain !== undefined; + logger.debug({ roots, results }); - v1Logger.debug({ domainId, exists }); - - return exists ? domainId : null; + // prefer the v2 Root's result when present, otherwise the first non-null hit from any v1 root. + const v2Index = v2Root ? roots.indexOf(v2Root) : -1; + const v2Hit = v2Index >= 0 ? results[v2Index] : null; + return v2Hit ?? results.find((r): r is DomainId => r !== null) ?? null; + }); } /** - * Forward-traverses the ENSv2 namegraph from the specified root in order to identify the Domain - * addressed by `name`. + * Forward-traverses the namegraph from `rootRegistryId`, one label at a time, using the unified + * `domain.subregistryId` pointer to hop from a parent Domain to the Registry its subnames live in. + * + * Both ENSv1 and ENSv2 Domains set `subregistryId` — ENSv1 Domains to their managed ENSv1 + * VirtualRegistry (set on first-child indexing), ENSv2 Domains to their declared Subregistry — so + * a single recursive CTE handles both lineages. The starting root picks which lineage: v1 and v2 + * registry IDs are disjoint, so there is no cross-contamination. */ -async function v2_getDomainIdByInterpretedName( +async function traverseFromRoot( rootRegistryId: RegistryId, name: InterpretedName, ): Promise { @@ -116,28 +97,22 @@ async function v2_getDomainIdByInterpretedName( // https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; - // TODO: need to join latest registration and confirm that it's not expired, if expired should treat the domain as not existing - const result = await ensDb.execute(sql` WITH RECURSIVE path AS ( SELECT - r.id AS registry_id, + ${rootRegistryId}::text AS next_registry_id, NULL::text AS domain_id, - NULL::text AS label_hash, 0 AS depth - FROM ${ensIndexerSchema.registry} r - WHERE r.id = ${rootRegistryId} UNION ALL SELECT - d.subregistry_id AS registry_id, + d.subregistry_id AS next_registry_id, d.id AS domain_id, - d.label_hash, path.depth + 1 FROM path - JOIN ${ensIndexerSchema.v2Domain} d - ON d.registry_id = path.registry_id + JOIN ${ensIndexerSchema.domain} d + ON d.registry_id = path.next_registry_id WHERE d.label_hash = (${rawLabelHashPathArray})[path.depth + 1] AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) ) @@ -147,30 +122,13 @@ async function v2_getDomainIdByInterpretedName( ORDER BY depth; `); - // couldn't for the life of me figure out how to type this result this correctly within drizzle... - const rows = result.rows as { - registry_id: RegistryId; - domain_id: ENSv2DomainId; - label_hash: LabelHash; - depth: number; - }[]; + const rows = result.rows as { domain_id: DomainId; depth: number }[]; - // this was a query for a TLD and it does not exist within the ENSv2 namegraph - if (rows.length === 0) { - v2Logger.debug({ labelHashPath, rows }); - return null; - } + if (rows.length === 0) return null; // biome-ignore lint/style/noNonNullAssertion: length check above const leaf = rows[rows.length - 1]!; - - // the v2Domain was found iff there is an exact match within the ENSv2 namegraph const exact = rows.length === labelHashPath.length; - v2Logger.debug({ labelHashPath, rows, exact }); - - if (exact) return leaf.domain_id; - - // otherwise, the v2 domain was not found - return null; + return exact ? leaf.domain_id : null; } 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 b9ee706b8..c08a2b34e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,4 +1,4 @@ -import type { InterpretedLabel, InterpretedName } from "enssdk"; +import type { DomainId, InterpretedLabel, InterpretedName } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names"; @@ -52,6 +52,69 @@ describe("Domain.subdomains", () => { }); }); +describe("Domain.path", () => { + type DomainPathResult = { + domain: { + id: DomainId; + path: { id: DomainId; name: InterpretedName | null }[] | null; + } | null; + }; + + const DomainPath = gql` + query DomainPath($name: InterpretedName!) { + domain(by: { name: $name }) { + id + path { + id + name + } + } + } + `; + + it("returns the full canonical path (leaf → root) for a deep name", async () => { + const result = await request(DomainPath, { + name: "wallet.linked.parent.eth", + }); + + expect(result.domain).not.toBeNull(); + const path = result.domain?.path; + expect(path).not.toBeNull(); + + const pathNames = (path ?? []).map((d) => d.name); + expect(pathNames).toEqual([ + "wallet.linked.parent.eth", + "linked.parent.eth", + "parent.eth", + "eth", + ]); + }); + + it("collapses aliases to their canonical path", async () => { + // `wallet.sub1.sub2.parent.eth` is an alias: `sub1.sub2.parent.eth`'s subregistry was + // re-pointed to the registry managed by `linked.parent.eth`. The canonical path must + // walk through `linked.parent.eth`, NOT `sub1.sub2.parent.eth` — edge-authentication + // in the reverse walk must reject the stale `registryCanonicalDomain` edge. + const aliasResult = await request(DomainPath, { + name: "wallet.sub1.sub2.parent.eth", + }); + const canonicalResult = await request(DomainPath, { + name: "wallet.linked.parent.eth", + }); + + 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"); + }); +}); + describe("Domain.subdomains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 31d98b1c7..ea0e147fc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,12 +1,9 @@ import { trace } from "@opentelemetry/api"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import { - type DomainId, - type ENSv1DomainId, - type ENSv2DomainId, - interpretedLabelsToInterpretedName, -} from "enssdk"; +import { type DomainId, interpretedLabelsToInterpretedName } from "enssdk"; + +import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -41,20 +38,19 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { OrderDirection } from "@/omnigraph-api/schema/order-direction"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; -import { RegistryRef } from "@/omnigraph-api/schema/registry"; +import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; import { ResolverRef } from "@/omnigraph-api/schema/resolver"; const tracer = trace.getTracer("schema/Domain"); -const isENSv1Domain = (domain: Domain): domain is ENSv1Domain => "parentId" in domain; -///////////////////////////// -// ENSv1Domain & ENSv2Domain -///////////////////////////// +/////////////////////////////// +// Loadable Interface (Domain) +/////////////////////////////// -export const ENSv1DomainRef = builder.loadableObjectRef("ENSv1Domain", { - load: (ids: ENSv1DomainId[]) => - withSpanAsync(tracer, "ENSv1Domain.load", { count: ids.length }, () => - ensDb.query.v1Domain.findMany({ +export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { + load: (ids: DomainId[]) => + withSpanAsync(tracer, "Domain.load", { count: ids.length }, () => + ensDb.query.domain.findMany({ where: (t, { inArray }) => inArray(t.id, ids), with: { label: true }, }), @@ -64,42 +60,21 @@ export const ENSv1DomainRef = builder.loadableObjectRef("ENSv1Domain", { sort: true, }); -export const ENSv2DomainRef = builder.loadableObjectRef("ENSv2Domain", { - load: (ids: ENSv2DomainId[]) => - withSpanAsync(tracer, "ENSv2Domain.load", { count: ids.length }, () => - ensDb.query.v2Domain.findMany({ - where: (t, { inArray }) => inArray(t.id, ids), - with: { label: true }, - }), - ), - toKey: getModelId, - cacheResolved: true, - sort: true, -}); +export type Domain = Exclude; +export type DomainInterface = Omit; +export type ENSv1Domain = RequiredAndNotNull & + RequiredAndNull & { type: "ENSv1Domain" }; +export type ENSv2Domain = RequiredAndNotNull & + RequiredAndNull & { type: "ENSv2Domain" }; -export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { - load: async (ids: DomainId[]): Promise<(ENSv1Domain | ENSv2Domain)[]> => { - const [v1Domains, v2Domains] = await Promise.all([ - ensDb.query.v1Domain.findMany({ - where: (t, { inArray }) => inArray(t.id, ids as any), // ignore downcast to ENSv1DomainId - with: { label: true }, - }), - ensDb.query.v2Domain.findMany({ - where: (t, { inArray }) => inArray(t.id, ids as any), // ignore downcast to ENSv2DomainId - with: { label: true }, - }), - ]); +export const isENSv1Domain = (domain: DomainInterface): domain is ENSv1Domain => + domain.type === "ENSv1Domain"; - return [...v1Domains, ...v2Domains]; - }, - toKey: getModelId, - cacheResolved: true, - sort: true, -}); +export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => + domain.type === "ENSv2Domain"; -export type ENSv1Domain = Exclude; -export type ENSv2Domain = Exclude; -export type Domain = Exclude; +export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); +export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); ////////////////////////////////// // DomainInterface Implementation @@ -137,14 +112,12 @@ DomainInterfaceRef.implement({ tracing: true, type: "InterpretedName", nullable: true, - resolve: async (domain, args, context) => { - const canonicalPath = isENSv1Domain(domain) - ? await context.loaders.v1CanonicalPath.load(domain.id) - : await context.loaders.v2CanonicalPath.load(domain.id); - if (!canonicalPath) return null; - - // TODO: this could be more efficient if the get*CanonicalPath helpers included the label - // join for us. + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); + if (canonicalPath instanceof Error) throw canonicalPath; + if (canonicalPath === null) return null; + + // TODO: this could be more efficient if getCanonicalPath included the label join for us. const domains = await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), ); @@ -169,15 +142,14 @@ DomainInterfaceRef.implement({ /////////////// path: t.field({ description: - "The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical.", + "The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical.", tracing: true, type: [DomainInterfaceRef], nullable: true, - resolve: async (domain, args, context) => { - const canonicalPath = isENSv1Domain(domain) - ? await context.loaders.v1CanonicalPath.load(domain.id) - : await context.loaders.v2CanonicalPath.load(domain.id); - if (!canonicalPath) return null; + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); + if (canonicalPath instanceof Error) throw canonicalPath; + if (canonicalPath === null) return null; return await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), @@ -185,6 +157,21 @@ DomainInterfaceRef.implement({ }, }), + ///////////////// + // Domain.parent + ///////////////// + 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.", + type: DomainInterfaceRef, + nullable: true, + resolve: async (domain, _args, context) => { + const path = await context.loaders.canonicalPath.load(domain.id); + if (path instanceof Error) throw path; + return path?.[1] ?? null; + }, + }), + //////////////// // Domain.owner //////////////// @@ -299,16 +286,16 @@ DomainInterfaceRef.implement({ ENSv1DomainRef.implement({ description: "An ENSv1Domain represents an ENSv1 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => isENSv1Domain(domain as Domain), + isTypeOf: (domain) => isENSv1Domain(domain as DomainInterface), fields: (t) => ({ - ////////////////////// - // ENSv1Domain.parent - ////////////////////// - parent: t.field({ - description: "The parent Domain of this Domain in the ENSv1 nametree.", - type: ENSv1DomainRef, - nullable: true, - resolve: (parent) => parent.parentId, + /////////////////// + // ENSv1Domain.node + /////////////////// + node: t.field({ + description: "The namehash of this ENSv1 Domain.", + type: "Node", + nullable: false, + resolve: (parent) => parent.node, }), ///////////////////////////////// @@ -330,10 +317,10 @@ ENSv1DomainRef.implement({ ENSv2DomainRef.implement({ description: "An ENSv2Domain represents an ENSv2 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => !isENSv1Domain(domain as Domain), + isTypeOf: (domain) => isENSv2Domain(domain as DomainInterface), fields: (t) => ({ ////////////////////// - // Domain.tokenId + // ENSv2Domain.tokenId ////////////////////// tokenId: t.field({ description: "The ENSv2Domain's current Token Id.", @@ -342,21 +329,21 @@ ENSv2DomainRef.implement({ resolve: (parent) => parent.tokenId, }), - ////////////////////// - // Domain.registry - ////////////////////// + /////////////////////// + // ENSv2Domain.registry + /////////////////////// registry: t.field({ description: "The Registry under which this ENSv2Domain exists.", - type: RegistryRef, + type: RegistryInterfaceRef, nullable: false, resolve: (parent) => parent.registryId, }), - ////////////////////// - // Domain.subregistry - ////////////////////// + ////////////////////////// + // ENSv2Domain.subregistry + ////////////////////////// subregistry: t.field({ - type: RegistryRef, + type: RegistryInterfaceRef, description: "The Registry this ENSv2Domain declares as its Subregistry, if exists.", nullable: true, resolve: (parent) => parent.subregistryId, 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 20a3da699..137e40e1e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -1,15 +1,16 @@ import { asInterpretedLabel, - asInterpretedName, type DomainId, + ETH_NODE, type InterpretedLabel, labelhashInterpretedLabel, makeENSv1DomainId, + makeENSv1RegistryId, makeENSv2DomainId, makeStorageId, type Name, + type Node, type NormalizedAddress, - namehashInterpretedName, } from "enssdk"; import { describe, expect, it } from "vitest"; @@ -38,20 +39,45 @@ const V2_ROOT_REGISTRY = getDatasourceContract( "RootRegistry", ); -const V1_ETH_DOMAIN_ID = makeENSv1DomainId(namehashInterpretedName(asInterpretedName("eth"))); +const V1_ROOT_REGISTRY = getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); + +const V1_ETH_DOMAIN_ID = makeENSv1DomainId(V1_ROOT_REGISTRY, ETH_NODE); const V2_ETH_STORAGE_ID = makeStorageId(labelhashInterpretedLabel(asInterpretedLabel("eth"))); const V2_ETH_DOMAIN_ID = makeENSv2DomainId(V2_ROOT_REGISTRY, V2_ETH_STORAGE_ID); describe("Query.root", () => { - it("returns the root registry", async () => { - await expect(request(gql`{ root { id } }`)).resolves.toMatchObject({ + it("returns the v2 root registry when v2 is defined (preferred over v1)", async () => { + await expect(request(gql`{ root { __typename id } }`)).resolves.toMatchObject({ root: { + __typename: "ENSv2Registry", id: getENSv2RootRegistryId(namespace), }, }); }); }); +describe("Query.registry polymorphism", () => { + const RegistryByContract = gql` + query RegistryByContract($contract: AccountIdInput!) { + registry(by: { contract: $contract }) { + __typename + id + } + } + `; + + it("returns an ENSv1Registry for the devnet ENSv1Registry contract", async () => { + await expect( + request(RegistryByContract, { contract: V1_ROOT_REGISTRY }), + ).resolves.toMatchObject({ + registry: { + __typename: "ENSv1Registry", + id: makeENSv1RegistryId(V1_ROOT_REGISTRY), + }, + }); + }); +}); + describe("Query.domains", () => { type QueryDomainsResult = { domains: GraphQLConnection<{ @@ -60,6 +86,7 @@ describe("Query.domains", () => { name: Name; label: { interpreted: InterpretedLabel }; owner: { address: NormalizedAddress }; + node?: Node; }>; }; @@ -77,6 +104,9 @@ describe("Query.domains", () => { owner { address } + ... on ENSv1Domain { + node + } } } } @@ -104,6 +134,8 @@ describe("Query.domains", () => { id: V1_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, + // ENSv1Domain exposes `node` — the namehash of the canonical name + node: ETH_NODE, }); expect(v2EthDomain).toMatchObject({ @@ -112,6 +144,27 @@ describe("Query.domains", () => { label: { interpreted: "eth" }, }); }); + + it("filters by canonical", async () => { + const result = await request(QueryDomains, { + name: "parent", + canonical: true, + }); + + const domains = flattenConnection(result.domains); + + // parent.eth is canonical (registered under the v2 ETH Registry which descends from the v2 Root) + const parentEth = domains.find((d) => d.name === "parent.eth"); + expect(parentEth).toBeDefined(); + + // every returned domain must have a defined canonical `name` (only canonical domains resolve one) + for (const d of domains) { + expect(d.name, `expected canonical name for ${d.id}`).toBeTruthy(); + } + }); + + // TODO: devnet fixture needs a known non-canonical Domain to assert exclusion against. + it.todo("excludes non-canonical domains when `canonical: true` is 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 58148775e..4e5df509f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -1,9 +1,9 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { makePermissionsId, makeRegistryId, makeResolverId } from "enssdk"; +import { makeConcreteRegistryId, makePermissionsId, makeResolverId } from "enssdk"; -import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getRootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; @@ -24,12 +24,10 @@ import { DomainInterfaceRef, DomainsOrderInput, DomainsWhereInput, - ENSv1DomainRef, - ENSv2DomainRef, } from "@/omnigraph-api/schema/domain"; import { PermissionsIdInput, PermissionsRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; -import { RegistryIdInput, RegistryRef } from "@/omnigraph-api/schema/registry"; +import { RegistryIdInput, RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; import { ResolverIdInput, ResolverRef } from "@/omnigraph-api/schema/resolver"; // don't want them to get familiar/accustomed to these methods until their necessity is certain @@ -38,45 +36,22 @@ const INCLUDE_DEV_METHODS = process.env.NODE_ENV !== "production"; builder.queryType({ fields: (t) => ({ ...(INCLUDE_DEV_METHODS && { - ///////////////////////////// - // Query.v1Domains (Testing) - ///////////////////////////// - v1Domains: t.connection({ + ////////////////////////////// + // Query.allDomains (Testing) + ////////////////////////////// + allDomains: t.connection({ description: "TODO", - type: ENSv1DomainRef, + type: DomainInterfaceRef, resolve: (parent, args) => lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.v1Domain), + totalCount: () => ensDb.$count(ensIndexerSchema.domain), connection: () => resolveCursorConnection( { ...ID_PAGINATED_CONNECTION_ARGS, args }, ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb.query.v1Domain.findMany({ - where: paginateBy(ensIndexerSchema.v1Domain.id, before, after), - orderBy: orderPaginationBy(ensIndexerSchema.v1Domain.id, inverted), - limit, - with: { label: true }, - }), - ), - }), - }), - - ///////////////////////////// - // Query.v2Domains (Testing) - ///////////////////////////// - v2Domains: t.connection({ - description: "TODO", - type: ENSv2DomainRef, - resolve: (parent, args) => - lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.v2Domain), - connection: () => - resolveCursorConnection( - { ...ID_PAGINATED_CONNECTION_ARGS, args }, - ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb.query.v2Domain.findMany({ - where: paginateBy(ensIndexerSchema.v2Domain.id, before, after), - orderBy: orderPaginationBy(ensIndexerSchema.v2Domain.id, inverted), + ensDb.query.domain.findMany({ + where: () => paginateBy(ensIndexerSchema.domain.id, before, after), + orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), limit, with: { label: true }, }), @@ -179,13 +154,12 @@ builder.queryType({ // Get Registry by Id or AccountId /////////////////////////////////// registry: t.field({ - description: "Identify a Registry by ID or AccountId.", - type: RegistryRef, + description: + "Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned.", + type: RegistryInterfaceRef, + nullable: true, args: { by: t.arg({ type: RegistryIdInput, required: true }) }, - resolve: (parent, args, context, info) => { - if (args.by.id !== undefined) return args.by.id; - return makeRegistryId(args.by.contract); - }, + resolve: (parent, args) => args.by.id ?? makeConcreteRegistryId(args.by.contract), }), /////////////////////////////////// @@ -218,11 +192,11 @@ builder.queryType({ // Get Root Registry ///////////////////// root: t.field({ - description: "The ENSv2 Root Registry, if exists.", - type: RegistryRef, - // TODO: make this nullable: false after all namespaces define ENSv2Root - nullable: true, - resolve: () => maybeGetENSv2RootRegistryId(config.namespace), + description: + "The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined or the ENSv1 Root Registry.", + type: RegistryInterfaceRef, + nullable: false, + resolve: () => getRootRegistryId(config.namespace), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/registration.ts b/apps/ensapi/src/omnigraph-api/schema/registration.ts index 87a69e8fe..a0a53b436 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registration.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registration.ts @@ -1,6 +1,6 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, eq } from "drizzle-orm"; -import type { ENSv1DomainId, RegistrationId } from "enssdk"; +import type { RegistrationId } from "enssdk"; import { hexToBigInt } from "viem"; import { @@ -21,7 +21,7 @@ import { PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; -import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; +import { DomainInterfaceRef, isENSv1Domain } from "@/omnigraph-api/schema/domain"; import { EventRef } from "@/omnigraph-api/schema/event"; import { RenewalRef } from "@/omnigraph-api/schema/renewal"; @@ -343,8 +343,21 @@ WrappedBaseRegistrarRegistrationRef.implement({ description: "The TokenID for this Domain in the NameWrapper.", type: "BigInt", nullable: false, - // NOTE: only ENSv1 Domains can be wrapped, id is guaranteed to be ENSv1DomainId === Node - resolve: (parent) => hexToBigInt(parent.domainId as ENSv1DomainId), + // Only ENSv1 Domains can be wrapped; the NameWrapper's ERC1155 tokenId is the Domain's node. + resolve: async (parent, _args, ctx) => { + const domain = await DomainInterfaceRef.getDataloader(ctx).load(parent.domainId); + if (!domain) { + throw new Error( + `Invariant(WrappedBaseRegistrarRegistration.tokenId): Domain '${parent.domainId}' not found.`, + ); + } + if (!isENSv1Domain(domain)) { + throw new Error( + `Invariant(WrappedBaseRegistrarRegistration.tokenId): expected ENSv1Domain for domainId '${parent.domainId}', got ${domain.type}.`, + ); + } + return hexToBigInt(domain.node); + }, }), ///////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/registry-permissions-user.ts b/apps/ensapi/src/omnigraph-api/schema/registry-permissions-user.ts index 5344b15b6..8955cebdf 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry-permissions-user.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry-permissions-user.ts @@ -3,7 +3,7 @@ import { makeRegistryId } from "enssdk"; import type { ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { AccountRef } from "@/omnigraph-api/schema/account"; -import { RegistryRef } from "@/omnigraph-api/schema/registry"; +import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; /** * Represents a PermissionsUser whose contract is a Registry, providing a semantic `registry` field. @@ -30,7 +30,7 @@ RegistryPermissionsUserRef.implement({ ///////////////////////////////////// registry: t.field({ description: "The Registry in which this Permission is granted.", - type: RegistryRef, + type: RegistryInterfaceRef, nullable: false, resolve: ({ chainId, address }) => makeRegistryId({ chainId, address }), }), diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index c1f932720..eb9b87be0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -2,6 +2,8 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth import { and, eq } from "drizzle-orm"; import { makePermissionsId, type RegistryId } from "enssdk"; +import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; + import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; @@ -19,12 +21,15 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef, DomainsOrderInput, - ENSv2DomainRef, RegistryDomainsWhereInput, } from "@/omnigraph-api/schema/domain"; import { PermissionsRef } from "@/omnigraph-api/schema/permissions"; -export const RegistryRef = builder.loadableObjectRef("Registry", { +/////////////////////////////////// +// Loadable Interface (Registry) +/////////////////////////////////// + +export const RegistryInterfaceRef = builder.loadableInterfaceRef("Registry", { load: (ids: RegistryId[]) => ensDb.query.registry.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), toKey: getModelId, @@ -32,14 +37,38 @@ export const RegistryRef = builder.loadableObjectRef("Registry", { sort: true, }); -export type Registry = Exclude; +export type Registry = Exclude; +export type RegistryInterface = Omit; +export type ENSv1Registry = RequiredAndNull & { type: "ENSv1Registry" }; +export type ENSv1VirtualRegistry = RequiredAndNotNull & { + type: "ENSv1VirtualRegistry"; +}; +export type ENSv2Registry = RequiredAndNull & { type: "ENSv2Registry" }; + +const isENSv1Registry = (registry: RegistryInterface): registry is ENSv1Registry => + registry.type === "ENSv1Registry"; + +const isENSv1VirtualRegistry = (registry: RegistryInterface): registry is ENSv1VirtualRegistry => + registry.type === "ENSv1VirtualRegistry"; -RegistryRef.implement({ - description: "A Registry represents an ENSv2 Registry contract.", +const isENSv2Registry = (registry: RegistryInterface): registry is ENSv2Registry => + registry.type === "ENSv2Registry"; + +export const ENSv1RegistryRef = builder.objectRef("ENSv1Registry"); +export const ENSv1VirtualRegistryRef = + builder.objectRef("ENSv1VirtualRegistry"); +export const ENSv2RegistryRef = builder.objectRef("ENSv2Registry"); + +///////////////////////////////////// +// RegistryInterface Implementation +///////////////////////////////////// +RegistryInterfaceRef.implement({ + description: + "A Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry.", fields: (t) => ({ - ////////////////////// + ///////////////// // Registry.id - ////////////////////// + ///////////////// id: t.field({ description: "A unique reference to this Registry.", type: "RegistryId", @@ -47,24 +76,35 @@ RegistryRef.implement({ resolve: (parent) => parent.id, }), - //////////////////// + /////////////////// + // Registry.contract + /////////////////// + contract: t.field({ + description: + "Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + + /////////////////// // Registry.parents - //////////////////// + /////////////////// parents: t.connection({ description: "The Domains for which this Registry is a Subregistry.", - type: ENSv2DomainRef, + type: DomainInterfaceRef, resolve: (parent, args) => { - const scope = eq(ensIndexerSchema.v2Domain.subregistryId, parent.id); + const scope = eq(ensIndexerSchema.domain.subregistryId, parent.id); return lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.v2Domain, scope), + totalCount: () => ensDb.$count(ensIndexerSchema.domain, scope), connection: () => resolveCursorConnection( { ...ID_PAGINATED_CONNECTION_ARGS, args }, ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb.query.v2Domain.findMany({ - where: and(scope, paginateBy(ensIndexerSchema.v2Domain.id, before, after)), - orderBy: orderPaginationBy(ensIndexerSchema.v2Domain.id, inverted), + ensDb.query.domain.findMany({ + where: and(scope, paginateBy(ensIndexerSchema.domain.id, before, after)), + orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), limit, with: { label: true }, }), @@ -100,19 +140,49 @@ RegistryRef.implement({ // TODO: render a RegistryPermissions model that parses the backing permissions into registry-semantic roles resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), }), + }), +}); - ///////////////////// - // Registry.contract - ///////////////////// - contract: t.field({ - description: "Contract metadata for this Registry", - type: AccountIdRef, +////////////////////////////// +// ENSv1Registry (concrete) +////////////////////////////// +ENSv1RegistryRef.implement({ + description: + "An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry).", + interfaces: [RegistryInterfaceRef], + isTypeOf: (registry) => isENSv1Registry(registry as RegistryInterface), +}); + +////////////////////////////// +// ENSv1VirtualRegistry +////////////////////////////// +ENSv1VirtualRegistryRef.implement({ + description: + "An ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash.", + interfaces: [RegistryInterfaceRef], + isTypeOf: (registry) => isENSv1VirtualRegistry(registry as RegistryInterface), + fields: (t) => ({ + /////////////////////////////// + // ENSv1VirtualRegistry.node + /////////////////////////////// + node: t.field({ + description: "The namehash of the parent ENSv1 Domain that owns this virtual Registry.", + type: "Node", nullable: false, - resolve: ({ chainId, address }) => ({ chainId, address }), + resolve: (parent) => parent.node, }), }), }); +////////////////////////////// +// ENSv2Registry +////////////////////////////// +ENSv2RegistryRef.implement({ + description: "An ENSv2Registry represents an ENSv2 Registry contract.", + interfaces: [RegistryInterfaceRef], + isTypeOf: (registry) => isENSv2Registry(registry as RegistryInterface), +}); + ////////// // Inputs ////////// diff --git a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts index 78f3f3401..43a921d29 100644 --- a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts @@ -16,8 +16,8 @@ export async function materializeENSv1DomainEffectiveOwner( // ensure owner await ensureAccount(context, owner); - // update v1Domain's effective owner + // update Domain's effective owner await context.ensDb - .update(ensIndexerSchema.v1Domain, { id }) + .update(ensIndexerSchema.domain, { id }) .set({ ownerId: interpretAddress(owner) }); } diff --git a/apps/ensindexer/src/lib/managed-names.test.ts b/apps/ensindexer/src/lib/managed-names.test.ts index 8bfd2c251..c3dbaef35 100644 --- a/apps/ensindexer/src/lib/managed-names.test.ts +++ b/apps/ensindexer/src/lib/managed-names.test.ts @@ -37,6 +37,12 @@ const controller = getDatasourceContract( "LegacyEthRegistrarController", ); +const ensv1Registry = getDatasourceContract( + ENSNamespaceIds.Mainnet, + DatasourceNames.ENSRoot, + "ENSv1Registry", +); + describe("managed-names", () => { beforeEach(() => { vi.resetAllMocks(); @@ -48,19 +54,23 @@ describe("managed-names", () => { it("should cache the result of viem#namehash", () => { expect(spy.mock.calls).toHaveLength(0); - expect(getManagedName(registrar)).toStrictEqual({ name: "eth", node: ETH_NODE }); + expect(getManagedName(registrar)).toMatchObject({ name: "eth", node: ETH_NODE }); // first call should invoke namehash expect(spy.mock.calls).toHaveLength(1); - expect(getManagedName(controller)).toStrictEqual({ name: "eth", node: ETH_NODE }); + expect(getManagedName(controller)).toMatchObject({ name: "eth", node: ETH_NODE }); // second call should not invoke namehash expect(spy.mock.calls).toHaveLength(1); }); - it("should return the managed name and node for the BaseRegistrar contract", () => { - expect(getManagedName(registrar)).toStrictEqual({ name: "eth", node: ETH_NODE }); + it("should return the managed name, node, and registry for the BaseRegistrar contract", () => { + expect(getManagedName(registrar)).toStrictEqual({ + name: "eth", + node: ETH_NODE, + registry: ensv1Registry, + }); }); it("should throw an error for a contract without a managed name", () => { diff --git a/apps/ensindexer/src/lib/managed-names.ts b/apps/ensindexer/src/lib/managed-names.ts index 4a45cdcb9..9ce776956 100644 --- a/apps/ensindexer/src/lib/managed-names.ts +++ b/apps/ensindexer/src/lib/managed-names.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { type AccountId, asInterpretedName, + ENS_ROOT_NAME, type InterpretedName, type Name, type Node, @@ -35,12 +36,32 @@ import { * wrapping direct subnames of specific Managed Names. */ +const ensRootRegistry = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "ENSv1Registry", +); +const ensRootRegistryOld = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "ENSv1RegistryOld", +); const ethnamesNameWrapper = getDatasourceContract( config.namespace, DatasourceNames.ENSRoot, "NameWrapper", ); +const basenamesRegistry = maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "Registry", +); +const lineanamesRegistry = maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "Registry", +); const lineanamesNameWrapper = maybeGetDatasourceContract( config.namespace, DatasourceNames.Lineanames, @@ -48,73 +69,98 @@ const lineanamesNameWrapper = maybeGetDatasourceContract( ); /** - * Mapping of a Managed Name to contracts that operate in the context of a (sub)Registry associated - * with that Name. + * Each Managed Name group is associated with exactly one concrete ENSv1 Registry (the mainnet ENS + * Registry, the Basenames shadow Registry, or the Lineanames shadow Registry). The Registry is + * what `handleNewOwner` writes domains into and what every Registrar/Controller/NameWrapper under + * the same Managed Name contributes to. */ -const CONTRACTS_BY_MANAGED_NAME: Record = { - eth: [ - getDatasourceContract( - config.namespace, // - DatasourceNames.ENSRoot, - "BaseRegistrar", - ), - getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "LegacyEthRegistrarController", - ), - getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "WrappedEthRegistrarController", - ), - getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "UnwrappedEthRegistrarController", - ), - getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "UniversalRegistrarRenewalWithReferrer", - ), - ethnamesNameWrapper, - ], - "base.eth": [ - maybeGetDatasourceContract( - config.namespace, // - DatasourceNames.Basenames, - "BaseRegistrar", - ), - maybeGetDatasourceContract( - config.namespace, - DatasourceNames.Basenames, - "EARegistrarController", - ), - maybeGetDatasourceContract( - config.namespace, // - DatasourceNames.Basenames, - "RegistrarController", - ), - maybeGetDatasourceContract( - config.namespace, - DatasourceNames.Basenames, - "UpgradeableRegistrarController", - ), - ].filter((c) => !!c), - "linea.eth": [ - maybeGetDatasourceContract( - config.namespace, // - DatasourceNames.Lineanames, - "BaseRegistrar", - ), - maybeGetDatasourceContract( - config.namespace, - DatasourceNames.Lineanames, - "EthRegistrarController", - ), - lineanamesNameWrapper, - ].filter((c) => !!c), +interface ManagedNameGroup { + registry: AccountId; + contracts: AccountId[]; +} + +/** + * Mapping of a Managed Name to its concrete Registry and the contracts that operate in its + * (sub)Registry context. + * + * The concrete ENSv1 Registry is included in `contracts` so that its own handlers resolve via the + * same {@link getManagedName} path. The mainnet ENSv1Registry's Managed Name is the ENS Root (""), + * so direct children of root (TLDs) point at the concrete Registry and everything below gets a + * virtual Registry. + * + * Groups for namespaces that don't ship a given shadow Registry are omitted entirely. + */ +const CONTRACTS_BY_MANAGED_NAME: Record = { + [ENS_ROOT_NAME]: { + registry: ensRootRegistry, + contracts: [ensRootRegistry, ensRootRegistryOld], + }, + eth: { + registry: ensRootRegistry, + contracts: [ + getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "BaseRegistrar"), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "LegacyEthRegistrarController", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "WrappedEthRegistrarController", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "UnwrappedEthRegistrarController", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "UniversalRegistrarRenewalWithReferrer", + ), + ethnamesNameWrapper, + ], + }, + ...(basenamesRegistry && { + "base.eth": { + registry: basenamesRegistry, + contracts: [ + basenamesRegistry, + maybeGetDatasourceContract(config.namespace, DatasourceNames.Basenames, "BaseRegistrar"), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "EARegistrarController", + ), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "RegistrarController", + ), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "UpgradeableRegistrarController", + ), + ].filter((c) => !!c), + } satisfies ManagedNameGroup, + }), + ...(lineanamesRegistry && { + "linea.eth": { + registry: lineanamesRegistry, + contracts: [ + lineanamesRegistry, + maybeGetDatasourceContract(config.namespace, DatasourceNames.Lineanames, "BaseRegistrar"), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "EthRegistrarController", + ), + lineanamesNameWrapper, + ].filter((c) => !!c), + } satisfies ManagedNameGroup, + }), }; /** @@ -140,13 +186,18 @@ const cachedNamehash = (name: Name): Node => { }; /** - * Given a `contract`, identify its Managed Name and Node. + * Given a `contract`, identify its Managed Name, Node, and the concrete ENSv1 Registry whose + * namegraph it writes into. * * @dev Caches the result of namehash(name). */ -export const getManagedName = (contract: AccountId): { name: InterpretedName; node: Node } => { - for (const [managedName, contracts] of Object.entries(CONTRACTS_BY_MANAGED_NAME)) { - const isAnyOfTheContracts = contracts.some((_contract) => accountIdEqual(_contract, contract)); +export const getManagedName = ( + contract: AccountId, +): { name: InterpretedName; node: Node; registry: AccountId } => { + for (const [managedName, group] of Object.entries(CONTRACTS_BY_MANAGED_NAME)) { + const isAnyOfTheContracts = group.contracts.some((_contract) => + accountIdEqual(_contract, contract), + ); if (isAnyOfTheContracts) { const namespaceSpecific = MANAGED_NAME_BY_NAMESPACE[config.namespace]?.[managedName]; @@ -156,7 +207,7 @@ export const getManagedName = (contract: AccountId): { name: InterpretedName; no const name = (namespaceSpecific ?? managedName) as InterpretedName; const node = cachedNamehash(name); - return { name, node }; + return { name, node, registry: group.registry }; } } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts index e6168f252..a088fafb6 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts @@ -79,9 +79,9 @@ export default function () { const labelHash = interpretTokenIdAsLabelHash(tokenId); const registrar = getThisAccountId(context, event); - const { node: managedNode } = getManagedName(registrar); + const { node: managedNode, registry } = getManagedName(registrar); const node = makeSubdomainNode(labelHash, managedNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); if (!registration) { @@ -89,7 +89,7 @@ export default function () { } // materialize Domain owner if exists - const domain = await context.ensDb.find(ensIndexerSchema.v1Domain, { id: domainId }); + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, to); // push event to domain history @@ -113,10 +113,10 @@ export default function () { const labelHash = interpretTokenIdAsLabelHash(tokenId); const registrar = getThisAccountId(context, event); - const { node: managedNode } = getManagedName(registrar); + const { node: managedNode, registry } = getManagedName(registrar); const node = makeSubdomainNode(labelHash, managedNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); const isFullyExpired = registration && isRegistrationFullyExpired(registration, event.block.timestamp); @@ -144,7 +144,7 @@ export default function () { }); // materialize Domain owner if exists - const domain = await context.ensDb.find(ensIndexerSchema.v1Domain, { id: domainId }); + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, owner); // push event to domain history @@ -173,9 +173,9 @@ export default function () { const labelHash = interpretTokenIdAsLabelHash(tokenId); const registrar = getThisAccountId(context, event); - const { node: managedNode } = getManagedName(registrar); + const { node: managedNode, registry } = getManagedName(registrar); const node = makeSubdomainNode(labelHash, managedNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); // Invariant: There must be a Registration to renew. diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index cfa08dd33..cb51449fa 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -5,23 +5,28 @@ import { ENS_ROOT_NODE, type LabelHash, makeENSv1DomainId, + makeENSv1RegistryId, + makeENSv1VirtualRegistryId, makeSubdomainNode, type Node, type NormalizedAddress, + type RegistryId, } from "enssdk"; import { isAddressEqual, zeroAddress } from "viem"; import { getENSRootChainId, interpretAddress, PluginName } from "@ensnode/ensnode-sdk"; -import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers"; +import { getThisAccountId } from "@/lib/get-this-account-id"; import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label"; import { addOnchainEventListener, ensIndexerSchema, type IndexingEngineContext, } from "@/lib/indexing-engines/ponder"; +import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { nodeIsMigrated } from "@/lib/protocol-acceleration/registry-migration-status"; @@ -54,9 +59,58 @@ export default function () { // if someone mints a node to the zero address, nothing happens in the Registry, so no-op if (isAddressEqual(zeroAddress, owner)) return; + // Canonicalize ENSv1Registry vs. ENSv1RegistryOld via `getManagedName(...).registry`. Both + // Registries share a Managed Name (the ENS Root for mainnet) and write into the same + // namegraph; canonicalizing here ensures Old events that pass `nodeIsMigrated` don't fragment + // domains across two Registry IDs. + const { node: managedNode, registry } = getManagedName(getThisAccountId(context, event)); + const node = makeSubdomainNode(labelHash, parentNode); - const domainId = makeENSv1DomainId(node); - const parentId = makeENSv1DomainId(parentNode); + const domainId = makeENSv1DomainId(registry, node); + const parentDomainId = makeENSv1DomainId(registry, parentNode); + + let parentRegistryId: RegistryId; + + // if the parent is the Managed Name, the parent registry is the Managed Name's Registry + if (parentNode === managedNode) { + // parent is concrete + parentRegistryId = makeENSv1RegistryId(registry); + + // ensure (concrete) ENSv1Registry + await context.ensDb + .insert(ensIndexerSchema.registry) + .values({ id: parentRegistryId, type: "ENSv1Registry", ...registry }) + .onConflictDoNothing(); + + // NOTE: we explicitly do not set the Canonical Domain for (concrete) ENSv1Registries — this + // traversal logic is handled by the Bridged Resolver concept during resolution + } else { + // parent registry is virtual + parentRegistryId = makeENSv1VirtualRegistryId(registry, parentNode); + + // ensure ENSv1VirtualRegistry for parent + await context.ensDb + .insert(ensIndexerSchema.registry) + .values({ + id: parentRegistryId, + type: "ENSv1VirtualRegistry", + chainId: registry.chainId, + address: registry.address, + node: parentNode, + }) + .onConflictDoNothing(); + + // ensure parent domain's subregistry is the ENSv1VirtualRegistry + 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 }) + .onConflictDoUpdate({ domainId: parentDomainId }); + } // If this is a direct subname of addr.reverse, we have 100% on-chain label discovery. // @@ -76,24 +130,30 @@ export default function () { await ensureUnknownLabel(context, labelHash); } - // upsert domain - await context.ensDb - .insert(ensIndexerSchema.v1Domain) - .values({ id: domainId, parentId, labelHash }) - .onConflictDoNothing(); + const ownerId = interpretAddress(owner); + await ensureAccount(context, owner); - // update rootRegistryOwner + // upsert domain, always updating ownerId and setting rootRegistryOwner to this explicit owner await context.ensDb - .update(ensIndexerSchema.v1Domain, { id: domainId }) - .set({ rootRegistryOwnerId: interpretAddress(owner) }); - - // materialize domain owner - // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars - // like BaseRegistrars & NameWrapper) it's ok to always set it here because the Registrar-emitted - // events occur _after_ the Registry events. So when a name is registered, for example, the Registry's - // owner changes to that of the NameWrapper but then the NameWrapper emits NameWrapped, and this - // indexing code re-materializes the Domain.ownerId to the NameWraper-emitted value. - await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + .insert(ensIndexerSchema.domain) + .values({ + id: domainId, + type: "ENSv1Domain", + 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 to always set it here because the Registrar-emitted + // events occur _after_ the Registry events. So when a name is registered, for example, the Registry's + // owner changes to that of the NameWrapper but then the NameWrapper emits NameWrapped, and this + // indexing code re-materializes the Domain.ownerId to the NameWrapper-emitted value. + ownerId, + rootRegistryOwnerId: ownerId, + }) + .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); // push event to domain history await ensureDomainEvent(context, event, domainId); @@ -111,20 +171,21 @@ export default function () { // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; - const domainId = makeENSv1DomainId(node); + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); - // set the domain's rootRegistryOwner to `owner` - await context.ensDb - .update(ensIndexerSchema.v1Domain, { id: domainId }) - .set({ rootRegistryOwnerId: interpretAddress(owner) }); + const ownerId = interpretAddress(owner); + await ensureAccount(context, owner); - // materialize domain owner + // update domain, setting ownerId and rootRegistryOwner to the new owner // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars // like BaseRegistrars & NameWrapper) it's ok to always set it here because the Registrar-emitted // events occur _after_ the Registry events. So when a name is wrapped, for example, the Registry's // owner changes to that of the NameWrapper but then the NameWrapper emits NameWrapped, and this - // indexing code re-materializes the Domain.ownerId to the NameWraper-emitted value. - await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + // indexing code re-materializes the Domain.ownerId to the NameWrapper-emitted value. + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ ownerId, rootRegistryOwnerId: ownerId }); // push event to domain history await ensureDomainEvent(context, event, domainId); @@ -138,11 +199,13 @@ export default function () { event: EventWithArgs<{ node: Node }>; }) { const { node } = event.args; - const domainId = makeENSv1DomainId(node); // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); + // push event to domain history await ensureDomainEvent(context, event, domainId); } @@ -155,11 +218,13 @@ export default function () { event: EventWithArgs<{ node: Node }>; }) { const { node } = event.args; - const domainId = makeENSv1DomainId(node); // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); + // NOTE: Domain-Resolver relations are handled by the protocol-acceleration plugin and are not // directly indexed here diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts index d7524427d..f90b2a831 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts @@ -120,8 +120,9 @@ export default function () { // otherwise is transfer of existing registration + const { registry } = getManagedName(getThisAccountId(context, event)); // the NameWrapper's ERC1155 TokenIds are the ENSv1Domain's Node so we `interpretTokenIdAsNode` - const domainId = makeENSv1DomainId(interpretTokenIdAsNode(tokenId)); + const domainId = makeENSv1DomainId(registry, interpretTokenIdAsNode(tokenId)); const registration = await getLatestRegistration(context, domainId); const isExpired = registration && isRegistrationExpired(registration, event.block.timestamp); @@ -169,7 +170,8 @@ export default function () { const registrant = owner; const registrar = getThisAccountId(context, event); - const domainId = makeENSv1DomainId(node); + const { node: managedNode, registry } = getManagedName(registrar); + const domainId = makeENSv1DomainId(registry, node); // decode name and discover labels try { @@ -191,8 +193,6 @@ export default function () { // handle wraps of direct-subname-of-registrar-managed-names if (registration && !isFullyExpired && registration.type === "BaseRegistrar") { - const { node: managedNode } = getManagedName(getThisAccountId(context, event)); - // Invariant: Emitted name is a direct subname of the Managed Name if (!isDirectSubnameOfManagedName(managedNode, name, node)) { throw new Error( @@ -279,7 +279,8 @@ export default function () { }) => { const { node } = event.args; - const domainId = makeENSv1DomainId(node); + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); if (!registration) { @@ -321,7 +322,8 @@ export default function () { }) => { const { node, fuses } = event.args; - const domainId = makeENSv1DomainId(node); + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); // Invariant: must have a Registration @@ -357,7 +359,8 @@ export default function () { const { node, expiry: _expiry } = event.args; const expiry = interpretExpiry(_expiry); - const domainId = makeENSv1DomainId(node); + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); // Invariant: must have Registration diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index fb8d09552..6514708bb 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -51,10 +51,10 @@ export default function () { } const controller = getThisAccountId(context, event); - const { node: managedNode } = getManagedName(controller); + const { node: managedNode, registry } = getManagedName(controller); const node = makeSubdomainNode(labelHash, managedNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); if (!registration) { @@ -112,9 +112,9 @@ export default function () { } const controller = getThisAccountId(context, event); - const { node: managedNode } = getManagedName(controller); + const { node: managedNode, registry } = getManagedName(controller); const node = makeSubdomainNode(labelHash, managedNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); const registration = await getLatestRegistration(context, domainId); if (!registration) { diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 76aafac3e..d7259bca6 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -4,7 +4,7 @@ import { type LabelHash, labelhashLiteralLabel, makeENSv2DomainId, - makeRegistryId, + makeENSv2RegistryId, makeStorageId, type NormalizedAddress, type TokenId, @@ -62,7 +62,7 @@ export default function () { const isReservation = owner === undefined; const registry = getThisAccountId(context, event); - const registryId = makeRegistryId(registry); + const registryId = makeENSv2RegistryId(registry); const storageId = makeStorageId(tokenId); const domainId = makeENSv2DomainId(registry, storageId); @@ -84,7 +84,7 @@ export default function () { // TODO(signals) — move to NewRegistry and add invariant here await context.ensDb .insert(ensIndexerSchema.registry) - .values({ id: registryId, ...registry }) + .values({ id: registryId, type: "ENSv2Registry", ...registry }) .onConflictDoNothing(); // ensure discovered Label @@ -111,11 +111,12 @@ export default function () { } } - // ensure v2Domain + // ensure ENSv2 Domain await context.ensDb - .insert(ensIndexerSchema.v2Domain) + .insert(ensIndexerSchema.domain) .values({ id: domainId, + type: "ENSv2Domain", tokenId, registryId, labelHash, @@ -123,7 +124,7 @@ export default function () { // a) this is a Registration, in which case a TransferSingle event will be emitted afterwards, or // b) this is a Reservation, in which there is no owner }) - // if the v2Domain exists, this is a re-register after expiration and tokenId will have changed + // if the domain exists, this is a re-register after expiration and tokenId will have changed .onConflictDoUpdate({ tokenId }); // insert Registration @@ -270,7 +271,7 @@ export default function () { // 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.v2Domain, { id: domainId }); + const previous = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); if (previous?.subregistryId) { await context.ensDb.delete(ensIndexerSchema.registryCanonicalDomain, { registryId: previous.subregistryId, @@ -278,11 +279,11 @@ export default function () { } await context.ensDb - .update(ensIndexerSchema.v2Domain, { id: domainId }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ subregistryId: null }); } else { const subregistryAccountId: AccountId = { chainId: context.chain.id, address: subregistry }; - const subregistryId = makeRegistryId(subregistryAccountId); + 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 @@ -292,7 +293,7 @@ export default function () { .onConflictDoUpdate({ domainId }); await context.ensDb - .update(ensIndexerSchema.v2Domain, { id: domainId }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ subregistryId }); } @@ -325,7 +326,7 @@ export default function () { const domainId = makeENSv2DomainId(registryAccountId, storageId); await context.ensDb - .update(ensIndexerSchema.v2Domain, { id: domainId }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ tokenId: newTokenId }); // push event to domain history @@ -347,13 +348,13 @@ export default function () { const domainId = makeENSv2DomainId(registry, storageId); // TODO(signals): remove this invariant, since we'll only be indexing Registry contracts - const registryId = makeRegistryId(registry); + const registryId = makeENSv2RegistryId(registry); const exists = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); if (!exists) return; // no-op non-Registry ERC1155 Transfers // update the Domain's ownerId await context.ensDb - .update(ensIndexerSchema.v2Domain, { id: domainId }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ ownerId: interpretAddress(owner) }); // push event to domain history diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts index 2055bcd23..766418ca2 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts @@ -13,6 +13,7 @@ import { PluginName } from "@ensnode/ensnode-sdk"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { addOnchainEventListener, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; +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"; @@ -37,8 +38,10 @@ export default function () { }) { const { node, resolver } = event.args; - const registry = getThisAccountId(context, event); - const domainId = makeENSv1DomainId(node); + // Canonicalize to the concrete ENSv1 Registry that governs this contract's namegraph + // (ENSv1Registry vs. ENSv1RegistryOld both canonicalize to the new Registry on mainnet). + const { registry } = getManagedName(getThisAccountId(context, event)); + const domainId = makeENSv1DomainId(registry, node); await ensureDomainResolverRelation(context, registry, domainId, resolver); } diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts index 3d680f067..15e1c2dc0 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts @@ -57,7 +57,7 @@ export default function () { const { label: labelHash, node: parentNode } = event.args; const registry = getThisAccountId(context, event); const node = makeSubdomainNode(labelHash, parentNode); - const domainId = makeENSv1DomainId(node); + const domainId = makeENSv1DomainId(registry, node); // all ThreeDNSToken nodes have a hardcoded resolver const resolver = ThreeDNSResolverByChainId[context.chain.id]; diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 828112322..90a18ab16 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -2,10 +2,9 @@ import type { Address, ChainId, DomainId, - ENSv1DomainId, - ENSv2DomainId, InterpretedLabel, LabelHash, + Node, PermissionsId, PermissionsResourceId, PermissionsUserId, @@ -13,6 +12,7 @@ import type { RegistryId, RenewalId, ResolverId, + TokenId, } from "enssdk"; import { index, onchainEnum, onchainTable, primaryKey, relations, sql, uniqueIndex } from "ponder"; import type { BlockNumber, Hash } from "viem"; @@ -35,20 +35,16 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * it's more expensive for us to recursively traverse the namegraph (like evm code does) because our * individual roundtrips from the db are relatively more expensive. * - * For the datamodel, this means that instead of a polymorphic Domain entity, representing both v1 - * and v2 Domains, this schema employs separate (but overlapping) v1Domains and v2Domains entities. - * This avoids resolution-time complications and more accurately represents the on-chain state. - * Domain polymorphism is applied at the API later, via GraphQL Interfaces, to simplify queries. - * * In general: the indexed schema should match on-chain state as closely as possible, and * resolution-time behavior within the ENS protocol should _also_ be implemented at resolution time - * in ENSApi. The current obvious exception to this is that v1Domain.owner is the _materialized_ - * _effective_ owner of the v1Domain. ENSv1 includes a mind-boggling number of ways to 'own' a v1Domain, + * in ENSApi. The current obvious exception is that `domain.ownerId` for ENSv1 Domains is the + * _materialized_ _effective_ owner. ENSv1 includes a diverse number of ways to 'own' a domain, * including the ENSv1 Registry, various Registrars, and the NameWrapper. The ENSv1 indexing logic - * within this ENSv2 plugin materialize the v1Domain's effective owner to simplify this aspect of ENS, - * and enable efficient queries against v1Domain.owner. + * within this ENSv2 plugin materializes the effective owner to simplify this aspect of ENS and + * enable efficient queries against `domain.ownerId`. * - * Many datamodels are shared between ENSv1 and ENSv2, including Registrations, Renewals, and Resolvers. + * When necessary, all datamodels are shared or polymorphic between ENSv1 and ENSv2, including + * Domains, Registries, Registrations, Renewals, and Resolvers. * * Registrations are polymorphic between the defined RegistrationTypes, depending on the associated * guarantees (for example, ENSv1 BaseRegistrar Registrations may have a gracePeriod, but ENSv2 @@ -59,20 +55,18 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * new label is encountered onchain, all Domains that use that label are automatically healed at * resolution-time. * - * v1Domains exist in a flat namespace and are absolutely addressed by `node`. As such, they describe - * a simple tree datamodel of: - * v1Domain -> v1Domain(s) -> v1Domain(s) -> ...etc - * - * v2Domains exist in a set of namegraphs. Each namegraph is a possibly cicular directed graph of - * (Root)Registry -> v2Domain(s) -> (sub)Regsitry -> v2Domain(s) -> ...etc - * with exactly one RootRegistry on the ENS Root Chain establishing the beginning of the _canonical_ - * namegraph. As discussed above, the canonical namegraph is never materialized, only _navigated_ - * at resolution-time, in order to correctly implement the complexities of the ENS protocol. + * ENSv1 and ENSv2 both fit the Registry → Domain → (Sub)Registry → Domain → ... namegraph model. + * For ENSv1, each domain that has children implicitly owns a "virtual" Registry (a row of type + * `ENSv1VirtualRegistry`) whose sole parent is that domain; children of the parent then point their + * `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. * * 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: * a) ENSv1RegistryOld -> ENSv1Registry migration status - * b) Domain-Resolver Relations for both v1Domains and v2Domains + * b) Domain-Resolver Relations for both ENSv1 and ENSv2 Domains * As such, none of that information is present in this ensv2.schema.ts file. * * In general, entities are keyed by a nominally-typed `id` that uniquely references them. This @@ -80,6 +74,9 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * deeply nested entities by a straightforward string ID. In cases where an entity's `id` is composed * of multiple pieces of information (for example, a Registry is identified by (chainId, address)), * then that information is, as well, included in the entity's columns, not just encoded in the id. + * Nowhere in this application, nor in user applications, should an entity's id be parsed for its + * constituent parts; all should be available, with their various type guarantees, on the entity + * itself. * * Events are structured as a single "events" table which tracks EVM Event Metadata for any on-chain * Event. Then, join tables (DomainEvent, ResolverEvent, etc) track the relationship between an @@ -166,7 +163,7 @@ export const account = onchainTable("accounts", (t) => ({ export const account_relations = relations(account, ({ many }) => ({ registrations: many(registration, { relationName: "registrant" }), - domains: many(v2Domain), + domains: many(domain), permissions: many(permissionsUser), })); @@ -174,27 +171,39 @@ export const account_relations = relations(account, ({ many }) => ({ // Registry //////////// +export const registryType = onchainEnum("RegistryType", [ + "ENSv1Registry", + "ENSv1VirtualRegistry", + "ENSv2Registry", +]); + export const registry = onchainTable( "registries", (t) => ({ // see RegistryId for guarantees id: t.text().primaryKey().$type(), + // has a type + type: registryType().notNull(), + chainId: t.integer().notNull().$type(), address: t.hex().notNull().$type
(), + + // If this is an ENSv1VirtualRegistry, `node` is the namehash of the parent ENSv1 domain that + // owns it, otherwise null. + node: t.hex().$type(), }), (t) => ({ - byId: uniqueIndex().on(t.chainId, t.address), + // NOTE: non-unique index because multiple rows can share (chainId, address) across virtual registries + byChainAddress: index().on(t.chainId, t.address), }), ); export const relations_registry = relations(registry, ({ one, many }) => ({ - domain: one(v2Domain, { - relationName: "subregistry", - fields: [registry.id], - references: [v2Domain.registryId], - }), - domains: many(v2Domain, { relationName: "registry" }), + // domains that declare this registry as their parent registry + domains: many(domain, { relationName: "registry" }), + // domains that declare this registry as their subregistry + domainsAsSubregistry: many(domain, { relationName: "subregistry" }), permissions: one(permissions, { relationName: "permissions", fields: [registry.chainId, registry.address], @@ -206,84 +215,43 @@ export const relations_registry = relations(registry, ({ one, many }) => ({ // Domains /////////// -export const v1Domain = onchainTable( - "v1_domains", - (t) => ({ - // keyed by node, see ENSv1DomainId for guarantees. - id: t.text().primaryKey().$type(), - - // must have a parent v1Domain (note: root node does not exist in index) - parentId: t.text().notNull().$type(), - - // may have an owner - ownerId: t.hex().$type
(), - - // represents a labelHash - labelHash: t.hex().notNull().$type(), - - // may have a `rootRegistryOwner` (ENSv1Registry's owner()), zeroAddress interpreted as null - rootRegistryOwnerId: t.hex().$type
(), +export const domainType = onchainEnum("DomainType", ["ENSv1Domain", "ENSv2Domain"]); - // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin - }), +export const domain = onchainTable( + "domains", (t) => ({ - byParent: index().on(t.parentId), - byOwner: index().on(t.ownerId), - byLabelHash: index().on(t.labelHash), - }), -); + // see DomainId for guarantees (ENSv1DomainId: `${ENSv1RegistryId}/${node}`, ENSv2DomainId: CAIP-19) + id: t.text().primaryKey().$type(), -export const relations_v1Domain = relations(v1Domain, ({ one, many }) => ({ - // v1Domain - parent: one(v1Domain, { - fields: [v1Domain.parentId], - references: [v1Domain.id], - }), - children: many(v1Domain, { relationName: "parent" }), - rootRegistryOwner: one(account, { - relationName: "rootRegistryOwner", - fields: [v1Domain.rootRegistryOwnerId], - references: [account.id], - }), + // has a type + type: domainType().notNull(), - // shared - owner: one(account, { - relationName: "owner", - fields: [v1Domain.ownerId], - references: [account.id], - }), - label: one(label, { - relationName: "label", - fields: [v1Domain.labelHash], - references: [label.labelHash], - }), - registrations: many(registration), -})); + // belongs to a registry + registryId: t.text().notNull().$type(), -export const v2Domain = onchainTable( - "v2_domains", - (t) => ({ - // see ENSv2DomainId for guarantees - id: t.text().primaryKey().$type(), + // may have a subregistry + subregistryId: t.text().$type(), - // has a tokenId - tokenId: t.bigint().notNull(), + // If this is an ENSv2Domain, the TokenId within the ENSv2Registry, otherwise null. + tokenId: t.bigint().$type(), - // belongs to registry - registryId: t.text().notNull().$type(), + // If this is an ENSv1Domain, The Domain's namehash, otherwise null. + node: t.hex().$type(), - // may have one subregistry - subregistryId: t.text().$type(), + // represents a labelHash + labelHash: t.hex().notNull().$type(), // may have an owner ownerId: t.hex().$type
(), - // represents a labelHash - labelHash: t.hex().notNull().$type(), + // If this is an ENSv1Domain, may have a `rootRegistryOwner`, otherwise null. + rootRegistryOwnerId: t.hex().$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), byRegistry: index().on(t.registryId), bySubregistry: index().on(t.subregistryId).where(sql`${t.subregistryId} IS NOT NULL`), byOwner: index().on(t.ownerId), @@ -291,28 +259,30 @@ export const v2Domain = onchainTable( }), ); -export const relations_v2Domain = relations(v2Domain, ({ one, many }) => ({ - // v2Domain +export const relations_domain = relations(domain, ({ one, many }) => ({ registry: one(registry, { relationName: "registry", - fields: [v2Domain.registryId], + fields: [domain.registryId], references: [registry.id], }), subregistry: one(registry, { relationName: "subregistry", - fields: [v2Domain.subregistryId], + fields: [domain.subregistryId], references: [registry.id], }), - - // shared owner: one(account, { relationName: "owner", - fields: [v2Domain.ownerId], + fields: [domain.ownerId], + references: [account.id], + }), + rootRegistryOwner: one(account, { + relationName: "rootRegistryOwner", + fields: [domain.rootRegistryOwnerId], references: [account.id], }), label: one(label, { relationName: "label", - fields: [v2Domain.labelHash], + fields: [domain.labelHash], references: [label.labelHash], }), registrations: many(registration), @@ -391,14 +361,10 @@ export const latestRegistrationIndex = onchainTable("latest_registration_indexes })); export const registration_relations = relations(registration, ({ one, many }) => ({ - // belongs to either v1Domain or v2Domain - v1Domain: one(v1Domain, { - fields: [registration.domainId], - references: [v1Domain.id], - }), - v2Domain: one(v2Domain, { + // belongs to a domain + domain: one(domain, { fields: [registration.domainId], - references: [v2Domain.id], + references: [domain.id], }), // has one registrant @@ -581,7 +547,7 @@ export const label = onchainTable( ); export const label_relations = relations(label, ({ many }) => ({ - domains: many(v2Domain), + domains: many(domain), })); /////////////////// @@ -596,5 +562,5 @@ export const label_relations = relations(label, ({ many }) => ({ // Registry contracts, ensuring that they are indexed during construction and are available for storage. export const registryCanonicalDomain = onchainTable("registry_canonical_domains", (t) => ({ registryId: t.text().primaryKey().$type(), - domainId: t.text().notNull().$type(), + domainId: t.text().notNull().$type(), })); diff --git a/packages/ensdb-sdk/src/lib/drizzle.test.ts b/packages/ensdb-sdk/src/lib/drizzle.test.ts index 0a7f1e9e3..ac355d09b 100644 --- a/packages/ensdb-sdk/src/lib/drizzle.test.ts +++ b/packages/ensdb-sdk/src/lib/drizzle.test.ts @@ -34,7 +34,7 @@ describe("buildIndividualEnsDbSchemas", () => { const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); expect(concreteEnsIndexerSchema.event).toBeDefined(); - expect(concreteEnsIndexerSchema.v1Domain).toBeDefined(); + expect(concreteEnsIndexerSchema.domain).toBeDefined(); expect(concreteEnsIndexerSchema.registration).toBeDefined(); expect(concreteEnsIndexerSchema.registrationType).toBeDefined(); }); @@ -141,16 +141,16 @@ describe("concrete tables — prototype and Symbol preservation", () => { it("preserves the Table prototype on cloned tables", () => { const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); - const abstractTable = abstractEnsIndexerSchema.v1Domain; - const concreteTable = concreteEnsIndexerSchema.v1Domain; + const abstractTable = abstractEnsIndexerSchema.domain; + const concreteTable = concreteEnsIndexerSchema.domain; expect(Object.getPrototypeOf(concreteTable)).toBe(Object.getPrototypeOf(abstractTable)); }); it("preserves Symbol-keyed properties (IsDrizzleTable, Columns, TableName) on cloned tables", () => { const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); - const abstractTable = abstractEnsIndexerSchema.v1Domain; - const concreteTable = concreteEnsIndexerSchema.v1Domain; + const abstractTable = abstractEnsIndexerSchema.domain; + const concreteTable = concreteEnsIndexerSchema.domain; expect((concreteTable as any)[IsDrizzleTable]).toBe((abstractTable as any)[IsDrizzleTable]); expect((concreteTable as any)[Columns]).toBe((abstractTable as any)[Columns]); @@ -160,7 +160,7 @@ describe("concrete tables — prototype and Symbol preservation", () => { it("isTable() returns true for cloned concrete tables", () => { const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); - expect(isTable(concreteEnsIndexerSchema.v1Domain)).toBe(true); + expect(isTable(concreteEnsIndexerSchema.domain)).toBe(true); expect(isTable(concreteEnsIndexerSchema.registration)).toBe(true); expect(isTable(concreteEnsIndexerSchema.event)).toBe(true); }); diff --git a/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts b/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts index 8a46901dc..15784be9f 100644 --- a/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts +++ b/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts @@ -2,8 +2,8 @@ import type { Cache, ResolveInfo, Resolver, Variables } from "@urql/exchange-gra import { type AccountId, type Address, + makeConcreteRegistryId, makePermissionsId, - makeRegistryId, makeResolverId, type PermissionsId, type RegistryId, @@ -43,8 +43,23 @@ export const byIdLookupResolvers: Record> = { registry(parent, args, cache, info) { const by = args.by as { id?: RegistryId; contract?: AccountId }; - if (by.id) return { __typename: "Registry", id: by.id }; - if (by.contract) return { __typename: "Registry", id: makeRegistryId(by.contract) }; + // `Registry` is a GraphQL interface; graphcache normalizes on the concrete typename, so we + // probe each implementation (ENSv1Registry, ENSv2Registry, ENSv1VirtualRegistry). + // Addressing by AccountId only reaches concrete registries — ENSv1VirtualRegistry ids carry + // a `/node` suffix that AccountId alone cannot produce. + const id = by.id ?? (by.contract ? makeConcreteRegistryId(by.contract) : undefined); + if (id) { + const v1Key = cache.keyOfEntity({ __typename: "ENSv1Registry", id }); + if (v1Key && cache.resolve(v1Key, "id")) return v1Key; + + const v2Key = cache.keyOfEntity({ __typename: "ENSv2Registry", id }); + if (v2Key && cache.resolve(v2Key, "id")) return v2Key; + + if (by.id) { + const virtualKey = cache.keyOfEntity({ __typename: "ENSv1VirtualRegistry", id }); + if (virtualKey && cache.resolve(virtualKey, "id")) return virtualKey; + } + } return passthrough(args, cache, info); }, diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index f8d49b931..13466df19 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, makeRegistryId } from "enssdk"; +import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId, type RegistryId } from "enssdk"; import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; import { @@ -17,6 +17,29 @@ import { export const getENSv1Registry = (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)); + +/** + * Gets the AccountId representing the ENSv1 Registry in the selected `namespace` if defined, + * otherwise `undefined`. + */ +export const maybeGetENSv1Registry = (namespace: ENSNamespaceId) => + maybeGetDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); + +/** + * Gets the ENSv1RegistryId representing the ENSv1 Root Registry in the selected `namespace` if + * defined, otherwise `undefined`. + */ +export const maybeGetENSv1RootRegistryId = (namespace: ENSNamespaceId) => { + const root = maybeGetENSv1Registry(namespace); + if (!root) return undefined; + return makeENSv1RegistryId(root); +}; + /** * Determines whether `contract` is the ENSv1 Registry in `namespace`. */ @@ -41,7 +64,7 @@ export const getENSv2RootRegistry = (namespace: ENSNamespaceId) => * @throws if the ENSv2Root Datasource or the RootRegistry contract are not defined */ export const getENSv2RootRegistryId = (namespace: ENSNamespaceId) => - makeRegistryId(getENSv2RootRegistry(namespace)); + makeENSv2RegistryId(getENSv2RootRegistry(namespace)); /** * Determines whether `contract` is the ENSv2 Root Registry in `namespace`. @@ -69,5 +92,48 @@ export const maybeGetENSv2RootRegistry = (namespace: ENSNamespaceId) => export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { const root = maybeGetENSv2RootRegistry(namespace); if (!root) return undefined; - return makeRegistryId(root); + return makeENSv2RegistryId(root); +}; + +////////////// +// Root +////////////// + +/** + * 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. + */ +export const getRootRegistryId = (namespace: ENSNamespaceId) => + maybeGetENSv2RootRegistryId(namespace) ?? getENSv1RootRegistryId(namespace); + +/** + * Gets every top-level Root Registry configured for the namespace: all concrete ENSv1Registries + * (ENSRoot, Basenames, Lineanames) 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}. + * + * Each concrete ENSv1Registry 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 CCIP-read ENSv2 shadow Registries are introduced, extend this helper to + * enumerate them. ENSv1 top-level registries are structurally identifiable (any `registry.type = + * "ENSv1Registry"` row is top-level); ENSv2 is not, so we rely on datasource configuration here. + */ +export const getRootRegistryIds = (namespace: ENSNamespaceId): RegistryId[] => { + const v1Registries = [ + getENSv1Registry(namespace), + maybeGetDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"), + maybeGetDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"), + ] + .filter((c): c is AccountId => c !== undefined) + .map(makeENSv1RegistryId); + + const v2Root = maybeGetENSv2RootRegistryId(namespace); + return v2Root ? [...v1Registries, v2Root] : v1Registries; }; diff --git a/packages/ensnode-sdk/src/shared/types.ts b/packages/ensnode-sdk/src/shared/types.ts index 980ff7e7f..318cf95ed 100644 --- a/packages/ensnode-sdk/src/shared/types.ts +++ b/packages/ensnode-sdk/src/shared/types.ts @@ -108,3 +108,10 @@ export type Unvalidated = DeepPartial; export type RequiredAndNotNull = T & { [P in K]-?: NonNullable; }; + +/** + * Marks keys in K as required (not undefined) and null. + */ +export type RequiredAndNull = T & { + [P in K]-?: null; +}; diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index c4721cc52..0993f2979 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -7,7 +7,10 @@ import type { DomainId, EACResource, ENSv1DomainId, + ENSv1RegistryId, + ENSv1VirtualRegistryId, ENSv2DomainId, + ENSv2RegistryId, LabelHash, Node, NormalizedAddress, @@ -24,11 +27,36 @@ import type { } from "./types"; import { AssetNamespaces } from "./types"; +export const makeENSv1RegistryId = (accountId: AccountId) => + stringifyAccountId(accountId) as ENSv1RegistryId; + +export const makeENSv2RegistryId = (accountId: AccountId) => + stringifyAccountId(accountId) as ENSv2RegistryId; + +export const makeENSv1VirtualRegistryId = (accountId: AccountId, node: Node) => + `${makeENSv1RegistryId(accountId)}/${node}` as ENSv1VirtualRegistryId; + +/** + * Stringifies an {@link AccountId} as a {@link RegistryId} union without narrowing to the + * v1 vs. v2 variant. Use when callsite context cannot determine which concrete variant is + * appropriate (e.g. client-side cache key reconstruction or polymorphic GraphQL inputs); + * prefer {@link makeENSv1RegistryId} or {@link makeENSv2RegistryId} when the variant is known. + */ export const makeRegistryId = (accountId: AccountId) => stringifyAccountId(accountId) as RegistryId; +/** + * Stringifies an {@link AccountId} as the id of a concrete Registry — either an + * {@link ENSv1RegistryId} or an {@link ENSv2RegistryId}, but never an + * {@link ENSv1VirtualRegistryId} (whose id format includes a trailing `/node` suffix that cannot + * be produced from an AccountId alone). + */ +export const makeConcreteRegistryId = (accountId: AccountId) => + stringifyAccountId(accountId) as ENSv1RegistryId | ENSv2RegistryId; + export const makeResolverId = (contract: AccountId) => stringifyAccountId(contract) as ResolverId; -export const makeENSv1DomainId = (node: Node) => node as ENSv1DomainId; +export const makeENSv1DomainId = (accountId: AccountId, node: Node) => + `${makeENSv1RegistryId(accountId)}/${node}` as ENSv1DomainId; export const makeENSv2DomainId = (registry: AccountId, storageId: StorageId) => stringifyAssetId({ diff --git a/packages/enssdk/src/lib/types/ensv2.ts b/packages/enssdk/src/lib/types/ensv2.ts index d5d484a82..46ee21056 100644 --- a/packages/enssdk/src/lib/types/ensv2.ts +++ b/packages/enssdk/src/lib/types/ensv2.ts @@ -1,10 +1,27 @@ -import type { Node } from "./ens"; import type { AccountIdString } from "./shared"; /** - * Serialized CAIP-10 Asset ID that uniquely identifies a Registry contract. + * Serialized CAIP-10 Asset ID that uniquely identifies a concrete ENSv1 Registry contract. */ -export type RegistryId = string & { __brand: "RegistryContractId" }; +export type ENSv1RegistryId = AccountIdString & { __brand: "ENSv1RegistryId" }; + +/** + * Serialized CAIP-10 Asset ID that uniquely identifies an ENSv2 Registry contract. + */ +export type ENSv2RegistryId = AccountIdString & { __brand: "ENSv2RegistryId" }; + +/** + * Uniquely identifies an ENSv1 Virtual Registry — a virtual registry managed by an ENSv1 domain + * that has children. Shape: `${ENSv1RegistryId}/${node}`, where `(chainId, address)` from the + * ENSv1RegistryId is the concrete Registry that housed the parent domain, and `node` is the + * parent's namehash. + */ +export type ENSv1VirtualRegistryId = string & { __brand: "ENSv1VirtualRegistryId" }; + +/** + * A RegistryId is one of ENSv1RegistryId, ENSv1VirtualRegistryId, or ENSv2RegistryId. + */ +export type RegistryId = ENSv1RegistryId | ENSv1VirtualRegistryId | ENSv2RegistryId; /** * A Label's Storage Id is uint256(labelHash) with lower (right-most) 32 bits zero'd. @@ -15,9 +32,12 @@ export type RegistryId = string & { __brand: "RegistryContractId" }; export type StorageId = bigint & { __brand: "StorageId" }; /** - * The node that uniquely identifies an ENSv1 name. + * Uniquely identifies an ENSv1 Domain. Shape: `${ENSv1RegistryId}/${node}`. + * + * Same shape as {@link ENSv1VirtualRegistryId} (registry + node), but distinct entity kinds living + * in distinct tables. */ -export type ENSv1DomainId = Node & { __brand: "ENSv1DomainId" }; +export type ENSv1DomainId = string & { __brand: "ENSv1DomainId" }; /** * The Serialized CAIP-19 Asset ID (using Storage Id instead of TokenId) that uniquely identifies diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 65594a805..c0dfeb04e 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1122,6 +1122,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "parent", + "type": { + "kind": "INTERFACE", + "name": "Domain" + }, + "args": [], + "isDeprecated": false + }, { "name": "path", "type": { @@ -1687,6 +1696,18 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Node" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "owner", "type": { @@ -1699,8 +1720,8 @@ const introspection = { { "name": "parent", "type": { - "kind": "OBJECT", - "name": "ENSv1Domain" + "kind": "INTERFACE", + "name": "Domain" }, "args": [], "isDeprecated": false @@ -1845,6 +1866,288 @@ const introspection = { } ] }, + { + "kind": "OBJECT", + "name": "ENSv1Registry", + "fields": [ + { + "name": "contract", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "domains", + "type": { + "kind": "OBJECT", + "name": "RegistryDomainsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "RegistryDomainsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "RegistryId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "parents", + "type": { + "kind": "OBJECT", + "name": "RegistryParentsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "permissions", + "type": { + "kind": "OBJECT", + "name": "Permissions" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Registry" + } + ] + }, + { + "kind": "OBJECT", + "name": "ENSv1VirtualRegistry", + "fields": [ + { + "name": "contract", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "domains", + "type": { + "kind": "OBJECT", + "name": "RegistryDomainsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "RegistryDomainsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "RegistryId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Node" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "parents", + "type": { + "kind": "OBJECT", + "name": "RegistryParentsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "permissions", + "type": { + "kind": "OBJECT", + "name": "Permissions" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Registry" + } + ] + }, { "kind": "OBJECT", "name": "ENSv2Domain", @@ -1936,6 +2239,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "parent", + "type": { + "kind": "INTERFACE", + "name": "Domain" + }, + "args": [], + "isDeprecated": false + }, { "name": "path", "type": { @@ -2048,7 +2360,7 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" } }, @@ -2119,7 +2431,7 @@ const introspection = { { "name": "subregistry", "type": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" }, "args": [], @@ -2206,24 +2518,159 @@ const introspection = { "kind": "SCALAR", "name": "String" } - }, - "args": [], + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PermissionsUser" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ENSv2Registry", + "fields": [ + { + "name": "contract", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "domains", + "type": { + "kind": "OBJECT", + "name": "RegistryDomainsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "RegistryDomainsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "RegistryId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "parents", + "type": { + "kind": "OBJECT", + "name": "RegistryParentsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], "isDeprecated": false }, { - "name": "node", + "name": "permissions", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PermissionsUser" - } + "kind": "OBJECT", + "name": "Permissions" }, "args": [], "isDeprecated": false } ], - "interfaces": [] + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Registry" + } + ] }, { "kind": "OBJECT", @@ -3640,6 +4087,44 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "allDomains", + "type": { + "kind": "OBJECT", + "name": "QueryAllDomainsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, { "name": "domain", "type": { @@ -3776,7 +4261,7 @@ const introspection = { { "name": "registry", "type": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" }, "args": [ @@ -3853,164 +4338,11 @@ const introspection = { }, { "name": "root", - "type": { - "kind": "OBJECT", - "name": "Registry" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "v1Domains", - "type": { - "kind": "OBJECT", - "name": "QueryV1DomainsConnection" - }, - "args": [ - { - "name": "after", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "before", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int" - } - } - ], - "isDeprecated": false - }, - { - "name": "v2Domains", - "type": { - "kind": "OBJECT", - "name": "QueryV2DomainsConnection" - }, - "args": [ - { - "name": "after", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "before", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int" - } - } - ], - "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "OBJECT", - "name": "QueryDomainsConnection", - "fields": [ - { - "name": "edges", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge" - } - } - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "pageInfo", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PageInfo" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "totalCount", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Int" - } - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge", - "fields": [ - { - "name": "cursor", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "node", "type": { "kind": "NON_NULL", "ofType": { "kind": "INTERFACE", - "name": "Domain" + "name": "Registry" } }, "args": [], @@ -4021,7 +4353,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryRegistrationsConnection", + "name": "QueryAllDomainsConnection", "fields": [ { "name": "edges", @@ -4033,7 +4365,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryRegistrationsConnectionEdge" + "name": "QueryAllDomainsConnectionEdge" } } } @@ -4070,7 +4402,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryRegistrationsConnectionEdge", + "name": "QueryAllDomainsConnectionEdge", "fields": [ { "name": "cursor", @@ -4090,7 +4422,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "INTERFACE", - "name": "Registration" + "name": "Domain" } }, "args": [], @@ -4101,7 +4433,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryResolversConnection", + "name": "QueryDomainsConnection", "fields": [ { "name": "edges", @@ -4113,7 +4445,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryResolversConnectionEdge" + "name": "QueryDomainsConnectionEdge" } } } @@ -4150,7 +4482,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryResolversConnectionEdge", + "name": "QueryDomainsConnectionEdge", "fields": [ { "name": "cursor", @@ -4169,8 +4501,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "Resolver" + "kind": "INTERFACE", + "name": "Domain" } }, "args": [], @@ -4181,7 +4513,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV1DomainsConnection", + "name": "QueryRegistrationsConnection", "fields": [ { "name": "edges", @@ -4193,7 +4525,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryV1DomainsConnectionEdge" + "name": "QueryRegistrationsConnectionEdge" } } } @@ -4230,7 +4562,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV1DomainsConnectionEdge", + "name": "QueryRegistrationsConnectionEdge", "fields": [ { "name": "cursor", @@ -4249,8 +4581,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "ENSv1Domain" + "kind": "INTERFACE", + "name": "Registration" } }, "args": [], @@ -4261,7 +4593,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV2DomainsConnection", + "name": "QueryResolversConnection", "fields": [ { "name": "edges", @@ -4273,7 +4605,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryV2DomainsConnectionEdge" + "name": "QueryResolversConnectionEdge" } } } @@ -4310,7 +4642,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV2DomainsConnectionEdge", + "name": "QueryResolversConnectionEdge", "fields": [ { "name": "cursor", @@ -4330,7 +4662,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "ENSv2Domain" + "name": "Resolver" } }, "args": [], @@ -4599,7 +4931,7 @@ const introspection = { "interfaces": [] }, { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry", "fields": [ { @@ -4726,7 +5058,21 @@ const introspection = { "isDeprecated": false } ], - "interfaces": [] + "interfaces": [], + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "ENSv1Registry" + }, + { + "kind": "OBJECT", + "name": "ENSv1VirtualRegistry" + }, + { + "kind": "OBJECT", + "name": "ENSv2Registry" + } + ] }, { "kind": "OBJECT", @@ -4917,8 +5263,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "ENSv2Domain" + "kind": "INTERFACE", + "name": "Domain" } }, "args": [], @@ -4948,7 +5294,7 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" } }, diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 82c298c64..ea92e7904 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -220,7 +220,12 @@ interface Domain { owner: Account """ - The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical. + The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + """ + parent: Domain + + """ + The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. """ path: [Domain!] @@ -332,14 +337,19 @@ type ENSv1Domain implements Domain { """ name: InterpretedName + """The namehash of this ENSv1 Domain.""" + node: Node! + """The owner of this Domain.""" owner: Account - """The parent Domain of this Domain in the ENSv1 nametree.""" - parent: ENSv1Domain + """ + The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + """ + parent: Domain """ - The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical. + The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. """ path: [Domain!] @@ -365,6 +375,55 @@ type ENSv1Domain implements Domain { subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection } +""" +An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry). +""" +type ENSv1Registry implements Registry { + """ + Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. + """ + contract: AccountId! + + """The Domains managed by this Registry.""" + domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection + + """A unique reference to this Registry.""" + id: RegistryId! + + """The Domains for which this Registry is a Subregistry.""" + parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection + + """The Permissions managed by this Registry.""" + permissions: Permissions +} + +""" +An ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash. +""" +type ENSv1VirtualRegistry implements Registry { + """ + Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. + """ + contract: AccountId! + + """The Domains managed by this Registry.""" + domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection + + """A unique reference to this Registry.""" + id: RegistryId! + + """ + The namehash of the parent ENSv1 Domain that owns this virtual Registry. + """ + node: Node! + + """The Domains for which this Registry is a Subregistry.""" + parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection + + """The Permissions managed by this Registry.""" + permissions: Permissions +} + """An ENSv2Domain represents an ENSv2 Domain.""" type ENSv2Domain implements Domain { """All Events associated with this Domain.""" @@ -385,7 +444,12 @@ type ENSv2Domain implements Domain { owner: Account """ - The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical. + The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + """ + parent: Domain + + """ + The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. """ path: [Domain!] @@ -431,6 +495,26 @@ type ENSv2DomainPermissionsConnectionEdge { node: PermissionsUser! } +"""An ENSv2Registry represents an ENSv2 Registry contract.""" +type ENSv2Registry implements Registry { + """ + Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. + """ + contract: AccountId! + + """The Domains managed by this Registry.""" + domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection + + """A unique reference to this Registry.""" + id: RegistryId! + + """The Domains for which this Registry is a Subregistry.""" + parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection + + """The Permissions managed by this Registry.""" + permissions: Permissions +} + """ ENSv2RegistryRegistration represents a Registration within an ENSv2 Registry. """ @@ -781,6 +865,9 @@ type Query { """Identify an Account by ID or Address.""" account(by: AccountByInput!): Account + """TODO""" + allDomains(after: String, before: String, first: Int, last: Int): QueryAllDomainsConnection + """Identify a Domain by Name or DomainId""" domain(by: DomainIdInput!): Domain @@ -793,7 +880,9 @@ type Query { """TODO""" registrations(after: String, before: String, first: Int, last: Int): QueryRegistrationsConnection - """Identify a Registry by ID or AccountId.""" + """ + Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned. + """ registry(by: RegistryIdInput!): Registry """Identify a Resolver by ID or AccountId.""" @@ -802,14 +891,21 @@ type Query { """TODO""" resolvers(after: String, before: String, first: Int, last: Int): QueryResolversConnection - """The ENSv2 Root Registry, if exists.""" - root: Registry + """ + The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined or the ENSv1 Root Registry. + """ + root: Registry! +} - """TODO""" - v1Domains(after: String, before: String, first: Int, last: Int): QueryV1DomainsConnection +type QueryAllDomainsConnection { + edges: [QueryAllDomainsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} - """TODO""" - v2Domains(after: String, before: String, first: Int, last: Int): QueryV2DomainsConnection +type QueryAllDomainsConnectionEdge { + cursor: String! + node: Domain! } type QueryDomainsConnection { @@ -845,28 +941,6 @@ type QueryResolversConnectionEdge { node: Resolver! } -type QueryV1DomainsConnection { - edges: [QueryV1DomainsConnectionEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type QueryV1DomainsConnectionEdge { - cursor: String! - node: ENSv1Domain! -} - -type QueryV2DomainsConnection { - edges: [QueryV2DomainsConnectionEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type QueryV2DomainsConnectionEdge { - cursor: String! - node: ENSv2Domain! -} - """ A Registration represents a Domain's registration status within the various registries. """ @@ -923,9 +997,13 @@ type RegistrationRenewalsConnectionEdge { node: Renewal! } -"""A Registry represents an ENSv2 Registry contract.""" -type Registry { - """Contract metadata for this Registry""" +""" +A Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry. +""" +interface Registry { + """ + Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. + """ contract: AccountId! """The Domains managed by this Registry.""" @@ -977,7 +1055,7 @@ type RegistryParentsConnection { type RegistryParentsConnectionEdge { cursor: String! - node: ENSv2Domain! + node: Domain! } type RegistryPermissionsUser {