From db21e8e3b298248e2661c45a4f5a83758e391814 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 17:10:14 -0500 Subject: [PATCH 01/19] checkpoint: initial spec --- SPEC-domain-model.md | 482 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 SPEC-domain-model.md diff --git a/SPEC-domain-model.md b/SPEC-domain-model.md new file mode 100644 index 000000000..714950a24 --- /dev/null +++ b/SPEC-domain-model.md @@ -0,0 +1,482 @@ +# SPEC: Unified polymorphic `domain` + `registry` + +Tracking: closes [#205](https://github.com/namehash/ensnode/issues/205), [#1511](https://github.com/namehash/ensnode/issues/1511), [#1877](https://github.com/namehash/ensnode/issues/1877). + +## Goal + +Merge `v1Domain` + `v2Domain` into a single polymorphic `domain` table. Make Registry polymorphic (ENSv1 / ENSv1Virtual / ENSv2) with a GraphQL interface mirroring Domain/Registration. ENSv1 `DomainId` becomes CAIP-shaped: `${ENSv1RegistryId}/${node}`. + +After this refactor, `find-domains`, protocol acceleration logic, and `get-domain-by-interpreted-name` operate over single unified tables. + +## Conceptual model + +- **Concrete ENSv1Registry** — an actual ENSv1 Registry contract (main ENS Registry on mainnet, Basenames Registry on Base, Lineanames Registry on Linea — the latter two are "shadow" ENSv1 registries). +- **ENSv1VirtualRegistry** — a virtual registry managed by each ENSv1 domain that has children. Lazily upserted when a subname is created. Keyed by `(chainId, address, node)` where `(chainId, address)` is the concrete Registry that housed the parent domain, and `node` is the parent's namehash. +- **ENSv2Registry** — existing ENSv2 Registry contracts (unchanged behavior). + +Every ENSv1 domain has `registryId` pointing at either: +- the concrete Registry, if the domain's parent is the Registry's Managed Name (e.g. `foo.eth` → concrete eth Registry); or +- the parent's virtual registry, otherwise (e.g. `sub.foo.eth` → virtual registry for `foo.eth`). + +The virtual registry for a parent domain is upserted only when the parent's first child is indexed. Upon upsert, `registryCanonicalDomain(virtualRegistryId → parentDomainId)` is also upserted (self-link) so reverse traversal works uniformly with ENSv2. + +The two nametrees (ENSv1 and ENSv2) are disjoint by design. Cross-chain bridging (mainnet ↔ Basenames/Lineanames) is handled by protocol-acceleration's bridged resolver, not by wiring `subregistryId` across chains. + +## Resolved decisions + +- `registryType` and `domainType` are `onchainEnum`s in the schema; TypeScript types are inferred (follow the `registrationType` pattern). +- `registry` table adds `type`. Replaces `uniqueIndex(chainId, address)` with a plain `index(chainId, address)`. +- `registry` table adds `node` (nullable, no index). Invariant: `node IS NOT NULL` iff `type === "ENSv1VirtualRegistry"`. Exposed on `ENSv1VirtualRegistryRef` only. +- ID shapes: + - `ENSv1RegistryId` = branded CAIP-10 string + - `ENSv2RegistryId` = branded CAIP-10 string + - `ENSv1VirtualRegistryId = ${ENSv1RegistryId}/${node}` + - `ENSv1DomainId = ${ENSv1RegistryId}/${node}` (same shape as virtual; distinct tables) +- ENSv1 `handleNewOwner` parent-registry selection: + ```ts + const { node: managedNode } = getManagedName(registry); + const parentRegistryId = + parentNode === managedNode + ? registryId + : makeENSv1VirtualRegistryId(registry, parentNode); + ``` + If `parentRegistryId !== registryId`, upsert the virtual registry row and upsert `registryCanonicalDomain(parentVirtualRegistryId → parentDomainId)`. +- Handler variable pattern: `registry = getThisAccountId(context, event)`, `registryId = makeENSv1RegistryId(registry)`, and `virtualRegistryId` when needed. +- Concrete `ENSv1Registry` row upserted with `onConflictDoNothing` on first event. +- `ENSv2Domain.tokenId` column is nullable in the schema; derived `ENSv2Domain` TS type asserts non-null via `RequiredAndNotNull`. Invariant assumed, no runtime check. Same pattern for `ENSv1VirtualRegistry.node`. +- `get-domain-by-interpreted-name.ts` keeps `Promise.all([v1, v2])` parallel fetching. Each traversal queries the unified `domain` table but is rooted at its respective ENSv1 or ENSv2 root registry. +- `parent` field moves from `ENSv1Domain` to the `Domain` interface; resolved via the canonical-path dataloader (`results[1]`). +- `Registry` becomes a GraphQL interface with `ENSv1Registry`, `ENSv1VirtualRegistry`, and `ENSv2Registry` implementations, following the `RegistrationInterfaceRef` pattern. +- `RegistryIdInput` AccountId path filters `type IN ('ENSv1Registry', 'ENSv2Registry')` (excludes virtual). +- `filter-by-parent.ts` needs no changes — works automatically once `domainsBase` is unified. +- Full reindex (no migrations). Single PR, multiple green commits. Single cross-package breaking changeset. +- Add `getENSv1RootRegistryId(namespace)` helper in `packages/ensnode-sdk/src/shared/root-registry.ts`. + +--- + +## Commit 1 — types + ID makers (`packages/enssdk`) + +**`src/lib/types/ensv2.ts`** + +```ts +export type ENSv1RegistryId = AccountIdString & { __brand: "ENSv1RegistryId" }; +export type ENSv2RegistryId = AccountIdString & { __brand: "ENSv2RegistryId" }; +export type ENSv1VirtualRegistryId = string & { __brand: "ENSv1VirtualRegistryId" }; +// shape: ${ENSv1RegistryId}/${node} + +export type RegistryId = ENSv1RegistryId | ENSv1VirtualRegistryId | ENSv2RegistryId; + +export type ENSv1DomainId = string & { __brand: "ENSv1DomainId" }; +// shape: ${ENSv1RegistryId}/${node} (no longer Node-derived) + +export type DomainId = ENSv1DomainId | ENSv2DomainId; // unchanged union +``` + +**`src/lib/ids.ts`** + +```ts +export const makeENSv1RegistryId = (acc: AccountId) => + stringifyAccountId(acc) as ENSv1RegistryId; +export const makeENSv2RegistryId = (acc: AccountId) => + stringifyAccountId(acc) as ENSv2RegistryId; + +export const makeENSv1VirtualRegistryId = (acc: AccountId, node: Node) => + `${makeENSv1RegistryId(acc)}/${node}` as ENSv1VirtualRegistryId; + +export const makeENSv1DomainId = (acc: AccountId, node: Node) => + `${makeENSv1RegistryId(acc)}/${node}` as ENSv1DomainId; // BREAKING: now takes acc + node + +// makeENSv2DomainId unchanged +// keep makeRegistryId if still used by union callsites +``` + +**Validate:** `pnpm -F enssdk typecheck`. + +--- + +## Commit 2 — root helper + unified schema + +**`packages/ensnode-sdk/src/shared/root-registry.ts`** + +Add `getENSv1RootRegistryId(namespace)` and `maybeGetENSv1RootRegistryId(namespace)` mirroring the v2 helpers. Resolves the concrete ENSv1 root Registry (ENSRoot datasource) per namespace. + +**`packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts`** + +Add enums: + +```ts +export const registryType = onchainEnum("RegistryType", [ + "ENSv1Registry", + "ENSv1VirtualRegistry", + "ENSv2Registry", +]); + +export const domainType = onchainEnum("DomainType", [ + "ENSv1Domain", + "ENSv2Domain", +]); +``` + +`registry` table: + +```ts +export const registry = onchainTable( + "registries", + (t) => ({ + id: t.text().primaryKey().$type(), + type: registryType().notNull(), + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + // non-null iff type === "ENSv1VirtualRegistry" + node: t.hex().$type(), + }), + (t) => ({ + byChainAddress: index().on(t.chainId, t.address), // plain, not unique + }), +); +``` + +Drop `v1Domain` + `v2Domain`. Single `domain` table: + +```ts +export const domain = onchainTable( + "domains", + (t) => ({ + id: t.text().primaryKey().$type(), + type: domainType().notNull(), + registryId: t.text().notNull().$type(), + subregistryId: t.text().$type(), // nullable + // non-null iff type === "ENSv2Domain" + tokenId: t.bigint(), + labelHash: t.hex().notNull().$type(), + ownerId: t.hex().$type
(), + // v1-only + rootRegistryOwnerId: t.hex().$type
(), + // parentId removed; canonical path via registryCanonicalDomain + }), + (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), + byLabelHash: index().on(t.labelHash), + }), +); +``` + +Relations: + +- `domain.registry` → `registry` (via `registryId`) +- `domain.subregistry` → `registry` (via `subregistryId`) +- `domain.owner` → `account` +- `domain.rootRegistryOwner` → `account` +- `domain.label` → `label` +- `domain.registrations` → `registration` (many) +- `registry` → `many(domain)` in both registry and subregistry roles + +`registrationType`, `registration`, and `registryCanonicalDomain` unchanged. `registration.domainId.$type()` already a union — confirm. + +**Validate:** `pnpm -F @ensnode/ensdb-sdk typecheck`, `pnpm -F @ensnode/ensnode-sdk typecheck`. + +--- + +## Commit 3 — indexer handlers (`apps/ensindexer`) + +Pattern for all v1 handlers: + +```ts +const registry = getThisAccountId(context, event); +const registryId = makeENSv1RegistryId(registry); + +await context.ensDb + .insert(ensIndexerSchema.registry) + .values({ + id: registryId, + type: "ENSv1Registry", + ...registry, + node: null, + }) + .onConflictDoNothing(); +``` + +### `src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts` + +**`handleNewOwner`:** + +```ts +const node = makeSubdomainNode(labelHash, parentNode); +const domainId = makeENSv1DomainId(registry, node); +const parentDomainId = makeENSv1DomainId(registry, parentNode); + +const { node: managedNode } = getManagedName(registry); +const parentRegistryId = + parentNode === managedNode + ? registryId + : makeENSv1VirtualRegistryId(registry, parentNode); + +if (parentRegistryId !== registryId) { + // upsert parent's virtual registry + await context.ensDb + .insert(ensIndexerSchema.registry) + .values({ + id: parentRegistryId, + type: "ENSv1VirtualRegistry", + chainId: registry.chainId, + address: registry.address, + node: parentNode, + }) + .onConflictDoNothing(); + + // self-link canonical domain for reverse traversal + await context.ensDb + .insert(ensIndexerSchema.registryCanonicalDomain) + .values({ registryId: parentRegistryId, domainId: parentDomainId }) + .onConflictDoUpdate({ domainId: parentDomainId }); +} + +await context.ensDb + .insert(ensIndexerSchema.domain) + .values({ + id: domainId, + type: "ENSv1Domain", + registryId: parentRegistryId, + labelHash, + }) + .onConflictDoNothing(); + +// keep existing rootRegistryOwnerId update + materializeENSv1DomainEffectiveOwner + ensureDomainEvent +``` + +**`handleTransfer` / `handleNewTTL` / `handleNewResolver`:** +- `const registry = getThisAccountId(...)`; +- `const domainId = makeENSv1DomainId(registry, node);` + +### `src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts` + +- `registry` insert: `type: "ENSv2Registry"`, `node: null`. +- Writes to `ensIndexerSchema.domain` with `type: "ENSv2Domain"`, `tokenId` set. +- `SubregistryUpdated` canonicalDomain logic unchanged. + +### Other handlers (retarget `domain`, add `type`, update id signatures) + +- `src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts` +- `src/plugins/ensv2/handlers/ensv1/{BaseRegistrar,NameWrapper,RegistrarController}.ts` +- `src/plugins/subgraph/shared-handlers/NameWrapper.ts` +- `src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ENSv2Registry,ThreeDNSToken}.ts` +- `src/lib/ensv2/domain-db-helpers.ts` — `materializeENSv1DomainEffectiveOwner` updates `ensIndexerSchema.domain`. + +**Validate:** `pnpm -F @ensnode/ensindexer typecheck`, `pnpm lint`, ensindexer unit tests. + +--- + +## Commit 4 — omnigraph / API (`apps/ensapi/src/omnigraph-api`) + +### `context.ts` + +Drop `v1CanonicalPath` + `v2CanonicalPath`; add single `canonicalPath` loader. + +### `lib/get-canonical-path.ts` + +Replace with a single `getCanonicalPath(domainId)`. Recursive CTE over `domain` + `registryCanonicalDomain`: + +- Base: `domain.id = $domainId`. +- Step: `JOIN registryCanonicalDomain rcd ON rcd.registryId = cur.registryId JOIN domain parent ON parent.id = rcd.domainId`. +- Terminates when `registryId` equals the namespace's v1 root or v2 root. + +The virtual-registry self-link rows written in Commit 3 make v1 reverse traversal uniform with v2. + +### `lib/get-domain-by-interpreted-name.ts` + +Keep `Promise.all([v1, v2])` structure. Each branch does a forward recursive CTE over the unified `domain` table: + +- `v1_` rooted at `getENSv1RootRegistryId(namespace)` (concrete ENSv1Registry id). +- `v2_` rooted at `maybeGetENSv2RootRegistryId(namespace)`. + +CTE shape identical to the current v2 traversal; domain-to-next-domain hops go via `domain.registryId`, so ENSv1 traversal moves transparently through virtual registries. + +"Prefer v2" ordering preserved. The old direct `v1Domain.findFirst` path is removed in favor of the traversal. + +### `lib/find-domains/layers/` + +- `base-domain-set.ts` — single `domain` source (no union). +- `filter-by-registry.ts` — `eq(base.registryId, id)`; delete the v2-only comment. +- `filter-by-parent.ts` — **no changes needed**; works automatically. +- `filter-by-canonical.ts` — uses unified `canonicalRegistriesCte`. +- `filter-by-name.ts` — retarget `domain`. +- `canonical-registries-cte.ts` — unified forward traversal over `domain` + `registryCanonicalDomain`, rooted at v1 or v2 root. + +### `schema/domain.ts` (follow `schema/registration.ts` pattern) + +```ts +export type Domain = Exclude; +export type DomainInterface = Pick< + Domain, + | "id" | "type" | "registryId" | "subregistryId" | "labelHash" + | "ownerId" | "rootRegistryOwnerId" | "tokenId" +>; +export type ENSv1Domain = Domain & { type: "ENSv1Domain" }; +export type ENSv2Domain = RequiredAndNotNull & { type: "ENSv2Domain" }; +``` + +- `DomainInterfaceRef.load` → single `ensDb.query.domain.findMany({ where: inArray(id, ids), with: { label: true } })`. +- `isENSv1Domain = (d) => (d as DomainInterface).type === "ENSv1Domain"`. +- Move `parent` onto `DomainInterfaceRef`: + ```ts + parent: t.field({ + type: DomainInterfaceRef, + nullable: true, + resolve: async (d, _, ctx) => { + const path = await ctx.loaders.canonicalPath.load(d.id); + return path?.[1] ?? null; + }, + }); + ``` +- `ENSv1DomainRef`: keep `rootRegistryOwner`; `isTypeOf: (v) => (v as DomainInterface).type === "ENSv1Domain"`. +- `ENSv2DomainRef`: keep `tokenId` / `registry` / `subregistry` / `permissions`; `isTypeOf: (v) => (v as DomainInterface).type === "ENSv2Domain"`. + +### `schema/registry.ts` (new interface pattern) + +```ts +export const RegistryInterfaceRef = builder.loadableInterfaceRef("Registry", { + load: (ids: RegistryId[]) => + ensDb.query.registry.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Registry = Exclude; +export type RegistryInterface = Pick; +export type ENSv1Registry = Registry & { type: "ENSv1Registry" }; +export type ENSv1VirtualRegistry = RequiredAndNotNull & { + type: "ENSv1VirtualRegistry"; +}; +export type ENSv2Registry = Registry & { type: "ENSv2Registry" }; +``` + +- `RegistryInterfaceRef.implement` — shared fields: `id`, `type`, `contract` (AccountId), `parents`, `domains`, `permissions`. +- `parents` is defined on the interface and returns `DomainInterfaceRef`. For `ENSv1VirtualRegistry` the sole parent is the canonical v1 domain (self-linked via `registryCanonicalDomain`); for concrete `ENSv1Registry` the parents are the TLDs under it; for `ENSv2Registry` the parents are the v2 domains declaring it as subregistry. +- `ENSv1RegistryRef` / `ENSv2RegistryRef`: `isTypeOf` checks on `.type`; minimal (or no) extra fields. +- `ENSv1VirtualRegistryRef`: exposes `node: Node` non-null; `isTypeOf: (v) => v.type === "ENSv1VirtualRegistry"`. +- Replace callsite usage of `RegistryRef` with `RegistryInterfaceRef` across the API layer. +- `RegistryIdInput` AccountId path resolver: + ```ts + where: and( + eq(registry.chainId, chainId), + eq(registry.address, address), + inArray(registry.type, ["ENSv1Registry", "ENSv2Registry"]), + ); + ``` + +### `schema/registration.ts`, `schema/query.ts`, `schema/account.ts`, `schema/permissions.ts` + +- Retarget `v1Domain` / `v2Domain` → `domain`. +- Swap `RegistryRef` imports to `RegistryInterfaceRef`. +- Permissions join by `(chainId, address)` + `registry.id = parent.registryId` remains correct (id narrows even with non-unique chainId/address). + +### Generated files + +Regenerate `packages/enssdk/src/omnigraph/generated/{introspection.ts,schema.graphql}` from pothos. + +**Validate:** `pnpm -F ensapi typecheck`, `pnpm lint`, ensapi unit tests. + +--- + +## Commit 5 — tests + changeset + +**Tests to update:** + +- `packages/ensdb-sdk/src/lib/drizzle.test.ts` — new schema shape. +- `apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts` — v1 id format, `Domain.parent`, unified loader, Registry interface queries. +- `apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts` — id churn. +- Fixture builders that produce v1 ids from bare nodes. + +**Run:** `pnpm test:integration` from the monorepo root. + +**Changeset:** single breaking changeset in `.changeset/`; major bumps for: + +- `enssdk` +- `@ensnode/ensdb-sdk` +- `@ensnode/ensnode-sdk` +- `@ensnode/ensindexer` +- `@ensnode/ensapi` + +Body notes: schema + id format breaking; requires full reindex; introduces polymorphic Registry GraphQL interface; closes #205, #1877, #1511. + +--- + +## Callsite audit + +### Domain writes (retarget `domain`, add `type`, update ids) + +- `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/{ENSv2Registry,ETHRegistrar}.ts` +- `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/{ENSv1Registry,BaseRegistrar,NameWrapper,RegistrarController}.ts` +- `apps/ensindexer/src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ENSv2Registry,ThreeDNSToken}.ts` +- `apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts` +- `apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts` + +### Domain reads / API (retarget `domain`) + +- `apps/ensapi/src/omnigraph-api/schema/{domain,registry,registration,query,account,permissions}.ts` +- `apps/ensapi/src/omnigraph-api/context.ts`, `yoga.ts` +- `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` +- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts` +- `apps/ensapi/src/omnigraph-api/lib/find-domains/layers/{base-domain-set,filter-by-name,filter-by-registry,filter-by-canonical}.ts` (skip `filter-by-parent`) +- `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` + +### `ensIndexerSchema.registry` reads (all OK via id narrowing except `RegistryIdInput`) + +- `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts:347` — by id, OK +- `apps/ensindexer/src/lib/indexing-engines/ponder.ts` — re-export, OK +- `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` — by id, OK +- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts:128` — by id, OK +- `apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts` — id join, OK +- `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` — id join, OK +- `apps/ensapi/src/omnigraph-api/schema/domain.ts:387` — permissions join, id-narrowed, OK +- `apps/ensapi/src/omnigraph-api/schema/{account,registry}.ts` — by id/registryId, OK +- **`RegistryIdInput` AccountId resolver** — add `type IN ('ENSv1Registry', 'ENSv2Registry)` filter. + +### `makeENSv1DomainId` signature change (acc + node) + +- `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/{ENSv1Registry,BaseRegistrar,NameWrapper,RegistrarController}.ts` +- `apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts` +- `apps/ensindexer/src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ThreeDNSToken}.ts` +- `apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts` +- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts:96` + +### Generated (regenerate, don't hand-edit) + +- `packages/enssdk/src/omnigraph/generated/{introspection.ts,schema.graphql}` + +--- + +## Task dependency graph + +``` +#1 (types+ids) ─┐ + ├─► #3 (schema) ─► #4 (indexer) ─► #5 (omnigraph) ─► #6 (tests+changeset) +#2 (v1 root) ─┘ +``` + +--- + +## Key file references + +- Schema: `packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts` +- IDs: `packages/enssdk/src/lib/ids.ts`, `packages/enssdk/src/lib/types/ensv2.ts` +- ENSv1 handler: `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts` +- ENSv2 handler: `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts` +- Managed names: `apps/ensindexer/src/lib/managed-names.ts` +- Registration schema pattern (reference for polymorphism): `apps/ensapi/src/omnigraph-api/schema/registration.ts` +- Domain schema: `apps/ensapi/src/omnigraph-api/schema/domain.ts` +- Registry schema: `apps/ensapi/src/omnigraph-api/schema/registry.ts` +- Canonical path: `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` +- Interpreted-name lookup: `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts` +- Canonical CTE: `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` +- Root registry helpers: `packages/ensnode-sdk/src/shared/root-registry.ts` + +--- + +## Branch + +`refactor/ensv1-domain-model` From a7e70bb6a3875fc6760f2e7810ca0377685fb943 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 18:16:50 -0500 Subject: [PATCH 02/19] enssdk: add v1/v2 registry id brands and reshape ENSv1DomainId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split `RegistryId` into a union of `ENSv1RegistryId`, `ENSv1VirtualRegistryId`, and `ENSv2RegistryId`. Add corresponding `makeENSv1RegistryId`, `makeENSv2RegistryId`, and `makeENSv1VirtualRegistryId` constructors, and keep `makeRegistryId` as a union-returning helper for callsites that genuinely can't narrow (e.g. client-side cache key reconstruction). Reshape `ENSv1DomainId` from `Node` to `${ENSv1RegistryId}/${node}` so ENSv1 domains are addressable in the same namegraph model as ENSv1VirtualRegistry. `makeENSv1DomainId` now takes `(AccountId, Node)` — breaking change for all callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/enssdk/src/lib/ids.ts | 21 +++++++++++++++++- packages/enssdk/src/lib/types/ensv2.ts | 30 +++++++++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index c4721cc52..4003602c3 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,27 @@ 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; 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 From 10e0307f927d1f19c10b037815fc03937a0b6e58 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 18:17:06 -0500 Subject: [PATCH 03/19] ensdb-sdk, ensnode-sdk: unified polymorphic domain + registry schema Replace the split `v1_domains` + `v2_domains` tables with a single polymorphic `domains` table keyed by `DomainId` and discriminated by `domainType` enum (`"ENSv1Domain"` | `"ENSv2Domain"`). Drop `domain.parentId`; ENSv1 parent traversal now flows through `registryCanonicalDomain` uniformly with ENSv2. `tokenId` becomes nullable (non-null iff ENSv2). Make `registries` polymorphic: add `registryType` enum (`"ENSv1Registry"` | `"ENSv1VirtualRegistry"` | `"ENSv2Registry"`), add nullable `node` column (non-null iff virtual), replace the unique `(chainId, address)` constraint with a plain index so virtual Registries keyed by node can share (chainId, address) with their concrete parent. Widen `registryCanonicalDomain.domainId` from `ENSv2DomainId` to the unified `DomainId`. Add `getENSv1RootRegistryId` / `maybeGetENSv1RootRegistryId` / `maybeGetENSv1Registry` helpers mirroring the v2 equivalents; narrow v2 helpers to use `makeENSv2RegistryId`. Update the ensdb-sdk drizzle test to reference the unified `domain` export. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/ensindexer-abstract/ensv2.schema.ts | 178 +++++++----------- packages/ensdb-sdk/src/lib/drizzle.test.ts | 12 +- .../ensnode-sdk/src/shared/root-registry.ts | 29 ++- 3 files changed, 102 insertions(+), 117 deletions(-) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 828112322..1a99a3779 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, @@ -35,18 +34,17 @@ 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. + * For the datamodel, this means a single polymorphic `domain` table captures both ENSv1 and ENSv2 + * Domains, discriminated by a `type` column. Domain polymorphism is exposed at the API layer 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 mind-boggling 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. * @@ -59,20 +57,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 @@ -166,7 +162,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 +170,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(), + // discriminates concrete ENSv1 / virtual ENSv1 / ENSv2 Registries + type: registryType().notNull(), + chainId: t.integer().notNull().$type(), address: t.hex().notNull().$type
(), + + // INVARIANT: non-null iff `type === "ENSv1VirtualRegistry"`. + // For a virtual registry, `node` is the namehash of the parent ENSv1 domain that owns it. + node: t.hex().$type(), }), (t) => ({ - byId: uniqueIndex().on(t.chainId, t.address), + // plain (non-unique) index — 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 (ENSv2 only) + domainsAsSubregistry: many(domain, { relationName: "subregistry" }), permissions: one(permissions, { relationName: "permissions", fields: [registry.chainId, registry.address], @@ -206,84 +214,40 @@ 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
(), - - // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin - }), - (t) => ({ - byParent: index().on(t.parentId), - byOwner: index().on(t.ownerId), - byLabelHash: index().on(t.labelHash), - }), -); - -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], - }), - - // 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), -})); +export const domainType = onchainEnum("DomainType", ["ENSv1Domain", "ENSv2Domain"]); -export const v2Domain = onchainTable( - "v2_domains", +export const domain = onchainTable( + "domains", (t) => ({ - // see ENSv2DomainId for guarantees - id: t.text().primaryKey().$type(), + // see DomainId for guarantees (ENSv1DomainId: `${ENSv1RegistryId}/${node}`, ENSv2DomainId: CAIP-19) + id: t.text().primaryKey().$type(), - // has a tokenId - tokenId: t.bigint().notNull(), + // discriminates ENSv1 / ENSv2 Domains + type: domainType().notNull(), - // belongs to registry + // belongs to a registry (concrete or virtual for ENSv1; concrete for ENSv2) registryId: t.text().notNull().$type(), - // may have one subregistry + // may have a subregistry (ENSv2 only in practice; nullable for ENSv1 Domains) subregistryId: t.text().$type(), - // may have an owner - ownerId: t.hex().$type
(), + // INVARIANT: non-null iff `type === "ENSv2Domain"`. + tokenId: t.bigint(), // represents a labelHash labelHash: t.hex().notNull().$type(), + // may have an owner (effective owner for ENSv1; materialized by the ENSv1 handlers) + ownerId: t.hex().$type
(), + + // ENSv1 only: may have a `rootRegistryOwner` (ENSv1Registry's owner()), zeroAddress → 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 +255,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 +357,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 +543,7 @@ export const label = onchainTable( ); export const label_relations = relations(label, ({ many }) => ({ - domains: many(v2Domain), + domains: many(domain), })); /////////////////// @@ -596,5 +558,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 9ee6316e3..4bedfa30c 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/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index f8d49b931..3feac5fbe 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 } 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,5 @@ export const maybeGetENSv2RootRegistry = (namespace: ENSNamespaceId) => export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { const root = maybeGetENSv2RootRegistry(namespace); if (!root) return undefined; - return makeRegistryId(root); + return makeENSv2RegistryId(root); }; From 0af2647d78e72e9113b6c54797a0119716a051fb Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 18:17:41 -0500 Subject: [PATCH 04/19] ensindexer: migrate handlers to unified domain + polymorphic registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `managed-names.ts`: `CONTRACTS_BY_MANAGED_NAME` now maps `Name` to `{ registry, contracts }`, `getManagedName(contract)` returns `{ name, node, registry }` so any Registrar / Controller / NameWrapper handler can resolve the concrete ENSv1 Registry that governs its namegraph. Add the ENS Root (`""`) Managed Name group covering the mainnet ENSv1Registry and ENSv1RegistryOld; include each shadow Registry (Basenames, Lineanames) in its respective Managed Name group. Groups for namespaces that don't ship a given shadow Registry are omitted entirely. ENSv1 `handleNewOwner`: upsert the concrete ENSv1 Registry row, pick `parentRegistryId` as the concrete Registry when `parentNode` is the Managed Name and as an `ENSv1VirtualRegistry` keyed by `parentNode` otherwise. When the parent is virtual, also upsert the virtual Registry row and the `registryCanonicalDomain` self-link so reverse traversal works uniformly with ENSv2. Combine domain upsert with `rootRegistryOwner` update into one query via `onConflictDoUpdate`. Canonicalize ENSv1Registry / ENSv1RegistryOld events through `getManagedName(...).registry` — ENSRegistryWithFallback proxies reads, so both contracts face the same logical namegraph and should write into the same Registry ID. All remaining v1 handlers (Transfer / NewTTL / NewResolver, BaseRegistrar, NameWrapper, RegistrarController, protocol-acceleration ENSv1Registry / ThreeDNSToken) update to the two-arg `makeENSv1DomainId(registry, node)`. ENSv2 `handleRegistrationOrReservation`: switch `makeRegistryId` to `makeENSv2RegistryId`, add `type: "ENSv2Registry"` to the Registry insert and `type: "ENSv2Domain"` to the Domain insert. Update `domain-db-helpers.materializeENSv1DomainEffectiveOwner` to write through the unified `domain` table. Update the managed-names test to assert the new `registry` return field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/ensv2/domain-db-helpers.ts | 2 +- apps/ensindexer/src/lib/managed-names.test.ts | 18 +- apps/ensindexer/src/lib/managed-names.ts | 193 +++++++++++------- .../ensv2/handlers/ensv1/BaseRegistrar.ts | 16 +- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 85 ++++++-- .../ensv2/handlers/ensv1/NameWrapper.ts | 17 +- .../handlers/ensv1/RegistrarController.ts | 8 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 27 +-- .../handlers/ENSv1Registry.ts | 7 +- .../handlers/ThreeDNSToken.ts | 2 +- 10 files changed, 250 insertions(+), 125 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts index 78f3f3401..1399d87d7 100644 --- a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts @@ -18,6 +18,6 @@ export async function materializeENSv1DomainEffectiveOwner( // update v1Domain'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 1e0fdb87e..7bb87fa42 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, @@ -36,12 +37,32 @@ import { toJson } from "@/lib/json-stringify-with-bigints"; * 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, @@ -49,73 +70,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, + }), }; /** @@ -141,13 +187,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]; @@ -157,7 +208,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 396774865..2f914e0ef 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts @@ -75,9 +75,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) { @@ -85,7 +85,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 @@ -109,10 +109,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); @@ -140,7 +140,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 @@ -169,9 +169,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..a25dc6e41 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -5,9 +5,12 @@ import { ENS_ROOT_NODE, type LabelHash, makeENSv1DomainId, + makeENSv1RegistryId, + makeENSv1VirtualRegistryId, makeSubdomainNode, type Node, type NormalizedAddress, + type RegistryId, } from "enssdk"; import { isAddressEqual, zeroAddress } from "viem"; @@ -16,12 +19,14 @@ import { getENSRootChainId, interpretAddress, PluginName } from "@ensnode/ensnod import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-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,53 @@ 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 Manage 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 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,16 +125,19 @@ export default function () { await ensureUnknownLabel(context, labelHash); } - // upsert domain - await context.ensDb - .insert(ensIndexerSchema.v1Domain) - .values({ id: domainId, parentId, labelHash }) - .onConflictDoNothing(); + const rootRegistryOwnerId = interpretAddress(owner); - // update rootRegistryOwner + // upsert domain, always updating rootRegistryOwner await context.ensDb - .update(ensIndexerSchema.v1Domain, { id: domainId }) - .set({ rootRegistryOwnerId: interpretAddress(owner) }); + .insert(ensIndexerSchema.domain) + .values({ + id: domainId, + type: "ENSv1Domain", + registryId: parentRegistryId, + labelHash, + rootRegistryOwnerId, + }) + .onConflictDoUpdate({ rootRegistryOwnerId }); // materialize domain owner // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars @@ -111,11 +163,12 @@ 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 }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ rootRegistryOwnerId: interpretAddress(owner) }); // materialize domain owner @@ -138,11 +191,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 +210,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 2002c49a4..0d3a139e8 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 a94df08e7..9b4837dd9 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -52,10 +52,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) { @@ -113,9 +113,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 a2d20d6fb..f477c1f35 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, @@ -58,7 +58,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); @@ -80,7 +80,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 @@ -107,11 +107,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, @@ -119,7 +120,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 @@ -266,7 +267,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, @@ -274,11 +275,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 @@ -288,7 +289,7 @@ export default function () { .onConflictDoUpdate({ domainId }); await context.ensDb - .update(ensIndexerSchema.v2Domain, { id: domainId }) + .update(ensIndexerSchema.domain, { id: domainId }) .set({ subregistryId }); } @@ -321,7 +322,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 @@ -343,13 +344,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]; From 5418244f8f7d6eb6a6a495ef623b0f9ee8c3f5c6 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 19:21:02 -0500 Subject: [PATCH 05/19] ensapi: migrate omnigraph to unified domain + polymorphic registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context & loaders - Drop `v1CanonicalPath` + `v2CanonicalPath` loaders in favour of a single `canonicalPath` loader backed by `getCanonicalPath(domainId)`. Canonical path - Replace `getV1CanonicalPath` + `getV2CanonicalPath` with a single recursive CTE over `domain` + `registryCanonicalDomain`. Recursion terminates naturally: roots have no `registryCanonicalDomain` entry, so the JOIN fails when we reach one. Canonicality is decided by the final `tld.registry_id === root` check. MAX_DEPTH guards against corrupted state. Interpreted-name lookup (`get-domain-by-interpreted-name.ts`) - Collapse the ENSv1 / ENSv2 branches into one `traverseFromRoot(root, name)` helper. Both lineages hop via `domain.subregistryId` (ENSv1 Domains now set this to their managed VirtualRegistry, symmetric with ENSv2 domains' declared subregistries). The starting root picks v1 vs v2 lineage; v1 and v2 registry IDs are disjoint, so no cross-contamination. Find-domains layers - `base-domain-set.ts`: single select over `domain`; `parentId` derived via `registryCanonicalDomain` uniformly for v1 and v2. - `filter-by-registry.ts`: simplify comment (no v1/v2 distinction). - `filter-by-canonical.ts`: all domains have a `registryId` now; canonicality reduces to `INNER JOIN` against the canonical-registries CTE. - `filter-by-name.ts`: collapse `v1DomainsByLabelHashPath` + `v2DomainsByLabelHashPath` into one CTE over `registryCanonicalDomain`. - `canonical-registries-cte.ts`: union v1 + v2 roots as base cases; recursive step uses `d.subregistry_id` uniformly. Schemas - `schema/domain.ts`: `DomainInterfaceRef` becomes a loadable interface with a single `ensDb.query.domain.findMany` loader. `DomainInterface = Omit`. Variant types tightened via `RequiredAndNotNull` / `RequiredAndNull` to encode invariants (`ENSv1Domain.{node: Node, tokenId: null}`; `ENSv2Domain.{tokenId: bigint, node: null, rootRegistryOwnerId: null}`). `parent` moves onto the interface via `ctx.loaders.canonicalPath`; expose `ENSv1Domain.node` as a first-class GraphQL field. - `schema/registry.ts`: new `RegistryInterfaceRef` with `ENSv1Registry`, `ENSv1VirtualRegistry`, `ENSv2Registry` implementations; shared fields (`id`, `type`, `contract`, `parents`, `domains`, `permissions`). `parents` uses `eq(domain.subregistryId, parent.id)` for virtual v1 and v2 (both set `subregistryId`), and `eq(domain.registryId, parent.id)` for concrete v1. `ENSv1VirtualRegistryRef` exposes `node: Node`. - `schema/query.ts`: `registry(by: {contract})` does a DB lookup filtered by `type IN (ENSv1Registry, ENSv2Registry)` — virtual Registries share `(chainId, address)` with their concrete parent and aren't addressable via contract alone. Dev-only `v1Domains` / `v2Domains` filter by `d.type`. - Swap `RegistryRef` → `RegistryInterfaceRef` in `query.ts` and `registry-permissions-user.ts`. - `schema/registration.ts`: `WrappedBaseRegistrarRegistration.tokenId` loads the domain via the `DomainInterfaceRef` dataloader and reads `domain.node`. Supporting changes - `ensdb-sdk` schema: add `domain.node: Hex` (non-null iff ENSv1Domain). - `ensindexer` ENSv1 `handleNewOwner`: write `node` on domain upsert and set parent domain's `subregistryId` to the VirtualRegistry when upserting it (so forward traversal + canonical-registries CTE work uniformly with v2). - `ensnode-sdk`: add `RequiredAndNull` helper type (symmetric to `RequiredAndNotNull`) for encoding "null in this variant" invariants. Regenerate pothos generated files (`schema.graphql`, `introspection.ts`). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensapi/src/omnigraph-api/context.ts | 18 +- .../find-domains/canonical-registries-cte.ts | 55 +- .../find-domains/layers/base-domain-set.ts | 76 +-- .../layers/filter-by-canonical.ts | 16 +- .../lib/find-domains/layers/filter-by-name.ts | 171 ++----- .../find-domains/layers/filter-by-parent.ts | 3 - .../find-domains/layers/filter-by-registry.ts | 5 +- .../omnigraph-api/lib/get-canonical-path.ts | 101 +--- .../lib/get-domain-by-interpreted-name.ts | 93 +--- .../ensapi/src/omnigraph-api/schema/domain.ts | 138 +++-- .../schema/query.integration.test.ts | 7 +- apps/ensapi/src/omnigraph-api/schema/query.ts | 81 ++- .../src/omnigraph-api/schema/registration.ts | 13 +- .../schema/registry-permissions-user.ts | 4 +- .../src/omnigraph-api/schema/registry.ts | 113 +++- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 6 + .../src/ensindexer-abstract/ensv2.schema.ts | 3 + packages/ensnode-sdk/src/shared/types.ts | 7 + .../src/omnigraph/generated/introspection.ts | 483 +++++++++++++++++- .../src/omnigraph/generated/schema.graphql | 90 +++- 20 files changed, 944 insertions(+), 539 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index a1ae5ac11..612f7bdf3 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..9b6d3e1e0 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,19 +2,18 @@ import config from "@/config"; import { sql } from "drizzle-orm"; -import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } 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. + * Note that the set of Canonical Registries is a _tree_ by construction: each Registry is reached + * via either `registryCanonicalDomain` (ENSv1 virtual / ENSv2) or the concrete ENSv1 root. For + * ENSv2, edge authentication (parent's `subregistryId` matches the child's `registryId`) prevents + * cycles in the declared namegraph; for ENSv1, each domain lives under exactly one Registry. * * 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 @@ -22,25 +21,30 @@ import { lazy } from "@/lib/lazy"; */ 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)); +const getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); +const getV2Root = 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 the ENSv1 root Registry and (when defined) + * the ENSv2 root Registry to construct a set of all Canonical Registries. + * + * A Canonical Registry is either root, or a Registry declared as a Subregistry by a Domain living + * in another Canonical Registry. 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 v1Root = getV1Root(); + const v2Root = getV2Root(); + + // TODO: this can be streamlined into a single union once ENSv2Root is available in all namespaces + const rootsUnion = v2Root + ? sql`SELECT ${v1Root}::text AS registry_id, 0 AS depth + UNION ALL + SELECT ${v2Root}::text AS registry_id, 0 AS depth` + : sql`SELECT ${v1Root}::text AS registry_id, 0 AS depth`; return ensDb .select({ @@ -53,15 +57,14 @@ 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 + 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} ) - SELECT registry_id FROM canonical_registries + SELECT registry_id FROM canonical_registries WHERE registry_id IS NOT NULL ) 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..6d7f9a9af 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 { eq, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -10,73 +10,47 @@ 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 .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, - and( - eq(v2ParentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), - eq(v2ParentDomain.subregistryId, ensIndexerSchema.v2Domain.registryId), - ), + parentDomain, + eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), ) + // 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..fd3a619c2 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,85 @@ 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}. In the unified model, both ENSv1 and ENSv2 parent links flow + * through `domain.registryId → registryCanonicalDomain.domainId`. */ -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"), - }) - .from( - sql`( - WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) - SELECT - d.id AS leaf_id, - d.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"), + 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) -- 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 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 SELECT upward_check.leaf_id, rcd.domain_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 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 +131,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 +149,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..8ca716a06 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,31 @@ 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 { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } 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. - * - * i.e. reverse traversal of the nametree - */ -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); -} +// lazy() defers construction until first use so that this module can be imported without env vars +// being present (e.g. during OpenAPI generation). +const getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); +const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); /** - * Provide the canonical parents for an ENSv2 Domain. + * Provide the canonical parents for a Domain via reverse traversal of the namegraph. * - * i.e. reverse traversal of the namegraph via registry_canonical_domains + * Traversal walks `domain → registry → canonical parent domain` via the + * {@link registryCanonicalDomain} table and terminates at either the namespace's v1 root Registry + * or its v2 root Registry. Returns `null` when the resulting path does not terminate at a + * root Registry (i.e. the Domain is not canonical). */ -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 v1Root = getV1Root(); + const v2Root = getV2Root(); const result = await ensDb.execute(sql` WITH RECURSIVE upward AS ( @@ -87,26 +33,25 @@ 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. 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 - ON pd.id = rcd.domain_id AND pd.subregistry_id = upward.registry_id - WHERE upward.registry_id != ${rootRegistryId} - AND upward.depth < ${MAX_DEPTH} + JOIN ${ensIndexerSchema.domain} pd + ON pd.id = rcd.domain_id + WHERE upward.depth < ${MAX_DEPTH} ) SELECT * FROM upward @@ -119,8 +64,10 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise maybeGetENSv2RootRegistryId(config.namespace)); +// lazy() defers construction until first use so that this module can be imported without env vars +// being present (e.g. during OpenAPI generation). +const getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); +const getV2Root = lazy(() => 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 +62,34 @@ 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 v1Root = getV1Root(); + const v2Root = getV2Root(); const [v1DomainId, v2DomainId] = await Promise.all([ - withActiveSpanAsync(tracer, "v1_getDomainId", {}, () => - v1_getDomainIdByInterpretedName(name), - ), - // only resolve v2Domain if ENSv2 Root Registry is defined - rootRegistryId - ? withActiveSpanAsync(tracer, "v2_getDomainId", {}, () => - v2_getDomainIdByInterpretedName(rootRegistryId, name), - ) + withActiveSpanAsync(tracer, "v1_getDomainId", {}, () => traverseFromRoot(v1Root, name)), + // only resolve v2 Domain if ENSv2 Root Registry is defined + v2Root + ? withActiveSpanAsync(tracer, "v2_getDomainId", {}, () => traverseFromRoot(v2Root, name)) : null, ]); logger.debug({ v1DomainId, v2DomainId }); - // prefer v2Domain over v1Domain + // prefer v2 Domain over v1 Domain 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; - - v1Logger.debug({ domainId, exists }); - - return exists ? domainId : 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 { @@ -121,23 +103,19 @@ async function v2_getDomainIdByInterpretedName( 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 +125,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.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 31d98b1c7..f19da42b5 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 }, - }), - ]); +const isENSv1Domain = (domain: unknown): domain is ENSv1Domain => + (domain as DomainInterface).type === "ENSv1Domain"; - return [...v1Domains, ...v2Domains]; - }, - toKey: getModelId, - cacheResolved: true, - sort: true, -}); +const isENSv2Domain = (domain: unknown): domain is ENSv2Domain => + (domain as DomainInterface).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,11 @@ 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); + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); if (!canonicalPath) return null; - // TODO: this could be more efficient if the get*CanonicalPath helpers included the label - // join for us. + // TODO: this could be more efficient if getCanonicalPath included the label join for us. const domains = await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), ); @@ -173,10 +145,8 @@ DomainInterfaceRef.implement({ 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); + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); if (!canonicalPath) return null; return await rejectAnyErrors( @@ -185,6 +155,20 @@ 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); + return path?.[1] ?? null; + }, + }), + //////////////// // Domain.owner //////////////// @@ -299,16 +283,16 @@ DomainInterfaceRef.implement({ ENSv1DomainRef.implement({ description: "An ENSv1Domain represents an ENSv1 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => isENSv1Domain(domain as Domain), + isTypeOf: (domain) => isENSv1Domain(domain), 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 +314,10 @@ ENSv1DomainRef.implement({ ENSv2DomainRef.implement({ description: "An ENSv2Domain represents an ENSv2 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => !isENSv1Domain(domain as Domain), + isTypeOf: (domain) => isENSv2Domain(domain), fields: (t) => ({ ////////////////////// - // Domain.tokenId + // ENSv2Domain.tokenId ////////////////////// tokenId: t.field({ description: "The ENSv2Domain's current Token Id.", @@ -342,21 +326,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..f127ae6a6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -38,7 +38,12 @@ 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, + namehashInterpretedName(asInterpretedName("eth")), +); const V2_ETH_STORAGE_ID = makeStorageId(labelhashInterpretedLabel(asInterpretedLabel("eth"))); const V2_ETH_DOMAIN_ID = makeENSv2DomainId(V2_ROOT_REGISTRY, V2_ETH_STORAGE_ID); diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 58148775e..59c237fe6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -1,7 +1,8 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { makePermissionsId, makeRegistryId, makeResolverId } from "enssdk"; +import { and, eq, inArray } from "drizzle-orm"; +import { makePermissionsId, makeResolverId } from "enssdk"; import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; @@ -24,12 +25,14 @@ import { DomainInterfaceRef, DomainsOrderInput, DomainsWhereInput, + type ENSv1Domain, ENSv1DomainRef, + type ENSv2Domain, 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 @@ -44,21 +47,26 @@ builder.queryType({ v1Domains: t.connection({ description: "TODO", type: ENSv1DomainRef, - resolve: (parent, args) => - lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.v1Domain), + resolve: (parent, args) => { + const scope = eq(ensIndexerSchema.domain.type, "ENSv1Domain"); + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.domain, scope), 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 }, - }), + ensDb.query.domain + .findMany({ + where: (t, { and }) => + and(scope, paginateBy(ensIndexerSchema.domain.id, before, after)), + orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), + limit, + with: { label: true }, + }) + .then((rows) => rows as ENSv1Domain[]), ), - }), + }); + }, }), ///////////////////////////// @@ -67,21 +75,26 @@ builder.queryType({ v2Domains: t.connection({ description: "TODO", type: ENSv2DomainRef, - resolve: (parent, args) => - lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.v2Domain), + resolve: (parent, args) => { + const scope = eq(ensIndexerSchema.domain.type, "ENSv2Domain"); + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.domain, scope), 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), - limit, - with: { label: true }, - }), + ensDb.query.domain + .findMany({ + where: (t, { and }) => + and(scope, paginateBy(ensIndexerSchema.domain.id, before, after)), + orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), + limit, + with: { label: true }, + }) + .then((rows) => rows as ENSv2Domain[]), ), - }), + }); + }, }), ///////////////////////////// @@ -180,11 +193,27 @@ builder.queryType({ /////////////////////////////////// registry: t.field({ description: "Identify a Registry by ID or AccountId.", - type: RegistryRef, + type: RegistryInterfaceRef, + nullable: true, args: { by: t.arg({ type: RegistryIdInput, required: true }) }, - resolve: (parent, args, context, info) => { + resolve: async (parent, args) => { if (args.by.id !== undefined) return args.by.id; - return makeRegistryId(args.by.contract); + // Look up the concrete Registry row by (chainId, address). Virtual Registries are excluded + // because they share (chainId, address) with their concrete parent and should not be + // addressable via AccountId alone. + const { chainId, address } = args.by.contract; + const [row] = await ensDb + .select({ id: ensIndexerSchema.registry.id }) + .from(ensIndexerSchema.registry) + .where( + and( + eq(ensIndexerSchema.registry.chainId, chainId), + eq(ensIndexerSchema.registry.address, address), + inArray(ensIndexerSchema.registry.type, ["ENSv1Registry", "ENSv2Registry"]), + ), + ) + .limit(1); + return row?.id ?? null; }, }), @@ -219,7 +248,7 @@ builder.queryType({ ///////////////////// root: t.field({ description: "The ENSv2 Root Registry, if exists.", - type: RegistryRef, + type: RegistryInterfaceRef, // TODO: make this nullable: false after all namespaces define ENSv2Root nullable: true, resolve: () => maybeGetENSv2RootRegistryId(config.namespace), diff --git a/apps/ensapi/src/omnigraph-api/schema/registration.ts b/apps/ensapi/src/omnigraph-api/schema/registration.ts index 87a69e8fe..00267d7fa 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, type ENSv1Domain } from "@/omnigraph-api/schema/domain"; import { EventRef } from "@/omnigraph-api/schema/event"; import { RenewalRef } from "@/omnigraph-api/schema/renewal"; @@ -343,8 +343,13 @@ 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, + )) as ENSv1Domain; + 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..757efabf2 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: unknown): registry is ENSv1Registry => + (registry as RegistryInterface).type === "ENSv1Registry"; + +const isENSv1VirtualRegistry = (registry: unknown): registry is ENSv1VirtualRegistry => + (registry as RegistryInterface).type === "ENSv1VirtualRegistry"; -RegistryRef.implement({ - description: "A Registry represents an ENSv2 Registry contract.", +const isENSv2Registry = (registry: unknown): registry is ENSv2Registry => + (registry as RegistryInterface).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,34 @@ RegistryRef.implement({ resolve: (parent) => parent.id, }), - //////////////////// + /////////////////// + // Registry.contract + /////////////////// + contract: t.field({ + description: "Contract metadata for this Registry", + 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 +139,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), +}); + +////////////////////////////// +// 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), + 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), +}); + ////////// // Inputs ////////// diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index a25dc6e41..05cb248d4 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -100,6 +100,11 @@ export default function () { }) .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) @@ -134,6 +139,7 @@ export default function () { id: domainId, type: "ENSv1Domain", registryId: parentRegistryId, + node, labelHash, rootRegistryOwnerId, }) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 1a99a3779..edeb38da5 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -234,6 +234,9 @@ export const domain = onchainTable( // INVARIANT: non-null iff `type === "ENSv2Domain"`. tokenId: t.bigint(), + // INVARIANT: non-null iff `type === "ENSv1Domain"`. The domain's namehash. + node: t.hex().$type(), + // represents a labelHash labelHash: t.hex().notNull().$type(), 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/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 65594a805..76d6874a1 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": [], @@ -2225,6 +2537,141 @@ const introspection = { ], "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": "permissions", + "type": { + "kind": "OBJECT", + "name": "Permissions" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Registry" + } + ] + }, { "kind": "OBJECT", "name": "ENSv2RegistryRegistration", @@ -3776,7 +4223,7 @@ const introspection = { { "name": "registry", "type": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" }, "args": [ @@ -3854,7 +4301,7 @@ const introspection = { { "name": "root", "type": { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry" }, "args": [], @@ -4599,7 +5046,7 @@ const introspection = { "interfaces": [] }, { - "kind": "OBJECT", + "kind": "INTERFACE", "name": "Registry", "fields": [ { @@ -4726,7 +5173,21 @@ const introspection = { "isDeprecated": false } ], - "interfaces": [] + "interfaces": [], + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "ENSv1Registry" + }, + { + "kind": "OBJECT", + "name": "ENSv1VirtualRegistry" + }, + { + "kind": "OBJECT", + "name": "ENSv2Registry" + } + ] }, { "kind": "OBJECT", @@ -4917,8 +5378,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "ENSv2Domain" + "kind": "INTERFACE", + "name": "Domain" } }, "args": [], @@ -4948,7 +5409,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..75a5fe454 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -219,6 +219,11 @@ interface Domain { """The owner of this Domain.""" owner: Account + """ + The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + """ + parent: Domain + """ The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical. """ @@ -332,11 +337,16 @@ 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. @@ -365,6 +375,51 @@ 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""" + 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""" + 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.""" @@ -384,6 +439,11 @@ type ENSv2Domain implements Domain { """The owner of this Domain.""" owner: Account + """ + The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + """ + parent: Domain + """ The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical. """ @@ -431,6 +491,24 @@ type ENSv2DomainPermissionsConnectionEdge { node: PermissionsUser! } +"""An ENSv2Registry represents an ENSv2 Registry contract.""" +type ENSv2Registry implements Registry { + """Contract metadata for this Registry""" + 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. """ @@ -923,8 +1001,10 @@ type RegistrationRenewalsConnectionEdge { node: Renewal! } -"""A Registry represents an ENSv2 Registry contract.""" -type 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""" contract: AccountId! @@ -977,7 +1057,7 @@ type RegistryParentsConnection { type RegistryParentsConnectionEdge { cursor: String! - node: ENSv2Domain! + node: Domain! } type RegistryPermissionsUser { From 707f33f6b44e0ee6c9002ba9f3935e3a9ec77477 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 19:22:29 -0500 Subject: [PATCH 06/19] chore: add breaking changeset for unified polymorphic domain/registry Closes #205, #1511, #1877. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/unified-domain-model.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/unified-domain-model.md diff --git a/.changeset/unified-domain-model.md b/.changeset/unified-domain-model.md new file mode 100644 index 000000000..e02f409b4 --- /dev/null +++ b/.changeset/unified-domain-model.md @@ -0,0 +1,28 @@ +--- +"enssdk": major +"@ensnode/ensdb-sdk": major +"@ensnode/ensnode-sdk": major +"ensindexer": major +"ensapi": major +--- + +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. + +### Migration + +- Full reindex required. No in-place data migration. +- Closes #205, #1511, #1877. From e57f87ce3ee46e0134c2928ab3d4527c4ee708c9 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 22 Apr 2026 11:15:32 -0500 Subject: [PATCH 07/19] fix: openapi-schema --- docs/ensnode.io/ensapi-openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index e4496ea1a..2e27b8e27 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ENSApi APIs", - "version": "1.9.0", + "version": "1.10.0", "description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode" }, "servers": [ From 5534db09510b3b8843e5bc51eda06bce0efadc52 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 22 Apr 2026 12:41:28 -0500 Subject: [PATCH 08/19] fix: address pr notes --- apps/ensapi/src/omnigraph-api/context.ts | 2 +- .../lib/find-domains/layers/base-domain-set.ts | 10 +++++++--- .../lib/find-domains/layers/filter-by-name.ts | 16 +++++++++++----- .../src/omnigraph-api/lib/get-canonical-path.ts | 5 +++-- apps/ensapi/src/omnigraph-api/schema/domain.ts | 11 +++++++---- .../src/omnigraph-api/schema/registration.ts | 11 +++++++---- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index 612f7bdf3..18e37a891 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -11,7 +11,7 @@ const errorAsValue = (error: unknown) => error instanceof Error ? error : new Error(String(error)); const createCanonicalPathLoader = () => - new DataLoader(async (domainIds) => + new DataLoader(async (domainIds) => Promise.all(domainIds.map((id) => getCanonicalPath(id).catch(errorAsValue))), ); 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 6d7f9a9af..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,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; @@ -35,14 +35,18 @@ export function domainsBase() { ), }) .from(ensIndexerSchema.domain) - // parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain + // parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain. + // The `parentDomain.subregistryId = domain.registryId` clause performs edge authentication. .leftJoin( ensIndexerSchema.registryCanonicalDomain, eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), ) .leftJoin( parentDomain, - eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), + and( + eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), + eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId), + ), ) // join label for labelHash/sortableLabel .leftJoin( diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts index fd3a619c2..2614f3903 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 @@ -64,29 +64,35 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { .from( sql`( WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) - -- and get their parent via registryCanonicalDomain + -- 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, - rcd.domain_id AS current_id, + parent.id AS current_id, 1 AS depth FROM ${ensIndexerSchema.domain} d JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd ON rcd.registry_id = d.registry_id + JOIN ${ensIndexerSchema.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, verifying each ancestor's labelHash + -- 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.domain} pd ON pd.id = upward_check.current_id JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd ON rcd.registry_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] ) 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 8ca716a06..fa11f682c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -41,7 +41,8 @@ export async function getCanonicalPath(domainId: DomainId): Promise 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. + -- 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, @@ -50,7 +51,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise & export type ENSv2Domain = RequiredAndNotNull & RequiredAndNull & { type: "ENSv2Domain" }; -const isENSv1Domain = (domain: unknown): domain is ENSv1Domain => +export const isENSv1Domain = (domain: unknown): domain is ENSv1Domain => (domain as DomainInterface).type === "ENSv1Domain"; -const isENSv2Domain = (domain: unknown): domain is ENSv2Domain => +export const isENSv2Domain = (domain: unknown): domain is ENSv2Domain => (domain as DomainInterface).type === "ENSv2Domain"; export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); @@ -114,7 +114,8 @@ DomainInterfaceRef.implement({ nullable: true, resolve: async (domain, _args, context) => { const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (!canonicalPath) return null; + 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( @@ -147,7 +148,8 @@ DomainInterfaceRef.implement({ nullable: true, resolve: async (domain, _args, context) => { const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (!canonicalPath) return null; + if (canonicalPath instanceof Error) throw canonicalPath; + if (canonicalPath === null) return null; return await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), @@ -165,6 +167,7 @@ DomainInterfaceRef.implement({ 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; }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/registration.ts b/apps/ensapi/src/omnigraph-api/schema/registration.ts index 00267d7fa..83addb6c9 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registration.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registration.ts @@ -21,7 +21,7 @@ import { PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; -import { DomainInterfaceRef, type ENSv1Domain } 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"; @@ -345,9 +345,12 @@ WrappedBaseRegistrarRegistrationRef.implement({ nullable: false, // 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, - )) as ENSv1Domain; + const domain = await DomainInterfaceRef.getDataloader(ctx).load(parent.domainId); + if (!isENSv1Domain(domain)) { + throw new Error( + `Invariant(WrappedBaseRegistrarRegistration.tokenId): expected ENSv1Domain for domainId '${parent.domainId}', got ${domain.type}.`, + ); + } return hexToBigInt(domain.node); }, }), From d29a5fbbc604bbcaea6d28ec70dcb9d1c086145e Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:14:50 -0500 Subject: [PATCH 09/19] fix: inline ownerId materialization to save a db op --- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 05cb248d4..8a5bcd007 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -16,7 +16,7 @@ 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"; @@ -130,9 +130,10 @@ export default function () { await ensureUnknownLabel(context, labelHash); } - const rootRegistryOwnerId = interpretAddress(owner); + const ownerId = interpretAddress(owner); + await ensureAccount(context, owner); - // upsert domain, always updating rootRegistryOwner + // upsert domain, always updating ownerId and setting rootRegistryOwner to this explicit owner await context.ensDb .insert(ensIndexerSchema.domain) .values({ @@ -141,17 +142,18 @@ export default function () { registryId: parentRegistryId, node, labelHash, - rootRegistryOwnerId, + // NOTE: the includsion 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 NameWraper-emitted value. + ownerId, + rootRegistryOwnerId: ownerId, }) - .onConflictDoUpdate({ rootRegistryOwnerId }); - - // 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); + .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); // push event to domain history await ensureDomainEvent(context, event, domainId); @@ -172,18 +174,18 @@ export default function () { const { registry } = getManagedName(getThisAccountId(context, event)); const domainId = makeENSv1DomainId(registry, node); - // set the domain's rootRegistryOwner to `owner` - await context.ensDb - .update(ensIndexerSchema.domain, { 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); + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ ownerId, rootRegistryOwnerId: ownerId }); // push event to domain history await ensureDomainEvent(context, event, domainId); From c38284f872bb52b73a51beb99400f3bbe1ff8909 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:49:59 -0500 Subject: [PATCH 10/19] feat(ensapi): Query.root non-null, prefer v2; add ENSv1 coverage tests - `Query.root` returns `v2Root ?? v1Root` (non-null), matching ENS Forward Resolution preference. - New integration coverage: `Query.registry` polymorphism for ENSv1Registry, `ENSv1Domain.node`, `domains(canonical: true)` filter, `Domain.path` for a deep name, and aliased-path divergence proving reverse-walk edge-auth rejects stale `registryCanonicalDomain` edges. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/query-root-nonnull.md | 5 ++ .../schema/domain.integration.test.ts | 65 ++++++++++++++++++- .../schema/query.integration.test.ts | 53 ++++++++++++++- apps/ensapi/src/omnigraph-api/schema/query.ts | 11 ++-- .../src/omnigraph/generated/schema.graphql | 6 +- 5 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 .changeset/query-root-nonnull.md diff --git a/.changeset/query-root-nonnull.md b/.changeset/query-root-nonnull.md new file mode 100644 index 000000000..4982c84cb --- /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. Matches ENS Forward Resolution preference. 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/query.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts index f127ae6a6..26241269e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -5,9 +5,11 @@ import { type InterpretedLabel, labelhashInterpretedLabel, makeENSv1DomainId, + makeENSv1RegistryId, makeENSv2DomainId, makeStorageId, type Name, + type Node, type NormalizedAddress, namehashInterpretedName, } from "enssdk"; @@ -48,15 +50,38 @@ const V2_ETH_STORAGE_ID = makeStorageId(labelhashInterpretedLabel(asInterpretedL 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<{ @@ -65,6 +90,7 @@ describe("Query.domains", () => { name: Name; label: { interpreted: InterpretedLabel }; owner: { address: NormalizedAddress }; + node?: Node; }>; }; @@ -82,6 +108,9 @@ describe("Query.domains", () => { owner { address } + ... on ENSv1Domain { + node + } } } } @@ -109,6 +138,8 @@ describe("Query.domains", () => { id: V1_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, + // ENSv1Domain exposes `node` — the namehash of the canonical name + node: namehashInterpretedName(asInterpretedName("eth")), }); expect(v2EthDomain).toMatchObject({ @@ -117,6 +148,24 @@ 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(); + } + }); }); describe("Query.domain", () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 59c237fe6..a0a764ce4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -4,7 +4,7 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth import { and, eq, inArray } from "drizzle-orm"; import { makePermissionsId, makeResolverId } from "enssdk"; -import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; @@ -247,11 +247,12 @@ builder.queryType({ // Get Root Registry ///////////////////// root: t.field({ - description: "The ENSv2 Root Registry, if exists.", + description: + "The Root Registry for this namespace. Prefers the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry. Matches ENS Forward Resolution preference.", type: RegistryInterfaceRef, - // TODO: make this nullable: false after all namespaces define ENSv2Root - nullable: true, - resolve: () => maybeGetENSv2RootRegistryId(config.namespace), + nullable: false, + resolve: () => + maybeGetENSv2RootRegistryId(config.namespace) ?? getENSv1RootRegistryId(config.namespace), }), }), }); diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 75a5fe454..38d1e5891 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -880,8 +880,10 @@ 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. Prefers the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry. Matches ENS Forward Resolution preference. + """ + root: Registry! """TODO""" v1Domains(after: String, before: String, first: Int, last: Int): QueryV1DomainsConnection From a2434ec16e05abd9eeb0ce965a94474b033704be Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:09:45 -0500 Subject: [PATCH 11/19] fix: regenerate gql schema --- packages/enssdk/src/omnigraph/generated/introspection.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 76d6874a1..754d3d103 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -4301,8 +4301,11 @@ const introspection = { { "name": "root", "type": { - "kind": "INTERFACE", - "name": "Registry" + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Registry" + } }, "args": [], "isDeprecated": false From e13da697e2ba7eb9c7793ca3cf10468ce4f00592 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:11:16 -0500 Subject: [PATCH 12/19] style: fix typos + clarify Domain.path description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `NameWraper-emitted` → `NameWrapper-emitted` (2×) - `includsion` → `inclusion` - `Domain.path` description now accurately reflects leaf→root order (inclusive) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensapi/src/omnigraph-api/schema/domain.ts | 2 +- .../src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 4ab86299e..aa26cdfa9 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -142,7 +142,7 @@ 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, diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 8a5bcd007..b444cad2f 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -142,14 +142,14 @@ export default function () { registryId: parentRegistryId, node, labelHash, - // NOTE: the includsion of ownerId here 'inlines' the logic of `materializeENSv1DomainEffectiveOwner`, + // 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 NameWraper-emitted value. + // indexing code re-materializes the Domain.ownerId to the NameWrapper-emitted value. ownerId, rootRegistryOwnerId: ownerId, }) @@ -182,7 +182,7 @@ export default function () { // 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. + // indexing code re-materializes the Domain.ownerId to the NameWrapper-emitted value. await context.ensDb .update(ensIndexerSchema.domain, { id: domainId }) .set({ ownerId, rootRegistryOwnerId: ownerId }); From ef9f65aa4a7d001d09cb71b22ef35d015fa09360 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:12:59 -0500 Subject: [PATCH 13/19] fix: schema and agents.md --- AGENTS.md | 2 ++ packages/enssdk/src/omnigraph/generated/schema.graphql | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 674d9db83..c93dd9160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,3 +81,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/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 38d1e5891..428a8938d 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -225,7 +225,7 @@ interface Domain { 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!] @@ -349,7 +349,7 @@ type ENSv1Domain implements Domain { 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!] @@ -445,7 +445,7 @@ type ENSv2Domain implements Domain { 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!] From 9a3f33c0d4ff869d469387209768ddb01b204a2b Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:31:51 -0500 Subject: [PATCH 14/19] refactor(ensnode-sdk): getRootRegistryId helper; clarify canonical-registries semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `getRootRegistryId(namespace)` in ensnode-sdk returning `v2Root ?? v1Root`, the primary Root Registry for display / forward-resolution preference. - `Query.root` uses the helper. - `getCanonicalRegistriesCTE` docstring now explicitly frames canonical = "resolvable under the primary pipeline", so ENSv1 subtrees remain in the canonical set (Universal Resolver v2 falls back to ENSv1 at resolution time for unmigrated names). - Downgrade unified-domain-model changeset from major → minor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/query-root-nonnull.md | 2 +- .changeset/unified-domain-model.md | 14 +++++--------- .../find-domains/canonical-registries-cte.ts | 18 +++++++++++------- .../lib/find-domains/layers/filter-by-name.ts | 7 +++---- apps/ensapi/src/omnigraph-api/schema/query.ts | 7 +++---- .../ensnode-sdk/src/shared/root-registry.ts | 16 ++++++++++++++++ 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.changeset/query-root-nonnull.md b/.changeset/query-root-nonnull.md index 4982c84cb..8e9c97101 100644 --- a/.changeset/query-root-nonnull.md +++ b/.changeset/query-root-nonnull.md @@ -2,4 +2,4 @@ "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. Matches ENS Forward Resolution preference. +`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 index e02f409b4..c4052ab80 100644 --- a/.changeset/unified-domain-model.md +++ b/.changeset/unified-domain-model.md @@ -1,9 +1,9 @@ --- -"enssdk": major -"@ensnode/ensdb-sdk": major -"@ensnode/ensnode-sdk": major -"ensindexer": major -"ensapi": major +"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. @@ -22,7 +22,3 @@ Unify `v1Domain` + `v2Domain` into a single polymorphic `domain` table discrimin - `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. -### Migration - -- Full reindex required. No in-place data migration. -- Closes #205, #1511, #1877. 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 9b6d3e1e0..85bbeba2b 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 @@ -11,9 +11,9 @@ import { lazy } from "@/lib/lazy"; * The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries. * * Note that the set of Canonical Registries is a _tree_ by construction: each Registry is reached - * via either `registryCanonicalDomain` (ENSv1 virtual / ENSv2) or the concrete ENSv1 root. For - * ENSv2, edge authentication (parent's `subregistryId` matches the child's `registryId`) prevents - * cycles in the declared namegraph; for ENSv1, each domain lives under exactly one Registry. + * via either `registryCanonicalDomain` (ENSv1 virtual / ENSv2) or the concrete ENSv1 root. + * Edge authentication (parent's `subregistryId` matches the child's `registryId`) prevents + * cycles in the declared namegraph. * * 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 @@ -28,10 +28,14 @@ const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); * Builds a recursive CTE that traverses forward from the ENSv1 root Registry and (when defined) * the ENSv2 root Registry to construct a set of all Canonical Registries. * - * A Canonical Registry is either root, or a Registry declared as a Subregistry by a Domain living - * in another Canonical Registry. 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. + * A Canonical Registry is one whose Domains are resolvable under the primary resolution pipeline. + * This includes both the ENSv2 subtree and the 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? */ 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 2614f3903..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 @@ -28,9 +28,7 @@ const FILTER_BY_NAME_MAX_DEPTH = 8; * - leafId: the deepest child (label "sub1") — the autocomplete result, for ownership check * - headId: the parent of the path (whose label should match partial "paren") * - * Algorithm: Start from the deepest child (leaf) and traverse UP via - * {@link registryCanonicalDomain}. In the unified model, both ENSv1 and ENSv2 parent links flow - * through `domain.registryId → registryCanonicalDomain.domainId`. + * Algorithm: Start from the deepest child (leaf) and traverse UP via {@link registryCanonicalDomain}. */ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { // If no concrete path, return all domains (leaf = head = self) @@ -53,7 +51,8 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { // 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 + // + // NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete // canonical path to the searched FQDN. return ensDb .select({ diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index a0a764ce4..239c20474 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -4,7 +4,7 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth import { and, eq, inArray } from "drizzle-orm"; import { makePermissionsId, makeResolverId } from "enssdk"; -import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getRootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; @@ -248,11 +248,10 @@ builder.queryType({ ///////////////////// root: t.field({ description: - "The Root Registry for this namespace. Prefers the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry. Matches ENS Forward Resolution preference.", + "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: () => - maybeGetENSv2RootRegistryId(config.namespace) ?? getENSv1RootRegistryId(config.namespace), + resolve: () => getRootRegistryId(config.namespace), }), }), }); diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index 3feac5fbe..d1e513cd1 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -94,3 +94,19 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { if (!root) return undefined; 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); From 5c056774d86908b3aca7b1e870256c15f943b138 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:37:58 -0500 Subject: [PATCH 15/19] perf(ensapi): short-circuit null subregistry_id in canonical-registries recursive step Moves the `registry_id IS NOT NULL` filter from the outer SELECT into the recursive step. Terminal Domains (no subregistry declared) no longer emit null rows into the CTE and no longer spawn dead-end JOIN attempts in the next recursive iteration. Same result set, less wasted work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/find-domains/canonical-registries-cte.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 85bbeba2b..761f654a9 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 @@ -63,12 +63,15 @@ export const getCanonicalRegistriesCTE = () => { WITH RECURSIVE canonical_registries AS ( ${rootsUnion} UNION ALL + -- 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 WHERE registry_id IS NOT NULL + SELECT registry_id FROM canonical_registries ) AS canonical_registries_cte`, ) .as("canonical_registries"); From 3ae09938e29ba663450ecbc02b7f52a98592d15b Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 14:00:59 -0500 Subject: [PATCH 16/19] refactor(ensapi,enssdk,ensdb-sdk): consolidate dev query, add makeConcreteRegistryId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `makeConcreteRegistryId(accountId)` helper in enssdk returning `ENSv1RegistryId | ENSv2RegistryId` (never virtual — virtual ids carry a `/node` suffix that AccountId alone cannot produce). - `Query.registry(by: { contract })` uses `makeConcreteRegistryId` directly; the downstream dataloader handles existence. Virtual registries remain intentionally unaddressable by AccountId. - `Query.v1Domains` + `Query.v2Domains` dev fields consolidated into a single polymorphic `Query.allDomains` dev field. - enskit `Query.registry` cache resolver uses `makeConcreteRegistryId` for tighter typing. - query integration test: ETH_NODE constant replaces namehashInterpretedName calls. - ENSv2 schema: refactored column-invariant docs into uniform "If this is X, ..., otherwise null" form; added `TokenId` brand on `domain.tokenId`; expanded descriptions to mention ThreeDNS Registries. AGENTS.md gains testing guidance. - Delete SPEC-domain-model.md (spec subsumed by implementation + changeset). Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 5 + SPEC-domain-model.md | 482 ------------------ .../schema/query.integration.test.ts | 10 +- apps/ensapi/src/omnigraph-api/schema/query.ts | 93 +--- .../src/omnigraph-api/schema/registry.ts | 5 +- .../src/ensindexer-abstract/ensv2.schema.ts | 46 +- .../omnigraph/_lib/by-id-lookup-resolvers.ts | 4 +- packages/enssdk/src/lib/ids.ts | 9 + .../src/omnigraph/generated/introspection.ts | 226 ++------ .../src/omnigraph/generated/schema.graphql | 60 +-- 10 files changed, 147 insertions(+), 793 deletions(-) delete mode 100644 SPEC-domain-model.md diff --git a/AGENTS.md b/AGENTS.md index c93dd9160..7de76af57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,3 +83,8 @@ Fail fast and loudly on invalid inputs. 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` + +## Testing + +- Prefer the `await expect(...).resolves.*` format over await-then-expect. +- Prefer `await expect(...).resolves.toMatchObject({})` over expecting individual properties, if it is more concise. diff --git a/SPEC-domain-model.md b/SPEC-domain-model.md deleted file mode 100644 index 714950a24..000000000 --- a/SPEC-domain-model.md +++ /dev/null @@ -1,482 +0,0 @@ -# SPEC: Unified polymorphic `domain` + `registry` - -Tracking: closes [#205](https://github.com/namehash/ensnode/issues/205), [#1511](https://github.com/namehash/ensnode/issues/1511), [#1877](https://github.com/namehash/ensnode/issues/1877). - -## Goal - -Merge `v1Domain` + `v2Domain` into a single polymorphic `domain` table. Make Registry polymorphic (ENSv1 / ENSv1Virtual / ENSv2) with a GraphQL interface mirroring Domain/Registration. ENSv1 `DomainId` becomes CAIP-shaped: `${ENSv1RegistryId}/${node}`. - -After this refactor, `find-domains`, protocol acceleration logic, and `get-domain-by-interpreted-name` operate over single unified tables. - -## Conceptual model - -- **Concrete ENSv1Registry** — an actual ENSv1 Registry contract (main ENS Registry on mainnet, Basenames Registry on Base, Lineanames Registry on Linea — the latter two are "shadow" ENSv1 registries). -- **ENSv1VirtualRegistry** — a virtual registry managed by each ENSv1 domain that has children. Lazily upserted when a subname is created. Keyed by `(chainId, address, node)` where `(chainId, address)` is the concrete Registry that housed the parent domain, and `node` is the parent's namehash. -- **ENSv2Registry** — existing ENSv2 Registry contracts (unchanged behavior). - -Every ENSv1 domain has `registryId` pointing at either: -- the concrete Registry, if the domain's parent is the Registry's Managed Name (e.g. `foo.eth` → concrete eth Registry); or -- the parent's virtual registry, otherwise (e.g. `sub.foo.eth` → virtual registry for `foo.eth`). - -The virtual registry for a parent domain is upserted only when the parent's first child is indexed. Upon upsert, `registryCanonicalDomain(virtualRegistryId → parentDomainId)` is also upserted (self-link) so reverse traversal works uniformly with ENSv2. - -The two nametrees (ENSv1 and ENSv2) are disjoint by design. Cross-chain bridging (mainnet ↔ Basenames/Lineanames) is handled by protocol-acceleration's bridged resolver, not by wiring `subregistryId` across chains. - -## Resolved decisions - -- `registryType` and `domainType` are `onchainEnum`s in the schema; TypeScript types are inferred (follow the `registrationType` pattern). -- `registry` table adds `type`. Replaces `uniqueIndex(chainId, address)` with a plain `index(chainId, address)`. -- `registry` table adds `node` (nullable, no index). Invariant: `node IS NOT NULL` iff `type === "ENSv1VirtualRegistry"`. Exposed on `ENSv1VirtualRegistryRef` only. -- ID shapes: - - `ENSv1RegistryId` = branded CAIP-10 string - - `ENSv2RegistryId` = branded CAIP-10 string - - `ENSv1VirtualRegistryId = ${ENSv1RegistryId}/${node}` - - `ENSv1DomainId = ${ENSv1RegistryId}/${node}` (same shape as virtual; distinct tables) -- ENSv1 `handleNewOwner` parent-registry selection: - ```ts - const { node: managedNode } = getManagedName(registry); - const parentRegistryId = - parentNode === managedNode - ? registryId - : makeENSv1VirtualRegistryId(registry, parentNode); - ``` - If `parentRegistryId !== registryId`, upsert the virtual registry row and upsert `registryCanonicalDomain(parentVirtualRegistryId → parentDomainId)`. -- Handler variable pattern: `registry = getThisAccountId(context, event)`, `registryId = makeENSv1RegistryId(registry)`, and `virtualRegistryId` when needed. -- Concrete `ENSv1Registry` row upserted with `onConflictDoNothing` on first event. -- `ENSv2Domain.tokenId` column is nullable in the schema; derived `ENSv2Domain` TS type asserts non-null via `RequiredAndNotNull`. Invariant assumed, no runtime check. Same pattern for `ENSv1VirtualRegistry.node`. -- `get-domain-by-interpreted-name.ts` keeps `Promise.all([v1, v2])` parallel fetching. Each traversal queries the unified `domain` table but is rooted at its respective ENSv1 or ENSv2 root registry. -- `parent` field moves from `ENSv1Domain` to the `Domain` interface; resolved via the canonical-path dataloader (`results[1]`). -- `Registry` becomes a GraphQL interface with `ENSv1Registry`, `ENSv1VirtualRegistry`, and `ENSv2Registry` implementations, following the `RegistrationInterfaceRef` pattern. -- `RegistryIdInput` AccountId path filters `type IN ('ENSv1Registry', 'ENSv2Registry')` (excludes virtual). -- `filter-by-parent.ts` needs no changes — works automatically once `domainsBase` is unified. -- Full reindex (no migrations). Single PR, multiple green commits. Single cross-package breaking changeset. -- Add `getENSv1RootRegistryId(namespace)` helper in `packages/ensnode-sdk/src/shared/root-registry.ts`. - ---- - -## Commit 1 — types + ID makers (`packages/enssdk`) - -**`src/lib/types/ensv2.ts`** - -```ts -export type ENSv1RegistryId = AccountIdString & { __brand: "ENSv1RegistryId" }; -export type ENSv2RegistryId = AccountIdString & { __brand: "ENSv2RegistryId" }; -export type ENSv1VirtualRegistryId = string & { __brand: "ENSv1VirtualRegistryId" }; -// shape: ${ENSv1RegistryId}/${node} - -export type RegistryId = ENSv1RegistryId | ENSv1VirtualRegistryId | ENSv2RegistryId; - -export type ENSv1DomainId = string & { __brand: "ENSv1DomainId" }; -// shape: ${ENSv1RegistryId}/${node} (no longer Node-derived) - -export type DomainId = ENSv1DomainId | ENSv2DomainId; // unchanged union -``` - -**`src/lib/ids.ts`** - -```ts -export const makeENSv1RegistryId = (acc: AccountId) => - stringifyAccountId(acc) as ENSv1RegistryId; -export const makeENSv2RegistryId = (acc: AccountId) => - stringifyAccountId(acc) as ENSv2RegistryId; - -export const makeENSv1VirtualRegistryId = (acc: AccountId, node: Node) => - `${makeENSv1RegistryId(acc)}/${node}` as ENSv1VirtualRegistryId; - -export const makeENSv1DomainId = (acc: AccountId, node: Node) => - `${makeENSv1RegistryId(acc)}/${node}` as ENSv1DomainId; // BREAKING: now takes acc + node - -// makeENSv2DomainId unchanged -// keep makeRegistryId if still used by union callsites -``` - -**Validate:** `pnpm -F enssdk typecheck`. - ---- - -## Commit 2 — root helper + unified schema - -**`packages/ensnode-sdk/src/shared/root-registry.ts`** - -Add `getENSv1RootRegistryId(namespace)` and `maybeGetENSv1RootRegistryId(namespace)` mirroring the v2 helpers. Resolves the concrete ENSv1 root Registry (ENSRoot datasource) per namespace. - -**`packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts`** - -Add enums: - -```ts -export const registryType = onchainEnum("RegistryType", [ - "ENSv1Registry", - "ENSv1VirtualRegistry", - "ENSv2Registry", -]); - -export const domainType = onchainEnum("DomainType", [ - "ENSv1Domain", - "ENSv2Domain", -]); -``` - -`registry` table: - -```ts -export const registry = onchainTable( - "registries", - (t) => ({ - id: t.text().primaryKey().$type(), - type: registryType().notNull(), - chainId: t.integer().notNull().$type(), - address: t.hex().notNull().$type
(), - // non-null iff type === "ENSv1VirtualRegistry" - node: t.hex().$type(), - }), - (t) => ({ - byChainAddress: index().on(t.chainId, t.address), // plain, not unique - }), -); -``` - -Drop `v1Domain` + `v2Domain`. Single `domain` table: - -```ts -export const domain = onchainTable( - "domains", - (t) => ({ - id: t.text().primaryKey().$type(), - type: domainType().notNull(), - registryId: t.text().notNull().$type(), - subregistryId: t.text().$type(), // nullable - // non-null iff type === "ENSv2Domain" - tokenId: t.bigint(), - labelHash: t.hex().notNull().$type(), - ownerId: t.hex().$type
(), - // v1-only - rootRegistryOwnerId: t.hex().$type
(), - // parentId removed; canonical path via registryCanonicalDomain - }), - (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), - byLabelHash: index().on(t.labelHash), - }), -); -``` - -Relations: - -- `domain.registry` → `registry` (via `registryId`) -- `domain.subregistry` → `registry` (via `subregistryId`) -- `domain.owner` → `account` -- `domain.rootRegistryOwner` → `account` -- `domain.label` → `label` -- `domain.registrations` → `registration` (many) -- `registry` → `many(domain)` in both registry and subregistry roles - -`registrationType`, `registration`, and `registryCanonicalDomain` unchanged. `registration.domainId.$type()` already a union — confirm. - -**Validate:** `pnpm -F @ensnode/ensdb-sdk typecheck`, `pnpm -F @ensnode/ensnode-sdk typecheck`. - ---- - -## Commit 3 — indexer handlers (`apps/ensindexer`) - -Pattern for all v1 handlers: - -```ts -const registry = getThisAccountId(context, event); -const registryId = makeENSv1RegistryId(registry); - -await context.ensDb - .insert(ensIndexerSchema.registry) - .values({ - id: registryId, - type: "ENSv1Registry", - ...registry, - node: null, - }) - .onConflictDoNothing(); -``` - -### `src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts` - -**`handleNewOwner`:** - -```ts -const node = makeSubdomainNode(labelHash, parentNode); -const domainId = makeENSv1DomainId(registry, node); -const parentDomainId = makeENSv1DomainId(registry, parentNode); - -const { node: managedNode } = getManagedName(registry); -const parentRegistryId = - parentNode === managedNode - ? registryId - : makeENSv1VirtualRegistryId(registry, parentNode); - -if (parentRegistryId !== registryId) { - // upsert parent's virtual registry - await context.ensDb - .insert(ensIndexerSchema.registry) - .values({ - id: parentRegistryId, - type: "ENSv1VirtualRegistry", - chainId: registry.chainId, - address: registry.address, - node: parentNode, - }) - .onConflictDoNothing(); - - // self-link canonical domain for reverse traversal - await context.ensDb - .insert(ensIndexerSchema.registryCanonicalDomain) - .values({ registryId: parentRegistryId, domainId: parentDomainId }) - .onConflictDoUpdate({ domainId: parentDomainId }); -} - -await context.ensDb - .insert(ensIndexerSchema.domain) - .values({ - id: domainId, - type: "ENSv1Domain", - registryId: parentRegistryId, - labelHash, - }) - .onConflictDoNothing(); - -// keep existing rootRegistryOwnerId update + materializeENSv1DomainEffectiveOwner + ensureDomainEvent -``` - -**`handleTransfer` / `handleNewTTL` / `handleNewResolver`:** -- `const registry = getThisAccountId(...)`; -- `const domainId = makeENSv1DomainId(registry, node);` - -### `src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts` - -- `registry` insert: `type: "ENSv2Registry"`, `node: null`. -- Writes to `ensIndexerSchema.domain` with `type: "ENSv2Domain"`, `tokenId` set. -- `SubregistryUpdated` canonicalDomain logic unchanged. - -### Other handlers (retarget `domain`, add `type`, update id signatures) - -- `src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts` -- `src/plugins/ensv2/handlers/ensv1/{BaseRegistrar,NameWrapper,RegistrarController}.ts` -- `src/plugins/subgraph/shared-handlers/NameWrapper.ts` -- `src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ENSv2Registry,ThreeDNSToken}.ts` -- `src/lib/ensv2/domain-db-helpers.ts` — `materializeENSv1DomainEffectiveOwner` updates `ensIndexerSchema.domain`. - -**Validate:** `pnpm -F @ensnode/ensindexer typecheck`, `pnpm lint`, ensindexer unit tests. - ---- - -## Commit 4 — omnigraph / API (`apps/ensapi/src/omnigraph-api`) - -### `context.ts` - -Drop `v1CanonicalPath` + `v2CanonicalPath`; add single `canonicalPath` loader. - -### `lib/get-canonical-path.ts` - -Replace with a single `getCanonicalPath(domainId)`. Recursive CTE over `domain` + `registryCanonicalDomain`: - -- Base: `domain.id = $domainId`. -- Step: `JOIN registryCanonicalDomain rcd ON rcd.registryId = cur.registryId JOIN domain parent ON parent.id = rcd.domainId`. -- Terminates when `registryId` equals the namespace's v1 root or v2 root. - -The virtual-registry self-link rows written in Commit 3 make v1 reverse traversal uniform with v2. - -### `lib/get-domain-by-interpreted-name.ts` - -Keep `Promise.all([v1, v2])` structure. Each branch does a forward recursive CTE over the unified `domain` table: - -- `v1_` rooted at `getENSv1RootRegistryId(namespace)` (concrete ENSv1Registry id). -- `v2_` rooted at `maybeGetENSv2RootRegistryId(namespace)`. - -CTE shape identical to the current v2 traversal; domain-to-next-domain hops go via `domain.registryId`, so ENSv1 traversal moves transparently through virtual registries. - -"Prefer v2" ordering preserved. The old direct `v1Domain.findFirst` path is removed in favor of the traversal. - -### `lib/find-domains/layers/` - -- `base-domain-set.ts` — single `domain` source (no union). -- `filter-by-registry.ts` — `eq(base.registryId, id)`; delete the v2-only comment. -- `filter-by-parent.ts` — **no changes needed**; works automatically. -- `filter-by-canonical.ts` — uses unified `canonicalRegistriesCte`. -- `filter-by-name.ts` — retarget `domain`. -- `canonical-registries-cte.ts` — unified forward traversal over `domain` + `registryCanonicalDomain`, rooted at v1 or v2 root. - -### `schema/domain.ts` (follow `schema/registration.ts` pattern) - -```ts -export type Domain = Exclude; -export type DomainInterface = Pick< - Domain, - | "id" | "type" | "registryId" | "subregistryId" | "labelHash" - | "ownerId" | "rootRegistryOwnerId" | "tokenId" ->; -export type ENSv1Domain = Domain & { type: "ENSv1Domain" }; -export type ENSv2Domain = RequiredAndNotNull & { type: "ENSv2Domain" }; -``` - -- `DomainInterfaceRef.load` → single `ensDb.query.domain.findMany({ where: inArray(id, ids), with: { label: true } })`. -- `isENSv1Domain = (d) => (d as DomainInterface).type === "ENSv1Domain"`. -- Move `parent` onto `DomainInterfaceRef`: - ```ts - parent: t.field({ - type: DomainInterfaceRef, - nullable: true, - resolve: async (d, _, ctx) => { - const path = await ctx.loaders.canonicalPath.load(d.id); - return path?.[1] ?? null; - }, - }); - ``` -- `ENSv1DomainRef`: keep `rootRegistryOwner`; `isTypeOf: (v) => (v as DomainInterface).type === "ENSv1Domain"`. -- `ENSv2DomainRef`: keep `tokenId` / `registry` / `subregistry` / `permissions`; `isTypeOf: (v) => (v as DomainInterface).type === "ENSv2Domain"`. - -### `schema/registry.ts` (new interface pattern) - -```ts -export const RegistryInterfaceRef = builder.loadableInterfaceRef("Registry", { - load: (ids: RegistryId[]) => - ensDb.query.registry.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), - toKey: getModelId, - cacheResolved: true, - sort: true, -}); - -export type Registry = Exclude; -export type RegistryInterface = Pick; -export type ENSv1Registry = Registry & { type: "ENSv1Registry" }; -export type ENSv1VirtualRegistry = RequiredAndNotNull & { - type: "ENSv1VirtualRegistry"; -}; -export type ENSv2Registry = Registry & { type: "ENSv2Registry" }; -``` - -- `RegistryInterfaceRef.implement` — shared fields: `id`, `type`, `contract` (AccountId), `parents`, `domains`, `permissions`. -- `parents` is defined on the interface and returns `DomainInterfaceRef`. For `ENSv1VirtualRegistry` the sole parent is the canonical v1 domain (self-linked via `registryCanonicalDomain`); for concrete `ENSv1Registry` the parents are the TLDs under it; for `ENSv2Registry` the parents are the v2 domains declaring it as subregistry. -- `ENSv1RegistryRef` / `ENSv2RegistryRef`: `isTypeOf` checks on `.type`; minimal (or no) extra fields. -- `ENSv1VirtualRegistryRef`: exposes `node: Node` non-null; `isTypeOf: (v) => v.type === "ENSv1VirtualRegistry"`. -- Replace callsite usage of `RegistryRef` with `RegistryInterfaceRef` across the API layer. -- `RegistryIdInput` AccountId path resolver: - ```ts - where: and( - eq(registry.chainId, chainId), - eq(registry.address, address), - inArray(registry.type, ["ENSv1Registry", "ENSv2Registry"]), - ); - ``` - -### `schema/registration.ts`, `schema/query.ts`, `schema/account.ts`, `schema/permissions.ts` - -- Retarget `v1Domain` / `v2Domain` → `domain`. -- Swap `RegistryRef` imports to `RegistryInterfaceRef`. -- Permissions join by `(chainId, address)` + `registry.id = parent.registryId` remains correct (id narrows even with non-unique chainId/address). - -### Generated files - -Regenerate `packages/enssdk/src/omnigraph/generated/{introspection.ts,schema.graphql}` from pothos. - -**Validate:** `pnpm -F ensapi typecheck`, `pnpm lint`, ensapi unit tests. - ---- - -## Commit 5 — tests + changeset - -**Tests to update:** - -- `packages/ensdb-sdk/src/lib/drizzle.test.ts` — new schema shape. -- `apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts` — v1 id format, `Domain.parent`, unified loader, Registry interface queries. -- `apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts` — id churn. -- Fixture builders that produce v1 ids from bare nodes. - -**Run:** `pnpm test:integration` from the monorepo root. - -**Changeset:** single breaking changeset in `.changeset/`; major bumps for: - -- `enssdk` -- `@ensnode/ensdb-sdk` -- `@ensnode/ensnode-sdk` -- `@ensnode/ensindexer` -- `@ensnode/ensapi` - -Body notes: schema + id format breaking; requires full reindex; introduces polymorphic Registry GraphQL interface; closes #205, #1877, #1511. - ---- - -## Callsite audit - -### Domain writes (retarget `domain`, add `type`, update ids) - -- `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/{ENSv2Registry,ETHRegistrar}.ts` -- `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/{ENSv1Registry,BaseRegistrar,NameWrapper,RegistrarController}.ts` -- `apps/ensindexer/src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ENSv2Registry,ThreeDNSToken}.ts` -- `apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts` -- `apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts` - -### Domain reads / API (retarget `domain`) - -- `apps/ensapi/src/omnigraph-api/schema/{domain,registry,registration,query,account,permissions}.ts` -- `apps/ensapi/src/omnigraph-api/context.ts`, `yoga.ts` -- `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` -- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts` -- `apps/ensapi/src/omnigraph-api/lib/find-domains/layers/{base-domain-set,filter-by-name,filter-by-registry,filter-by-canonical}.ts` (skip `filter-by-parent`) -- `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` - -### `ensIndexerSchema.registry` reads (all OK via id narrowing except `RegistryIdInput`) - -- `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts:347` — by id, OK -- `apps/ensindexer/src/lib/indexing-engines/ponder.ts` — re-export, OK -- `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` — by id, OK -- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts:128` — by id, OK -- `apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts` — id join, OK -- `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` — id join, OK -- `apps/ensapi/src/omnigraph-api/schema/domain.ts:387` — permissions join, id-narrowed, OK -- `apps/ensapi/src/omnigraph-api/schema/{account,registry}.ts` — by id/registryId, OK -- **`RegistryIdInput` AccountId resolver** — add `type IN ('ENSv1Registry', 'ENSv2Registry)` filter. - -### `makeENSv1DomainId` signature change (acc + node) - -- `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/{ENSv1Registry,BaseRegistrar,NameWrapper,RegistrarController}.ts` -- `apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts` -- `apps/ensindexer/src/plugins/protocol-acceleration/handlers/{ENSv1Registry,ThreeDNSToken}.ts` -- `apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts` -- `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts:96` - -### Generated (regenerate, don't hand-edit) - -- `packages/enssdk/src/omnigraph/generated/{introspection.ts,schema.graphql}` - ---- - -## Task dependency graph - -``` -#1 (types+ids) ─┐ - ├─► #3 (schema) ─► #4 (indexer) ─► #5 (omnigraph) ─► #6 (tests+changeset) -#2 (v1 root) ─┘ -``` - ---- - -## Key file references - -- Schema: `packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts` -- IDs: `packages/enssdk/src/lib/ids.ts`, `packages/enssdk/src/lib/types/ensv2.ts` -- ENSv1 handler: `apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts` -- ENSv2 handler: `apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts` -- Managed names: `apps/ensindexer/src/lib/managed-names.ts` -- Registration schema pattern (reference for polymorphism): `apps/ensapi/src/omnigraph-api/schema/registration.ts` -- Domain schema: `apps/ensapi/src/omnigraph-api/schema/domain.ts` -- Registry schema: `apps/ensapi/src/omnigraph-api/schema/registry.ts` -- Canonical path: `apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts` -- Interpreted-name lookup: `apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts` -- Canonical CTE: `apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts` -- Root registry helpers: `packages/ensnode-sdk/src/shared/root-registry.ts` - ---- - -## Branch - -`refactor/ensv1-domain-model` 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 26241269e..d7d8ed464 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -1,7 +1,7 @@ import { asInterpretedLabel, - asInterpretedName, type DomainId, + ETH_NODE, type InterpretedLabel, labelhashInterpretedLabel, makeENSv1DomainId, @@ -11,7 +11,6 @@ import { type Name, type Node, type NormalizedAddress, - namehashInterpretedName, } from "enssdk"; import { describe, expect, it } from "vitest"; @@ -42,10 +41,7 @@ const V2_ROOT_REGISTRY = getDatasourceContract( const V1_ROOT_REGISTRY = getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); -const V1_ETH_DOMAIN_ID = makeENSv1DomainId( - V1_ROOT_REGISTRY, - namehashInterpretedName(asInterpretedName("eth")), -); +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); @@ -139,7 +135,7 @@ describe("Query.domains", () => { name: "eth", label: { interpreted: "eth" }, // ENSv1Domain exposes `node` — the namehash of the canonical name - node: namehashInterpretedName(asInterpretedName("eth")), + node: ETH_NODE, }); expect(v2EthDomain).toMatchObject({ diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 239c20474..4e5df509f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -1,8 +1,7 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, eq, inArray } from "drizzle-orm"; -import { makePermissionsId, makeResolverId } from "enssdk"; +import { makeConcreteRegistryId, makePermissionsId, makeResolverId } from "enssdk"; import { getRootRegistryId } from "@ensnode/ensnode-sdk"; @@ -25,10 +24,6 @@ import { DomainInterfaceRef, DomainsOrderInput, DomainsWhereInput, - type ENSv1Domain, - ENSv1DomainRef, - type ENSv2Domain, - ENSv2DomainRef, } from "@/omnigraph-api/schema/domain"; import { PermissionsIdInput, PermissionsRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; @@ -41,60 +36,27 @@ const INCLUDE_DEV_METHODS = process.env.NODE_ENV !== "production"; builder.queryType({ fields: (t) => ({ ...(INCLUDE_DEV_METHODS && { - ///////////////////////////// - // Query.v1Domains (Testing) - ///////////////////////////// - v1Domains: t.connection({ - description: "TODO", - type: ENSv1DomainRef, - resolve: (parent, args) => { - const scope = eq(ensIndexerSchema.domain.type, "ENSv1Domain"); - return lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.domain, scope), - connection: () => - resolveCursorConnection( - { ...ID_PAGINATED_CONNECTION_ARGS, args }, - ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb.query.domain - .findMany({ - where: (t, { and }) => - and(scope, paginateBy(ensIndexerSchema.domain.id, before, after)), - orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), - limit, - with: { label: true }, - }) - .then((rows) => rows as ENSv1Domain[]), - ), - }); - }, - }), - - ///////////////////////////// - // Query.v2Domains (Testing) - ///////////////////////////// - v2Domains: t.connection({ + ////////////////////////////// + // Query.allDomains (Testing) + ////////////////////////////// + allDomains: t.connection({ description: "TODO", - type: ENSv2DomainRef, - resolve: (parent, args) => { - const scope = eq(ensIndexerSchema.domain.type, "ENSv2Domain"); - return lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.domain, scope), + type: DomainInterfaceRef, + resolve: (parent, args) => + lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.domain), connection: () => resolveCursorConnection( { ...ID_PAGINATED_CONNECTION_ARGS, args }, ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb.query.domain - .findMany({ - where: (t, { and }) => - and(scope, paginateBy(ensIndexerSchema.domain.id, before, after)), - orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), - limit, - with: { label: true }, - }) - .then((rows) => rows as ENSv2Domain[]), + ensDb.query.domain.findMany({ + where: () => paginateBy(ensIndexerSchema.domain.id, before, after), + orderBy: orderPaginationBy(ensIndexerSchema.domain.id, inverted), + limit, + with: { label: true }, + }), ), - }); - }, + }), }), ///////////////////////////// @@ -192,29 +154,12 @@ builder.queryType({ // Get Registry by Id or AccountId /////////////////////////////////// registry: t.field({ - description: "Identify a Registry by ID or AccountId.", + 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: async (parent, args) => { - if (args.by.id !== undefined) return args.by.id; - // Look up the concrete Registry row by (chainId, address). Virtual Registries are excluded - // because they share (chainId, address) with their concrete parent and should not be - // addressable via AccountId alone. - const { chainId, address } = args.by.contract; - const [row] = await ensDb - .select({ id: ensIndexerSchema.registry.id }) - .from(ensIndexerSchema.registry) - .where( - and( - eq(ensIndexerSchema.registry.chainId, chainId), - eq(ensIndexerSchema.registry.address, address), - inArray(ensIndexerSchema.registry.type, ["ENSv1Registry", "ENSv2Registry"]), - ), - ) - .limit(1); - return row?.id ?? null; - }, + resolve: (parent, args) => args.by.id ?? makeConcreteRegistryId(args.by.contract), }), /////////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index 757efabf2..33eef81d8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -80,7 +80,8 @@ RegistryInterfaceRef.implement({ // Registry.contract /////////////////// contract: t.field({ - description: "Contract metadata for this Registry", + 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 }), @@ -147,7 +148,7 @@ RegistryInterfaceRef.implement({ ////////////////////////////// ENSv1RegistryRef.implement({ description: - "An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry).", + "An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, the Lineanames shadow Registry, or a ThreeDNS Registry).", interfaces: [RegistryInterfaceRef], isTypeOf: (registry) => isENSv1Registry(registry), }); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index edeb38da5..ada660888 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -12,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"; @@ -34,19 +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 a single polymorphic `domain` table captures both ENSv1 and ENSv2 - * Domains, discriminated by a `type` column. Domain polymorphism is exposed at the API layer 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 is that `domain.ownerId` for ENSv1 Domains is the - * _materialized_ _effective_ owner. ENSv1 includes a mind-boggling number of ways to 'own' a domain, + * _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 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 @@ -61,9 +59,10 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * 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. + * the Basenames Registry, the Lineanames Registry, ThreeDNS Registries) 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: @@ -76,6 +75,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 @@ -182,18 +184,18 @@ export const registry = onchainTable( // see RegistryId for guarantees id: t.text().primaryKey().$type(), - // discriminates concrete ENSv1 / virtual ENSv1 / ENSv2 Registries + // has a type type: registryType().notNull(), chainId: t.integer().notNull().$type(), address: t.hex().notNull().$type
(), - // INVARIANT: non-null iff `type === "ENSv1VirtualRegistry"`. - // For a virtual registry, `node` is the namehash of the parent ENSv1 domain that owns it. + // If this is an ENSv1VirtualRegistry, `node` is the namehash of the parent ENSv1 domain that + // owns it, otherwise null. node: t.hex().$type(), }), (t) => ({ - // plain (non-unique) index — multiple rows can share (chainId, address) across virtual registries + // NOTE: non-unique index because multiple rows can share (chainId, address) across virtual registries byChainAddress: index().on(t.chainId, t.address), }), ); @@ -201,7 +203,7 @@ export const registry = onchainTable( export const relations_registry = relations(registry, ({ one, many }) => ({ // domains that declare this registry as their parent registry domains: many(domain, { relationName: "registry" }), - // domains that declare this registry as their subregistry (ENSv2 only) + // domains that declare this registry as their subregistry domainsAsSubregistry: many(domain, { relationName: "subregistry" }), permissions: one(permissions, { relationName: "permissions", @@ -222,28 +224,28 @@ export const domain = onchainTable( // see DomainId for guarantees (ENSv1DomainId: `${ENSv1RegistryId}/${node}`, ENSv2DomainId: CAIP-19) id: t.text().primaryKey().$type(), - // discriminates ENSv1 / ENSv2 Domains + // has a type type: domainType().notNull(), - // belongs to a registry (concrete or virtual for ENSv1; concrete for ENSv2) + // belongs to a registry registryId: t.text().notNull().$type(), - // may have a subregistry (ENSv2 only in practice; nullable for ENSv1 Domains) + // may have a subregistry subregistryId: t.text().$type(), - // INVARIANT: non-null iff `type === "ENSv2Domain"`. - tokenId: t.bigint(), + // If this is an ENSv2Domain, the TokenId within the ENSv2Registry, otherwise null. + tokenId: t.bigint().$type(), - // INVARIANT: non-null iff `type === "ENSv1Domain"`. The domain's namehash. + // If this is an ENSv1Domain, The Domain's namehash, otherwise null. node: t.hex().$type(), // represents a labelHash labelHash: t.hex().notNull().$type(), - // may have an owner (effective owner for ENSv1; materialized by the ENSv1 handlers) + // may have an owner ownerId: t.hex().$type
(), - // ENSv1 only: may have a `rootRegistryOwner` (ENSv1Registry's owner()), zeroAddress → null + // If this is an ENSv1Domain, may have a `rootRegistryOwner`, otherwise null. rootRegistryOwnerId: t.hex().$type
(), // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin 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..27f1e40db 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, @@ -44,7 +44,7 @@ export const byIdLookupResolvers: Record> = { 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) }; + if (by.contract) return { __typename: "Registry", id: makeConcreteRegistryId(by.contract) }; return passthrough(args, cache, info); }, diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index 4003602c3..0993f2979 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -44,6 +44,15 @@ export const makeENSv1VirtualRegistryId = (accountId: AccountId, node: Node) => */ 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 = (accountId: AccountId, node: Node) => diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 754d3d103..c0dfeb04e 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -4087,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": { @@ -4309,89 +4347,13 @@ const introspection = { }, "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", + "name": "QueryAllDomainsConnection", "fields": [ { "name": "edges", @@ -4403,7 +4365,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge" + "name": "QueryAllDomainsConnectionEdge" } } } @@ -4440,7 +4402,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge", + "name": "QueryAllDomainsConnectionEdge", "fields": [ { "name": "cursor", @@ -4471,7 +4433,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryRegistrationsConnection", + "name": "QueryDomainsConnection", "fields": [ { "name": "edges", @@ -4483,7 +4445,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryRegistrationsConnectionEdge" + "name": "QueryDomainsConnectionEdge" } } } @@ -4520,7 +4482,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryRegistrationsConnectionEdge", + "name": "QueryDomainsConnectionEdge", "fields": [ { "name": "cursor", @@ -4540,87 +4502,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "INTERFACE", - "name": "Registration" - } - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "OBJECT", - "name": "QueryResolversConnection", - "fields": [ - { - "name": "edges", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "QueryResolversConnectionEdge" - } - } - } - }, - "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": "QueryResolversConnectionEdge", - "fields": [ - { - "name": "cursor", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "node", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "Resolver" + "name": "Domain" } }, "args": [], @@ -4631,7 +4513,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV1DomainsConnection", + "name": "QueryRegistrationsConnection", "fields": [ { "name": "edges", @@ -4643,7 +4525,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryV1DomainsConnectionEdge" + "name": "QueryRegistrationsConnectionEdge" } } } @@ -4680,7 +4562,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV1DomainsConnectionEdge", + "name": "QueryRegistrationsConnectionEdge", "fields": [ { "name": "cursor", @@ -4699,8 +4581,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "ENSv1Domain" + "kind": "INTERFACE", + "name": "Registration" } }, "args": [], @@ -4711,7 +4593,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV2DomainsConnection", + "name": "QueryResolversConnection", "fields": [ { "name": "edges", @@ -4723,7 +4605,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "QueryV2DomainsConnectionEdge" + "name": "QueryResolversConnectionEdge" } } } @@ -4760,7 +4642,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryV2DomainsConnectionEdge", + "name": "QueryResolversConnectionEdge", "fields": [ { "name": "cursor", @@ -4780,7 +4662,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "ENSv2Domain" + "name": "Resolver" } }, "args": [], diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 428a8938d..ea92e7904 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -379,7 +379,9 @@ type ENSv1Domain implements Domain { An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry). """ type ENSv1Registry implements Registry { - """Contract metadata for this 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.""" @@ -399,7 +401,9 @@ type ENSv1Registry implements Registry { An ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash. """ type ENSv1VirtualRegistry implements Registry { - """Contract metadata for this 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.""" @@ -493,7 +497,9 @@ type ENSv2DomainPermissionsConnectionEdge { """An ENSv2Registry represents an ENSv2 Registry contract.""" type ENSv2Registry implements Registry { - """Contract metadata for this 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.""" @@ -859,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 @@ -871,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.""" @@ -881,15 +892,20 @@ type Query { resolvers(after: String, before: String, first: Int, last: Int): QueryResolversConnection """ - The Root Registry for this namespace. Prefers the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry. Matches ENS Forward Resolution preference. + 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 { @@ -925,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. """ @@ -1007,7 +1001,9 @@ type RegistrationRenewalsConnectionEdge { A Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry. """ interface Registry { - """Contract metadata for this 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.""" From 0f34ce16dc767cf3e5eba61cbf2186d6999487c2 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 14:04:33 -0500 Subject: [PATCH 17/19] =?UTF-8?q?chore(enssdk):=20regenerate=20GraphQL=20s?= =?UTF-8?q?chema=20=E2=80=94=20ENSv1Registry=20description=20includes=20Th?= =?UTF-8?q?reeDNS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/enssdk/src/omnigraph/generated/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index ea92e7904..1fcd974dd 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -376,7 +376,7 @@ type ENSv1Domain implements Domain { } """ -An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry). +An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, the Lineanames shadow Registry, or a ThreeDNS Registry). """ type ENSv1Registry implements Registry { """ From c37a9a4d6f056d0ef766f53c2f688e5f11561f53 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 14:32:09 -0500 Subject: [PATCH 18/19] fix(ensapi,ensnode-sdk,enskit): multi-root traversal + type-guard + graphcache fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `getRootRegistryIds(namespace)` helper returns every top-level Root Registry (all concrete ENSv1Registries + ENSv2 Root when defined). ENSv1 is multi-rooted on disk: Basenames/Lineanames shadow Registries root their own subtrees and are not linked to the mainnet subtree at the indexed-namegraph level. - `getDomainIdByInterpretedName` now forward-traverses from every Root in parallel, preferring the v2 hit and otherwise any v1 hit. Fixes a bug where shadow-registry names (e.g. foo.base.eth) were unreachable via `Query.domain(by: name)`. - `canonical-registries-cte` extends the seed union to every top-level Root. - `isENSv1Domain`/`isENSv2Domain` tightened to `(domain: DomainInterface)` — pushes null-handling to callers; Pothos `isTypeOf` casts at the boundary. - `WrappedBaseRegistrarRegistration.tokenId` explicit null-check before the ENSv1Domain invariant, so a missing domain raises the right error rather than TypeError-ing on `domain.type` interpolation. - enskit `Query.registry` cache resolver probes concrete `__typename`s (ENSv1Registry / ENSv2Registry / ENSv1VirtualRegistry) — `Registry` is a GraphQL interface; graphcache normalizes on the concrete type. - Typo + stale-comment cleanup (Manage Name → Managed Name; v1Domain's → Domain's; drop stale expiry-TODO that contradicts the file's docstring). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../find-domains/canonical-registries-cte.ts | 29 +++++------- .../lib/get-domain-by-interpreted-name.ts | 45 +++++++++---------- .../ensapi/src/omnigraph-api/schema/domain.ts | 12 ++--- .../src/omnigraph-api/schema/registration.ts | 5 +++ .../src/lib/ensv2/domain-db-helpers.ts | 2 +- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 2 +- .../omnigraph/_lib/by-id-lookup-resolvers.ts | 19 +++++++- .../ensnode-sdk/src/shared/root-registry.ts | 29 +++++++++++- 8 files changed, 90 insertions(+), 53 deletions(-) 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 761f654a9..2895b7093 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,10 +2,9 @@ import config from "@/config"; import { sql } from "drizzle-orm"; -import { getENSv1RootRegistryId, 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 namegraph in order to construct the set of Canonical Registries. @@ -21,17 +20,15 @@ import { lazy } from "@/lib/lazy"; */ const CANONICAL_REGISTRIES_MAX_DEPTH = 16; -const getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); -const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); - /** - * Builds a recursive CTE that traverses forward from the ENSv1 root Registry and (when defined) - * the ENSv2 root Registry to construct a set of all Canonical Registries. + * 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 the 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. + * 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 @@ -40,15 +37,11 @@ const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); * TODO: could this be optimized further, perhaps as a materialized view? */ export const getCanonicalRegistriesCTE = () => { - const v1Root = getV1Root(); - const v2Root = getV2Root(); + const roots = getRootRegistryIds(config.namespace); - // TODO: this can be streamlined into a single union once ENSv2Root is available in all namespaces - const rootsUnion = v2Root - ? sql`SELECT ${v1Root}::text AS registry_id, 0 AS depth - UNION ALL - SELECT ${v2Root}::text AS registry_id, 0 AS depth` - : sql`SELECT ${v1Root}::text AS registry_id, 0 AS depth`; + 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({ diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index 896d88a0e..d602233c9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -10,18 +10,12 @@ import { type RegistryId, } from "enssdk"; -import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getRootRegistryIds, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { lazy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; -// lazy() defers construction until first use so that this module can be imported without env vars -// being present (e.g. during OpenAPI generation). -const getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); -const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); - const tracer = trace.getTracer("get-domain-by-interpreted-name"); const logger = makeLogger("get-domain-by-interpreted-name"); @@ -62,21 +56,26 @@ export async function getDomainIdByInterpretedName( name: InterpretedName, ): Promise { return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, async () => { - const v1Root = getV1Root(); - const v2Root = getV2Root(); - - const [v1DomainId, v2DomainId] = await Promise.all([ - withActiveSpanAsync(tracer, "v1_getDomainId", {}, () => traverseFromRoot(v1Root, name)), - // only resolve v2 Domain if ENSv2 Root Registry is defined - v2Root - ? withActiveSpanAsync(tracer, "v2_getDomainId", {}, () => traverseFromRoot(v2Root, name)) - : null, - ]); - - logger.debug({ v1DomainId, v2DomainId }); - - // prefer v2 Domain over v1 Domain - return v2DomainId || v1DomainId || null; + // 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), + ), + ), + ); + + logger.debug({ roots, results }); + + // 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; }); } @@ -98,8 +97,6 @@ async function traverseFromRoot( // 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 diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index aa26cdfa9..ea0e147fc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -67,11 +67,11 @@ export type ENSv1Domain = RequiredAndNotNull & export type ENSv2Domain = RequiredAndNotNull & RequiredAndNull & { type: "ENSv2Domain" }; -export const isENSv1Domain = (domain: unknown): domain is ENSv1Domain => - (domain as DomainInterface).type === "ENSv1Domain"; +export const isENSv1Domain = (domain: DomainInterface): domain is ENSv1Domain => + domain.type === "ENSv1Domain"; -export const isENSv2Domain = (domain: unknown): domain is ENSv2Domain => - (domain as DomainInterface).type === "ENSv2Domain"; +export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => + domain.type === "ENSv2Domain"; export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); @@ -286,7 +286,7 @@ DomainInterfaceRef.implement({ ENSv1DomainRef.implement({ description: "An ENSv1Domain represents an ENSv1 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => isENSv1Domain(domain), + isTypeOf: (domain) => isENSv1Domain(domain as DomainInterface), fields: (t) => ({ /////////////////// // ENSv1Domain.node @@ -317,7 +317,7 @@ ENSv1DomainRef.implement({ ENSv2DomainRef.implement({ description: "An ENSv2Domain represents an ENSv2 Domain.", interfaces: [DomainInterfaceRef], - isTypeOf: (domain) => isENSv2Domain(domain), + isTypeOf: (domain) => isENSv2Domain(domain as DomainInterface), fields: (t) => ({ ////////////////////// // ENSv2Domain.tokenId diff --git a/apps/ensapi/src/omnigraph-api/schema/registration.ts b/apps/ensapi/src/omnigraph-api/schema/registration.ts index 83addb6c9..a0a53b436 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registration.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registration.ts @@ -346,6 +346,11 @@ WrappedBaseRegistrarRegistrationRef.implement({ // 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}.`, diff --git a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts index 1399d87d7..43a921d29 100644 --- a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts @@ -16,7 +16,7 @@ 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.domain, { id }) .set({ ownerId: interpretAddress(owner) }); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index b444cad2f..cb51449fa 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -71,7 +71,7 @@ export default function () { let parentRegistryId: RegistryId; - // if the parent is the Managed Name, the parent registry is the Manage Name's Registry + // 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); 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 27f1e40db..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 @@ -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: makeConcreteRegistryId(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 d1e513cd1..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, makeENSv1RegistryId, makeENSv2RegistryId } from "enssdk"; +import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId, type RegistryId } from "enssdk"; import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; import { @@ -110,3 +110,30 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { */ 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; +}; From 9d5922545780e636e6911d98382df622e0484528 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 15:26:51 -0500 Subject: [PATCH 19/19] fix(ensapi): multi-root canonical-path + DISTINCT canonical-registries + nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `getCanonicalPath` now terminates at any Root Registry returned by `getRootRegistryIds` — fixes the shadow-registry bug where `Domain.name`/`path`/`parent` all returned null for direct children of Basenames/Lineanames (e.g. foo.base.eth). Parallel to the fix already applied to forward traversal. - `canonical-registries-cte` outer projection uses `SELECT DISTINCT`. The reachable registry set is a DAG (aliased subregistries let multiple parent Domains declare the same child Registry), so the CTE can emit the same registry_id at multiple depths; without DISTINCT, `filterByCanonical`'s innerJoin multiplies base rows and inflates `$count`/pagination. Docstring rewritten to match reality (forward `subregistryId` walk, not `rcd`). - `isENSv1Registry`/`isENSv1VirtualRegistry`/`isENSv2Registry` signatures tightened to `RegistryInterface`; Pothos `isTypeOf` casts at the boundary (parallel to the Domain type-guard fix). - AGENTS.md Testing sections merged (dedup heading). - `it.todo` for negative canonical-filter assertion (devnet needs a known non-canonical fixture). - Drop ThreeDNS phrases from the ENSv1Registry descriptions (deferred to a future 3DNS integration PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 7 ++---- .../find-domains/canonical-registries-cte.ts | 16 ++++++------- .../omnigraph-api/lib/get-canonical-path.ts | 23 +++++++------------ .../schema/query.integration.test.ts | 3 +++ .../src/omnigraph-api/schema/registry.ts | 20 ++++++++-------- .../src/ensindexer-abstract/ensv2.schema.ts | 7 +++--- .../src/omnigraph/generated/schema.graphql | 2 +- 7 files changed, 35 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7de76af57..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 @@ -83,8 +85,3 @@ Fail fast and loudly on invalid inputs. 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` - -## Testing - -- Prefer the `await expect(...).resolves.*` format over await-then-expect. -- Prefer `await expect(...).resolves.toMatchObject({})` over expecting individual properties, if it is more concise. 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 2895b7093..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 @@ -9,14 +9,14 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; /** * The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries. * - * Note that the set of Canonical Registries is a _tree_ by construction: each Registry is reached - * via either `registryCanonicalDomain` (ENSv1 virtual / ENSv2) or the concrete ENSv1 root. - * Edge authentication (parent's `subregistryId` matches the child's `registryId`) prevents - * cycles in the declared namegraph. + * 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; @@ -64,7 +64,7 @@ export const getCanonicalRegistriesCTE = () => { 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/get-canonical-path.ts b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts index fa11f682c..5b268f995 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -3,29 +3,23 @@ import config from "@/config"; import { sql } from "drizzle-orm"; import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; -import { getENSv1RootRegistryId, 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 getV1Root = lazy(() => getENSv1RootRegistryId(config.namespace)); -const getV2Root = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); - /** * Provide the canonical parents for a Domain via reverse traversal of the namegraph. * * Traversal walks `domain → registry → canonical parent domain` via the - * {@link registryCanonicalDomain} table and terminates at either the namespace's v1 root Registry - * or its v2 root Registry. Returns `null` when the resulting path does not terminate at a - * root Registry (i.e. the Domain is not canonical). + * {@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 getCanonicalPath(domainId: DomainId): Promise { - const v1Root = getV1Root(); - const v2Root = getV2Root(); + const rootRegistryIds = getRootRegistryIds(config.namespace); const result = await ensDb.execute(sql` WITH RECURSIVE upward AS ( @@ -65,10 +59,9 @@ export async function getCanonicalPath(domainId: DomainId): Promise { 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/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index 33eef81d8..eb9b87be0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -45,14 +45,14 @@ export type ENSv1VirtualRegistry = RequiredAndNotNull & { }; export type ENSv2Registry = RequiredAndNull & { type: "ENSv2Registry" }; -const isENSv1Registry = (registry: unknown): registry is ENSv1Registry => - (registry as RegistryInterface).type === "ENSv1Registry"; +const isENSv1Registry = (registry: RegistryInterface): registry is ENSv1Registry => + registry.type === "ENSv1Registry"; -const isENSv1VirtualRegistry = (registry: unknown): registry is ENSv1VirtualRegistry => - (registry as RegistryInterface).type === "ENSv1VirtualRegistry"; +const isENSv1VirtualRegistry = (registry: RegistryInterface): registry is ENSv1VirtualRegistry => + registry.type === "ENSv1VirtualRegistry"; -const isENSv2Registry = (registry: unknown): registry is ENSv2Registry => - (registry as RegistryInterface).type === "ENSv2Registry"; +const isENSv2Registry = (registry: RegistryInterface): registry is ENSv2Registry => + registry.type === "ENSv2Registry"; export const ENSv1RegistryRef = builder.objectRef("ENSv1Registry"); export const ENSv1VirtualRegistryRef = @@ -148,9 +148,9 @@ RegistryInterfaceRef.implement({ ////////////////////////////// ENSv1RegistryRef.implement({ description: - "An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, the Lineanames shadow Registry, or a ThreeDNS Registry).", + "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), + isTypeOf: (registry) => isENSv1Registry(registry as RegistryInterface), }); ////////////////////////////// @@ -160,7 +160,7 @@ 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), + isTypeOf: (registry) => isENSv1VirtualRegistry(registry as RegistryInterface), fields: (t) => ({ /////////////////////////////// // ENSv1VirtualRegistry.node @@ -180,7 +180,7 @@ ENSv1VirtualRegistryRef.implement({ ENSv2RegistryRef.implement({ description: "An ENSv2Registry represents an ENSv2 Registry contract.", interfaces: [RegistryInterfaceRef], - isTypeOf: (registry) => isENSv2Registry(registry), + isTypeOf: (registry) => isENSv2Registry(registry as RegistryInterface), }); ////////// diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index ada660888..90a18ab16 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -59,10 +59,9 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * 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, ThreeDNS Registries) 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. + * 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: diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 1fcd974dd..ea92e7904 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -376,7 +376,7 @@ type ENSv1Domain implements Domain { } """ -An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, the Lineanames shadow Registry, or a ThreeDNS Registry). +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 { """