From 67fa76af434542c45b604562c3bddfc040e5353d Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 11 May 2026 20:55:38 -0500 Subject: [PATCH 01/15] feat: initial materialized name checkpoint --- .changeset/materialize-canonical-name.md | 10 + .../schema/account.integration.test.ts | 8 +- .../schema/domain.integration.test.ts | 107 ++++------ .../ensapi/src/omnigraph-api/schema/domain.ts | 134 +++++++------ .../schema/query.integration.test.ts | 20 +- .../find-domains/domain-pagination-queries.ts | 4 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 189 +++++++++++++++--- .../src/lib/ensv2/label-db-helpers.ts | 11 + .../ensv2/namehash-label-hash-path.test.ts | 41 ++++ .../src/lib/ensv2/namehash-label-hash-path.ts | 23 +++ .../ensv2/handlers/ensv1/ENSv1Registry.ts | 7 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 2 +- .../src/ensindexer-abstract/ensv2.schema.ts | 32 ++- .../src/omnigraph-api/example-queries.ts | 20 +- .../src/omnigraph/generated/introspection.ts | 142 +++++-------- .../src/omnigraph/generated/schema.graphql | 70 +++---- 16 files changed, 503 insertions(+), 317 deletions(-) create mode 100644 .changeset/materialize-canonical-name.md create mode 100644 apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts create mode 100644 apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts diff --git a/.changeset/materialize-canonical-name.md b/.changeset/materialize-canonical-name.md new file mode 100644 index 0000000000..a7da2c15ea --- /dev/null +++ b/.changeset/materialize-canonical-name.md @@ -0,0 +1,10 @@ +--- +"ensindexer": minor +"ensapi": minor +"@ensnode/ensdb-sdk": minor +"@ensnode/ensnode-sdk": patch +--- + +**Materialize `Domain.canonicalName`, `canonicalLabelHashPath`, and `canonicalNode`** on every Canonical Domain, maintained synchronously by `canonicality-db-helpers.ts` during domain creation, canonicality flips, and label heals. Indexes: hash on `canonicalName` (exact lookup), GIN trigram on `canonicalName` (substring), GIN on `canonicalLabelHashPath` (heal cascade), hash on `canonicalNode` (resolver-record joins). + +**Omnigraph (breaking)**: restructure `Domain.canonical` into a nullable `DomainCanonical` object. Removes top-level `Domain.canonical: Boolean!`, `Domain.name: InterpretedName`, and `Domain.path: [DomainInterface]`; adds `Domain.canonical: DomainCanonical` (null when the Domain is not Canonical) with subfields `{ name: InterpretedName!, path: [DomainId!]!, node: Node! }`. `Domain.canonical.path` now returns DomainIds instead of nested Domain objects. diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index 50f3a1d370..a5ac70b5e0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -27,7 +27,7 @@ describe("Account.domains", () => { account: { domains: GraphQLConnection<{ __typename: "ENSv1Domain" | "ENSv2Domain"; - name: InterpretedName | null; + canonical: { name: InterpretedName } | null; }>; }; }; @@ -39,7 +39,7 @@ describe("Account.domains", () => { where: { version: $version }, order: { by: NAME, dir: ASC } ) { - edges { node { __typename, name } } + edges { node { __typename, canonical { name } } } } } } @@ -50,7 +50,7 @@ describe("Account.domains", () => { address: accounts.owner.address, }); const domains = flattenConnection(result.account.domains); - const names = domains.map((d) => d.name); + const names = domains.map((d) => d.canonical?.name); const expected = [ "alias.eth", @@ -77,7 +77,7 @@ describe("Account.domains", () => { address: accounts.user.address, }); const domains = flattenConnection(result.account.domains); - const names = domains.map((d) => d.name); + const names = domains.map((d) => d.canonical?.name); expect(names, "expected 'newowner.eth' in new owner's domains").toContain("newowner.eth"); }); 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 da448e72fa..73caad6c50 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -36,7 +36,6 @@ describe("Domain.subdomains", () => { type SubdomainsResult = { domain: { subdomains: GraphQLConnection<{ - name: InterpretedName | null; label: { interpreted: InterpretedLabel }; }>; }; @@ -45,7 +44,7 @@ describe("Domain.subdomains", () => { const DomainSubdomains = gql` query DomainSubdomains($name: InterpretedName!) { domain(by: { name: $name }) { - subdomains { edges { node { name label { interpreted } } } } + subdomains { edges { node { label { interpreted } } } } } } `; @@ -61,87 +60,60 @@ describe("Domain.subdomains", () => { }); }); -describe("Domain.path", () => { - type DomainPathResult = { +describe("Domain.canonical", () => { + type DomainCanonicalQueryResult = { domain: { id: DomainId; - path: { id: DomainId; name: InterpretedName | null }[] | null; + canonical: { + name: InterpretedName; + node: string; + path: DomainId[]; + } | null; } | null; }; - const DomainPath = gql` - query DomainPath($name: InterpretedName!) { - domain(by: { name: $name }) { - id - path { - id - name - } - } + const DomainCanonicalByName = gql` + query DomainCanonicalByName($name: InterpretedName!) { + domain(by: { name: $name }) { id canonical { name node path } } } `; - it("returns the full canonical path (leaf → root) for a deep name", async () => { - await expect( - request(DomainPath, { name: "wallet.sub1.sub2.parent.eth" }), - ).resolves.toMatchObject({ - domain: { - path: [ - { name: "wallet.sub1.sub2.parent.eth" }, - { name: "sub1.sub2.parent.eth" }, - { name: "sub2.parent.eth" }, - { name: "parent.eth" }, - { name: "eth" }, - ], - }, - }); - }); + const DomainCanonicalById = gql` + query DomainCanonicalById($id: DomainId!) { + domain(by: { id: $id }) { id canonical { name node path } } + } + `; - it("returns the canonical path for a linked Name", async () => { + it.each(DEVNET_NAMES)( + "materializes canonical.{name, path, node} for '$name'", + async ({ name, canonical }) => { + const result = await request(DomainCanonicalByName, { name }); + expect(result.domain?.canonical).not.toBeNull(); + expect(result.domain!.canonical!.name).toBe(canonical); + expect(result.domain!.canonical!.path.length).toBe(canonical.split(".").length); + }, + ); + + it("returns the canonical name for a linked Name", async () => { // The wallet Registry's `ParentUpdated` claims `sub1.sub2.parent.eth` as its canonical parent. // `linked.parent.eth.subregistry` was later re-pointed to the same Registry without a // corresponding `ParentUpdated`, so `wallet.linked.parent.eth` is an addressable alias whose - // canonical lineage walks through `sub1.sub2.parent.eth` + // canonical lineage walks through `sub1.sub2.parent.eth`. await expect( - request(DomainPath, { name: "wallet.linked.parent.eth" }), + request(DomainCanonicalByName, { + name: "wallet.linked.parent.eth", + }), ).resolves.toMatchObject({ domain: { - path: [ - { name: "wallet.sub1.sub2.parent.eth" }, - { name: "sub1.sub2.parent.eth" }, - { name: "sub2.parent.eth" }, - { name: "parent.eth" }, - { name: "eth" }, - ], + canonical: { + name: "wallet.sub1.sub2.parent.eth", + path: expect.arrayContaining([expect.any(String)]), + }, }, }); }); -}); -describe("Domain.canonical", () => { - type DomainCanonicalResult = { - domain: { id: DomainId; canonical: boolean } | null; - }; - - const DomainCanonicalByName = gql` - query DomainCanonicalByName($name: InterpretedName!) { - domain(by: { name: $name }) { id canonical } - } - `; - - const DomainCanonicalById = gql` - query DomainCanonicalById($id: DomainId!) { - domain(by: { id: $id }) { id canonical } - } - `; - - it.each(DEVNET_NAMES)("is true for ENSv2 Domain '$name'", async ({ name }) => { - await expect( - request(DomainCanonicalByName, { name }), - ).resolves.toMatchObject({ domain: { canonical: true } }); - }); - - it("is true for ENSv1 addr.reverse", async () => { + it("is canonical for ENSv1 addr.reverse", async () => { const v1RootRegistry = getDatasourceContract( "ens-test-env", DatasourceNames.ENSRoot, @@ -149,9 +121,10 @@ describe("Domain.canonical", () => { ); const id = makeENSv1DomainId(v1RootRegistry, ADDR_REVERSE_NODE); - await expect( - request(DomainCanonicalById, { id }), - ).resolves.toMatchObject({ domain: { id, canonical: true } }); + const result = await request(DomainCanonicalById, { id }); + expect(result.domain?.id).toBe(id); + expect(result.domain?.canonical).not.toBeNull(); + expect(result.domain!.canonical!.name).toBe("addr.reverse"); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index ce8535bf60..c68ba5634c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,7 +1,7 @@ import { trace } from "@opentelemetry/api"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import { type DomainId, interpretedLabelsToInterpretedName } from "enssdk"; +import type { DomainId } from "enssdk"; import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; @@ -26,7 +26,6 @@ import { getDomainResolver } from "@/omnigraph-api/lib/get-domain-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; -import { rejectAnyErrors } from "@/omnigraph-api/lib/reject-any-errors"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS, @@ -77,6 +76,66 @@ export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); +//////////////////////////////// +// DomainCanonical +//////////////////////////////// +/** + * Canonical-tree fields materialized on each Canonical Domain. Source is the parent Domain row; + * `canonicalName` and `canonicalNode` are direct column reads, `path` is resolved via + * `getCanonicalPath` (the canonical-edge upward CTE). + */ +export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); + +DomainCanonicalRef.implement({ + description: + "The materialized canonical-tree projection of a Canonical Domain — Canonical Name, " + + "leaf-to-root canonical path (as DomainIds), and namehash.", + fields: (t) => ({ + name: t.field({ + description: "The Canonical Name for this Domain.", + type: "InterpretedName", + nullable: false, + resolve: (domain) => { + if (!domain.canonicalName) { + throw new Error( + `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, + ); + } + return domain.canonicalName; + }, + }), + path: t.field({ + description: + "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds.", + type: ["DomainId"], + nullable: false, + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); + if (canonicalPath instanceof Error) throw canonicalPath; + if (canonicalPath === null) { + throw new Error( + `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' produced null canonical path.`, + ); + } + return canonicalPath; + }, + }), + node: t.field({ + description: "The namehash of this Domain's Canonical Name.", + type: "Node", + nullable: false, + resolve: (domain) => { + if (!domain.canonicalNode) { + throw new Error( + `Invariant(DomainCanonical.node): canonical Domain '${domain.id}' is missing canonicalNode.`, + ); + } + return domain.canonicalNode; + }, + }), + }), +}); + ////////////////////////////////// // DomainInterface Implementation ////////////////////////////////// @@ -108,64 +167,11 @@ DomainInterfaceRef.implement({ // Domain.canonical //////////////////// canonical: t.field({ - description: "Whether the Domain is Canonical.", - type: "Boolean", - nullable: false, - resolve: (parent) => parent.canonical, - }), - - /////////////// - // Domain.name - /////////////// - name: t.field({ description: - "The Canonical Name for this Domain. If the Domain is not Canonical, then `name` will be null.", - tracing: true, - type: "InterpretedName", + "The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical.", + type: DomainCanonicalRef, nullable: true, - resolve: async (domain, _args, context) => { - const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (canonicalPath instanceof Error) throw canonicalPath; - if (canonicalPath === null) return null; - - // TODO: this could be more efficient if getCanonicalPath included the label join for us. - const domains = await rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), - ); - - const labels = canonicalPath.map((domainId) => { - const found = domains.find((d) => d.id === domainId); - if (!found) { - throw new Error( - `Invariant(Domain.name): Domain in CanonicalPath not found:\nPath: ${JSON.stringify(canonicalPath)}\nDomainId: ${domainId}`, - ); - } - - return found.label.interpreted; - }); - - return interpretedLabelsToInterpretedName(labels); - }, - }), - - /////////////// - // Domain.path - /////////////// - path: t.field({ - description: - "The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical.", - tracing: true, - type: [DomainInterfaceRef], - nullable: true, - resolve: async (domain, _args, context) => { - const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (canonicalPath instanceof Error) throw canonicalPath; - if (canonicalPath === null) return null; - - return await rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), - ); - }, + resolve: (domain) => (domain.canonical ? domain : null), }), ///////////////// @@ -173,13 +179,15 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical.", + "The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry).", type: DomainInterfaceRef, nullable: true, - resolve: async (domain, _args, context) => { - const path = await context.loaders.canonicalPath.load(domain.id); - if (path instanceof Error) throw path; - return path?.[1] ?? null; + resolve: async (domain) => { + const registry = await ensDb.query.registry.findFirst({ + where: (t, { eq }) => eq(t.id, domain.registryId), + columns: { canonicalDomainId: true }, + }); + return registry?.canonicalDomainId ?? null; }, }), 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 4f78ec06cd..bea4d70187 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -83,7 +83,7 @@ describe("Query.domains", () => { domains: GraphQLConnection<{ __typename: "ENSv1Domain" | "ENSv2Domain"; id: DomainId; - name: Name; + canonical: { name: Name } | null; label: { interpreted: InterpretedLabel }; owner: { address: NormalizedAddress }; node?: Node; @@ -97,7 +97,9 @@ describe("Query.domains", () => { node { __typename id - name + canonical { + name + } label { interpreted } @@ -131,7 +133,7 @@ describe("Query.domains", () => { domains.find((d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID), ).toMatchObject({ id: V1_ETH_DOMAIN_ID, - name: "eth", + canonical: { name: "eth" }, label: { interpreted: "eth" }, node: ETH_NODE, }); @@ -140,7 +142,7 @@ describe("Query.domains", () => { domains.find((d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID), ).toMatchObject({ id: V2_ETH_DOMAIN_ID, - name: "eth", + canonical: { name: "eth" }, label: { interpreted: "eth" }, }); }); @@ -150,12 +152,12 @@ describe("Query.domains", () => { 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"); + const parentEth = domains.find((d) => d.canonical?.name === "parent.eth"); expect(parentEth).toBeDefined(); - // every returned domain must have a defined canonical `name` (only canonical domains resolve one) + // every returned domain must be canonical for (const d of domains) { - expect(d.name, `expected canonical name for ${d.id}`).toBeTruthy(); + expect(d.canonical, `expected canonical for ${d.id}`).not.toBeNull(); } }); @@ -199,14 +201,14 @@ describe("Query.domain", () => { const DomainByName = gql` query DomainByName($name: InterpretedName!) { domain(by: { name: $name }) { - name + canonical { name } } } `; it.each(DEVNET_NAMES)("resolves $name", async ({ name, canonical }) => { await expect(request(DomainByName, { name })).resolves.toMatchObject({ - domain: { name: canonical }, + domain: { canonical: { name: canonical } }, }); }); diff --git a/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts index 4a251fa848..3b12e36908 100644 --- a/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts +++ b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts @@ -1,4 +1,4 @@ -import type { DomainId, InterpretedLabel, Name } from "enssdk"; +import type { DomainId, InterpretedLabel } from "enssdk"; import { gql } from "@/test/integration/omnigraph-api-client"; @@ -14,7 +14,6 @@ const PageInfoFragment = gql` const PaginatedDomainFragment = gql` fragment PaginatedDomainFragment on Domain { id - name label { interpreted } registration { expiry @@ -25,7 +24,6 @@ const PaginatedDomainFragment = gql` export type PaginatedDomainResult = { id: DomainId; - name: Name | null; label: { interpreted: InterpretedLabel }; registration: { expiry: string | null; diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 9048944227..03c0bcea4e 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -1,11 +1,19 @@ import config from "@/config"; import { sql } from "drizzle-orm"; -import type { AccountId, DomainId, NormalizedAddress, RegistryId } from "enssdk"; +import type { + AccountId, + DomainId, + InterpretedName, + LabelHash, + NormalizedAddress, + RegistryId, +} from "enssdk"; import { isRootRegistryId } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; +import { namehashLabelHashPath } from "@/lib/ensv2/namehash-label-hash-path"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; /** @@ -69,6 +77,7 @@ export async function ensureDomainInRegistry( context: IndexingEngineContext, registryId: RegistryId, domainId: DomainId, + labelHash: LabelHash, ): Promise { const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); if (!registry) { @@ -77,10 +86,47 @@ export async function ensureDomainInRegistry( ); } - // inherit the parent Registry's current canonical flag - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ canonical: registry.canonical }); + // Materialize canonical-tree fields when the parent Registry is canonical. When it isn't, + // we rely on two invariants and skip the write entirely: + // 1. Fresh Domain rows default to `canonical = false` (column default), so the typical insert + // flow needs no write here. + // 2. `cascadeCanonicality` flips every descendant (canonical + the three materialized fields) + // whenever a Registry's `canonical` flag flips, so re-runs of `ensureDomainInRegistry` + // against an existing row always observe the row already in sync with the Registry. + if (registry.canonical) { + // Invariant: callers ensure the Label row (via ensureLabel / ensureUnknownLabel) before this + // function. The Label is required to materialize `canonicalName`. + const label = await context.ensDb.find(ensIndexerSchema.label, { labelHash }); + if (!label) { + throw new Error( + `Invariant(ensureDomainInRegistry): Label '${labelHash}' must exist before linking Domain '${domainId}'.`, + ); + } + + // Read the canonical parent Domain (if any) to inherit its materialized path/name. Root + // Registries have no canonical parent Domain (`canonicalDomainId` is null) and seed the path/name. + const parentDomain = registry.canonicalDomainId + ? await context.ensDb.find(ensIndexerSchema.domain, { id: registry.canonicalDomainId }) + : null; + + const canonicalLabelHashPath: LabelHash[] = [ + labelHash, + ...(parentDomain?.canonicalLabelHashPath ?? []), + ]; + const canonicalName = ( + parentDomain?.canonicalName + ? `${label.interpreted}.${parentDomain.canonicalName}` + : label.interpreted + ) as InterpretedName; + const canonicalNode = namehashLabelHashPath(canonicalLabelHashPath); + + await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ + canonical: true, + canonicalName, + canonicalLabelHashPath, + canonicalNode, + }); + } // flip the parent Registry's __hasChildren sentinel on the first child (idempotent thereafter) if (!registry.__hasChildren) { @@ -285,39 +331,106 @@ async function reconcileRegistryCanonicality( } } +/** + * Propagate a Label heal to every canonical Domain whose `canonicalLabelHashPath` contains + * `labelHash`. Re-renders `canonical_name` by joining each path element to its current + * `label.interpreted` value (preserving leaf-first ordering via WITH ORDINALITY). + * + * `canonicalLabelHashPath` and `canonicalNode` are untouched — label heals don't change labelHashes. + * + * Selectivity comes from the GIN index `byCanonicalLabelHashPath` on `canonical_label_hash_path`. + * Note: GIN indexes are applied at realtime by Ponder, not during backfill — backfill-time heal + * cascades degenerate to a sequential scan; see specs/materialized-name.md for cost analysis. + */ +export async function cascadeLabelHeal( + context: IndexingEngineContext, + labelHash: LabelHash, +): Promise { + await context.ensDb.sql.execute(sql` + UPDATE ${ensIndexerSchema.domain} AS d + SET canonical_name = ( + SELECT string_agg(l.interpreted, '.' ORDER BY p.ord ASC) + FROM unnest(d.canonical_label_hash_path) WITH ORDINALITY AS p(lh, ord) + JOIN ${ensIndexerSchema.label} l ON l.label_hash = p.lh + ) + WHERE d.canonical = true + AND d.canonical_label_hash_path @> ARRAY[${labelHash}]::text[]; + `); +} + /** * Walk the canonical subgraph rooted at `registryId` and set `canonical = nextCanonical` on - * every Registry and Domain it visits. + * every Registry and Domain it visits, additionally materializing canonical-tree fields on + * every affected Domain. * - * Uses one recursive CTE that enumerates the canonical subgraph by following unidirectional - * pointers + agreement check, then two data-modifying CTEs that batch-update Registries and their - * child Domains. The walk visits exactly the rows whose canonicality flag must flip (the - * `IS DISTINCT FROM` filter skips rows already at the target value, which matters for the start - * registry — its flag is set in the same statement — and for any descendants that happen to already - * be consistent). + * Two phases: + * - Phase A is a single statement: one recursive CTE that enumerates the canonical subgraph + * by following unidirectional pointers + agreement check, while carrying the partial + * `parent_path` / `parent_name` accumulators down the tree. A data-modifying CTE batch-updates + * Registry rows; the trailing UPDATE batch-updates Domain rows with the materialized + * `canonical_name` / `canonical_label_hash_path`, nulls `canonical_node` (Phase B fills it), + * and `RETURNING`s the updated (id, canonical_label_hash_path) for the JS-RTT pass. + * - Phase B is JS-side: chunked bulk UPDATEs that compute `canonical_node` via + * `namehashLabelHashPath` over each row's `canonical_label_hash_path`. Only runs when + * `nextCanonical = true`; when flipping to false, Phase A already nulled `canonical_node`. + * + * The `IS DISTINCT FROM` filter skips rows already at the target value (the start registry's + * flag is set in the same statement, and any descendants that happen to already be consistent + * are no-op'd). */ +const CANONICAL_NODE_BATCH_SIZE = 10_000; + async function cascadeCanonicality( context: IndexingEngineContext, registryId: RegistryId, nextCanonical: boolean, ): Promise { - await context.ensDb.sql.execute(sql` - WITH RECURSIVE walk(registry_id) AS ( - SELECT ${registryId}::text + const changed = await context.ensDb.sql.execute(sql` + WITH RECURSIVE walk(registry_id, parent_path, parent_name) AS ( + -- base: seed parent_path / parent_name from the start registry's canonical parent Domain + -- (if any). The start Registry may be a root, in which case no parent Domain exists and + -- seeds are () / NULL. + SELECT + ${registryId}::text, + COALESCE(seed.canonical_label_hash_path, ARRAY[]::text[]), + seed.canonical_name + FROM ( + SELECT pd.canonical_label_hash_path, pd.canonical_name + FROM ${ensIndexerSchema.registry} r + LEFT JOIN ${ensIndexerSchema.domain} pd ON pd.id = r.canonical_domain_id + WHERE r.id = ${registryId} + ) seed UNION - -- step downward: from a registry on the canonical subgraph, find each child Domain - -- (rows whose registry_id equals the current registry id), then follow that Domain's - -- subregistry_id if and only if the child registry agrees back via - -- canonical_domain_id = domain.id. - SELECT child_reg.id + -- step downward via the canonical-edge agreement, extending parent_path / parent_name by + -- the linking Domain's labelHash / interpreted label. + SELECT + child_reg.id, + ARRAY[d.label_hash] || w.parent_path, + COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) FROM walk w JOIN ${ensIndexerSchema.domain} d ON d.registry_id = w.registry_id + JOIN ${ensIndexerSchema.label} l + ON l.label_hash = d.label_hash JOIN ${ensIndexerSchema.registry} child_reg ON child_reg.id = d.subregistry_id - WHERE child_reg.canonical_domain_id = d.id + AND child_reg.canonical_domain_id = d.id + ), + domain_targets AS ( + -- for each Registry in the walk, enumerate ALL of its child Domains (regardless of whether + -- they themselves have a canonical-agreeing subregistry) and project the materialized + -- path / name. + SELECT + d.id AS domain_id, + ARRAY[d.label_hash] || w.parent_path AS new_path, + COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) AS new_name + FROM walk w + JOIN ${ensIndexerSchema.domain} d + ON d.registry_id = w.registry_id + JOIN ${ensIndexerSchema.label} l + ON l.label_hash = d.label_hash ), upd_reg AS ( UPDATE ${ensIndexerSchema.registry} @@ -326,9 +439,35 @@ async function cascadeCanonicality( AND canonical IS DISTINCT FROM ${nextCanonical} RETURNING id ) - UPDATE ${ensIndexerSchema.domain} - SET canonical = ${nextCanonical} - WHERE registry_id IN (SELECT registry_id FROM walk) - AND canonical IS DISTINCT FROM ${nextCanonical}; + UPDATE ${ensIndexerSchema.domain} AS d + SET canonical = ${nextCanonical}, + canonical_name = CASE WHEN ${nextCanonical} THEN dt.new_name ELSE NULL END, + canonical_label_hash_path = CASE WHEN ${nextCanonical} THEN dt.new_path ELSE NULL END, + canonical_node = NULL + FROM domain_targets dt + WHERE d.id = dt.domain_id + AND d.canonical IS DISTINCT FROM ${nextCanonical} + RETURNING d.id, d.canonical_label_hash_path; `); + + // Phase B: when flipping to canonical, compute and write `canonical_node` per affected Domain. + // When flipping to non-canonical, Phase A already nulled `canonical_node` — nothing to do. + if (!nextCanonical) return; + + const rows = changed.rows as { id: DomainId; canonical_label_hash_path: LabelHash[] }[]; + for (let i = 0; i < rows.length; i += CANONICAL_NODE_BATCH_SIZE) { + const batch = rows.slice(i, i + CANONICAL_NODE_BATCH_SIZE); + const ids = batch.map((r) => r.id); + const nodes = batch.map((r) => namehashLabelHashPath(r.canonical_label_hash_path)); + + await context.ensDb.sql.execute(sql` + UPDATE ${ensIndexerSchema.domain} AS d + SET canonical_node = upd.canonical_node + FROM ( + SELECT unnest(${ids}::text[]) AS id, + unnest(${nodes}::text[]) AS canonical_node + ) upd + WHERE d.id = upd.id; + `); + } } diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts index fd3917cfea..c437a94d77 100644 --- a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts @@ -7,6 +7,7 @@ import { literalLabelToInterpretedLabel, } from "enssdk"; +import { cascadeLabelHeal } from "@/lib/ensv2/canonicality-db-helpers"; import { labelByLabelHash } from "@/lib/graphnode-helpers"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; @@ -20,15 +21,25 @@ export async function labelExists(context: IndexingEngineContext, labelHash: Lab /** * Ensures that the LiteralLabel `label` is interpreted and upserted into the Label rainbow table. + * When this upgrades an existing row's `interpreted` value (a heal), propagates the new value to + * every canonical Domain whose `canonicalLabelHashPath` contains this `labelHash`. */ export async function ensureLabel(context: IndexingEngineContext, label: LiteralLabel) { const labelHash = labelhashLiteralLabel(label); const interpreted = literalLabelToInterpretedLabel(label); + // Read prior value to detect heal-upgrades (encoded labelhash → real label, or any change). + // No row → first time we've seen this labelHash, so no existing canonical Domain references it. + const prev = await context.ensDb.find(ensIndexerSchema.label, { labelHash }); + await context.ensDb .insert(ensIndexerSchema.label) .values({ labelHash, interpreted }) .onConflictDoUpdate({ interpreted }); + + if (prev && prev.interpreted !== interpreted) { + await cascadeLabelHeal(context, labelHash); + } } /** diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts new file mode 100644 index 0000000000..45048468c9 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts @@ -0,0 +1,41 @@ +import { + type InterpretedName, + type LabelHash, + type LiteralLabel, + labelhashLiteralLabel, + namehashInterpretedName, +} from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { namehashLabelHashPath } from "./namehash-label-hash-path"; + +const labelHashOf = (label: string) => labelhashLiteralLabel(label as LiteralLabel); + +describe("namehashLabelHashPath", () => { + it("namehashes a single labelHash to the same node as namehashing the label", () => { + // Known label whose hash is its own namehash-input element + const eth = labelHashOf("eth"); + expect(namehashLabelHashPath([eth])).toBe(namehashInterpretedName("eth" as InterpretedName)); + }); + + it("namehashes a leaf-first path equivalent to dot-joining the labels", () => { + // Path is leaf-first: ["wallet", "sub1", "sub2", "parent", "eth"] + const labels = ["wallet", "sub1", "sub2", "parent", "eth"]; + const path = labels.map(labelHashOf); + + expect(namehashLabelHashPath(path)).toBe( + namehashInterpretedName("wallet.sub1.sub2.parent.eth" as InterpretedName), + ); + }); + + it("falls back to encoded label form for unknown labelHashes (heal-stable)", () => { + // A labelHash with no preimage — should namehash via the encoded form `[]` + const unknown = + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as LabelHash; + const eth = labelHashOf("eth"); + + expect(namehashLabelHashPath([unknown, eth])).toBe( + namehashInterpretedName(`[${unknown.slice(2)}].eth` as InterpretedName), + ); + }); +}); diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts new file mode 100644 index 0000000000..0d2e8fba9e --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts @@ -0,0 +1,23 @@ +import { + encodeLabelHash, + type InterpretedLabel, + interpretedLabelsToInterpretedName, + type LabelHash, + type Node, + namehashInterpretedName, +} from "enssdk"; + +/** + * Namehash a leaf-first labelHash path (i.e. `Domain.canonicalLabelHashPath`) by encoding each + * labelHash as an EncodedLabelHash, joining into an InterpretedName, then namehashing. + * + * Used to derive `Domain.canonicalNode` from `Domain.canonicalLabelHashPath`. Robust to label + * heals — the namehash is over labelHashes, not interpreted labels. + */ +export function namehashLabelHashPath(labelHashPath: LabelHash[]): Node { + return namehashInterpretedName( + interpretedLabelsToInterpretedName( + labelHashPath.map((lh) => encodeLabelHash(lh) as unknown as InterpretedLabel), + ), + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 242da6cf27..864b5e000b 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -127,9 +127,8 @@ export default function () { }) .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); - await ensureDomainInRegistry(context, parentRegistryId, domainId); - - // Label Healing + // Label Healing — must run before `ensureDomainInRegistry` so the Label row exists when the + // canonical-tree materializer reads it. // // only attempt to heal label if it doesn't already exist const exists = await labelExists(context, labelHash); @@ -157,6 +156,8 @@ export default function () { } } + await ensureDomainInRegistry(context, parentRegistryId, domainId, labelHash); + // push event to domain history const eventId = await ensureEvent(context, event); await ensureDomainEvent(context, domainId, eventId); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 234b5b9ad4..9fdcf6643f 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -131,7 +131,7 @@ export default function () { // if the domain exists, this is a re-register after expiration and tokenId will have changed .onConflictDoUpdate({ tokenId }); - await ensureDomainInRegistry(context, registryId, domainId); + await ensureDomainInRegistry(context, registryId, domainId, labelHash); // insert Registration const registrantId = await ensureAccount(context, registrant); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 9fe69e7e57..37567c1e5f 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -2,6 +2,7 @@ import type { ChainId, DomainId, InterpretedLabel, + InterpretedName, LabelHash, Node, NormalizedAddress, @@ -24,8 +25,9 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * * While the initial approach was a highly materialized view of the ENS protocol, abstracting away * as many on-chain details as possible, in practice—due to the sheer complexity of the protocol at - * resolution-time—it becomes more or less impossible to appropriately materialize the canonical - * namegraph. + * resolution-time—full materialization of resolution behavior is impractical. The canonical + * subgraph, however, is materialized inline via synchronous handler-side cascades; see + * `Domain.canonical*` fields and `canonicality-db-helpers.ts`. * * As a result, this schema takes a balanced approach. It mimics on-chain state as closely as possible, * with the obvious exception of materializing specific state that must trivially filterable. Then, @@ -50,10 +52,11 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * guarantees (for example, ENSv1 BaseRegistrar Registrations may have a gracePeriod, but ENSv2 * Registry Registrations do not). * - * Instead of materializing a Domain's name at any point, we maintain an internal rainbow table of - * labelHash -> InterpretedLabel (the Label entity). This ensures that regardless of how or when a - * new label is encountered onchain, all Domains that use that label are automatically healed at - * resolution-time. + * The `Label` entity (labelHash → InterpretedLabel) remains the source of truth for label values. + * Canonical-tree fields on `Domain` (`canonicalName`, `canonicalLabelHashPath`, `canonicalNode`) + * are materialized inline by the handlers in `canonicality-db-helpers.ts`. Label heals propagate + * to `canonicalName` via a GIN-indexed bulk UPDATE outside Ponder's cache; cascade round-trips + * are bounded to events that already pay a flush (canonicality flip, heal of an unknown label). * * 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 @@ -288,6 +291,13 @@ export const domain = onchainTable( // Whether this Domain is part of the canonical nametree. Mirrors the parent Registry's flag. canonical: t.boolean().notNull().default(false), + // Materialized canonical-tree fields. All three are set/cleared atomically with `canonical` + // (all NULL iff `canonical = false`). Maintained inline by `canonicality-db-helpers.ts`. + // `canonicalLabelHashPath` is leaf-first; `canonicalNode` is the namehash over the path. + canonicalName: t.text().$type(), + canonicalLabelHashPath: t.hex().array().$type(), + canonicalNode: t.hex().$type(), + // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin }), (t) => ({ @@ -296,6 +306,16 @@ export const domain = onchainTable( bySubregistry: index().on(t.subregistryId).where(sql`${t.subregistryId} IS NOT NULL`), byOwner: index().on(t.ownerId), byLabelHash: index().on(t.labelHash), + + // hash index avoids the btree 8191-byte row-size hazard for spam names + byCanonicalNameExact: index().using("hash", t.canonicalName), + // GIN trigram index for substring / similarity queries (inline `gin_trgm_ops` via `sql` + // because passing it through `.op()` gets dropped by Ponder) + byCanonicalNameFuzzy: index().using("gin", sql`${t.canonicalName} gin_trgm_ops`), + // GIN containment for `cascadeLabelHeal`'s `canonical_label_hash_path @> ARRAY[lh]` lookup + byCanonicalLabelHashPath: index().using("gin", t.canonicalLabelHashPath), + // hash index for resolver-record → canonical-domain joins + byCanonicalNode: index().using("hash", t.canonicalNode), }), ); diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index d4e0127fdb..338d09accd 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -56,7 +56,7 @@ export const GRAPHQL_API_EXAMPLE_QUERIES: Array<{ # # There are also example queries in the tabs above ☝️ query HelloWorld { - domain(by: { name: "eth" }) { name owner { address } } + domain(by: { name: "eth" }) { canonical { name } owner { address } } }`, variables: { default: {} }, }, @@ -80,7 +80,7 @@ query FindDomains( __typename id label { interpreted hash } - name + canonical { name } registration { expiry event { timestamp } } } @@ -104,7 +104,7 @@ query DomainByName($name: InterpretedName!) { __typename id label { interpreted hash } - name + canonical { name node path } owner { address } ... on ENSv1Domain { @@ -131,11 +131,11 @@ query DomainByName($name: InterpretedName!) { query: ` query DomainSubdomains($name: InterpretedName!) { domain(by: {name: $name}) { - name + canonical { name } subdomains(first: 10) { edges { node { - name + canonical { name } } } } @@ -185,7 +185,7 @@ query AccountDomains( edges { node { label { interpreted } - name + canonical { name } } } } @@ -230,7 +230,7 @@ query RegistryDomains( edges { node { label { interpreted } - name + canonical { name } } } } @@ -359,17 +359,17 @@ query Namegraph { domains { edges { node { - name + canonical { name } subdomains { edges { node { - name + canonical { name } subdomains { edges { node { - name + canonical { name } } } } diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 5578bb4837..78c89b723b 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1052,11 +1052,8 @@ const introspection = { { "name": "canonical", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Boolean" - } + "kind": "OBJECT", + "name": "DomainCanonical" }, "args": [], "isDeprecated": false @@ -1130,15 +1127,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "name", - "type": { - "kind": "SCALAR", - "name": "InterpretedName" - }, - "args": [], - "isDeprecated": false - }, { "name": "owner", "type": { @@ -1157,21 +1145,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "path", - "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "INTERFACE", - "name": "Domain" - } - } - }, - "args": [], - "isDeprecated": false - }, { "name": "registration", "type": { @@ -1293,6 +1266,55 @@ const introspection = { } ] }, + { + "kind": "OBJECT", + "name": "DomainCanonical", + "fields": [ + { + "name": "name", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterpretedName" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Node" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "path", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "DomainId" + } + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "DomainEventsConnection", @@ -1660,11 +1682,8 @@ const introspection = { { "name": "canonical", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Boolean" - } + "kind": "OBJECT", + "name": "DomainCanonical" }, "args": [], "isDeprecated": false @@ -1738,15 +1757,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "name", - "type": { - "kind": "SCALAR", - "name": "InterpretedName" - }, - "args": [], - "isDeprecated": false - }, { "name": "node", "type": { @@ -1777,21 +1787,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "path", - "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "INTERFACE", - "name": "Domain" - } - } - }, - "args": [], - "isDeprecated": false - }, { "name": "registration", "type": { @@ -2230,11 +2225,8 @@ const introspection = { { "name": "canonical", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Boolean" - } + "kind": "OBJECT", + "name": "DomainCanonical" }, "args": [], "isDeprecated": false @@ -2308,15 +2300,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "name", - "type": { - "kind": "SCALAR", - "name": "InterpretedName" - }, - "args": [], - "isDeprecated": false - }, { "name": "owner", "type": { @@ -2335,21 +2318,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "path", - "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "INTERFACE", - "name": "Domain" - } - } - }, - "args": [], - "isDeprecated": false - }, { "name": "permissions", "type": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 7ec260c289..a55b5f0a74 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -216,8 +216,10 @@ scalar CoinType A Domain represents an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ interface Domain { - """Whether the Domain is Canonical.""" - canonical: Boolean! + """ + The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + """ + canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -228,26 +230,16 @@ interface Domain { """The Label this Domain represents in the ENS Namegraph""" label: Label! - """ - The Canonical Name for this Domain. If the Domain is not Canonical, then `name` will be null. - """ - name: InterpretedName - """ If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). """ owner: Account """ - The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). """ parent: Domain - """ - The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. - """ - path: [Domain!] - """The latest Registration for this Domain, if exists.""" registration: Registration @@ -265,6 +257,22 @@ interface Domain { subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection } +""" +The materialized canonical-tree projection of a Canonical Domain — Canonical Name, leaf-to-root canonical path (as DomainIds), and namehash. +""" +type DomainCanonical { + """The Canonical Name for this Domain.""" + name: InterpretedName! + + """The namehash of this Domain's Canonical Name.""" + node: Node! + + """ + The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds. + """ + path: [DomainId!]! +} + type DomainEventsConnection { edges: [DomainEventsConnectionEdge!]! pageInfo: PageInfo! @@ -348,8 +356,10 @@ enum ENSProtocolVersion { """An ENSv1Domain represents an ENSv1 Domain.""" type ENSv1Domain implements Domain { - """Whether the Domain is Canonical.""" - canonical: Boolean! + """ + The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + """ + canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -360,11 +370,6 @@ type ENSv1Domain implements Domain { """The Label this Domain represents in the ENS Namegraph""" label: Label! - """ - The Canonical Name for this Domain. If the Domain is not Canonical, then `name` will be null. - """ - name: InterpretedName - """The namehash of this ENSv1 Domain.""" node: Node! @@ -374,15 +379,10 @@ type ENSv1Domain implements Domain { owner: Account """ - The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). """ parent: Domain - """ - The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. - """ - path: [Domain!] - """The latest Registration for this Domain, if exists.""" registration: Registration @@ -462,8 +462,10 @@ type ENSv1VirtualRegistry implements Registry { """An ENSv2Domain represents an ENSv2 Domain.""" type ENSv2Domain implements Domain { - """Whether the Domain is Canonical.""" - canonical: Boolean! + """ + The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + """ + canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -474,26 +476,16 @@ type ENSv2Domain implements Domain { """The Label this Domain represents in the ENS Namegraph""" label: Label! - """ - The Canonical Name for this Domain. If the Domain is not Canonical, then `name` will be null. - """ - name: InterpretedName - """ If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). """ owner: Account """ - The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). """ parent: Domain - """ - The Canonical Path from this Domain to the ENS Root, in leaf→root order and inclusive of this Domain. `path` is null if the Domain is not Canonical. - """ - path: [Domain!] - """ Permissions for this Domain within its Registry, representing the roles granted to users for this Domain's token. """ From ec247e417c266a4e10fba56fa3dfa018b8ce52b9 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:01:26 -0500 Subject: [PATCH 02/15] fix: changeset, update example app for new format --- .changeset/ensapi-canonical-name.md | 5 +++++ .changeset/materialize-canonical-name.md | 6 +----- apps/ensapi/src/omnigraph-api/schema/domain.ts | 7 +------ examples/enskit-react-example/src/AccountView.tsx | 8 ++++---- examples/enskit-react-example/src/DomainView.tsx | 10 ++++++---- examples/enskit-react-example/src/SearchView.tsx | 10 +++++----- .../enssdk/src/omnigraph/generated/introspection.ts | 4 ++-- packages/enssdk/src/omnigraph/generated/schema.graphql | 2 +- .../enssdk/src/omnigraph/module.integration.test.ts | 4 ++-- 9 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 .changeset/ensapi-canonical-name.md diff --git a/.changeset/ensapi-canonical-name.md b/.changeset/ensapi-canonical-name.md new file mode 100644 index 0000000000..20fa96b440 --- /dev/null +++ b/.changeset/ensapi-canonical-name.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: restructure `Domain.canonical` into a nullable `DomainCanonical` object. Removes top-level `Domain.canonical: Boolean!`, `Domain.name: InterpretedName`, and `Domain.path: [DomainInterface]`; adds `Domain.canonical: DomainCanonical` (null when the Domain is not Canonical) with subfields `{ name: InterpretedName!, path: [Domain!]!, node: Node! }`. diff --git a/.changeset/materialize-canonical-name.md b/.changeset/materialize-canonical-name.md index a7da2c15ea..5d0169ec39 100644 --- a/.changeset/materialize-canonical-name.md +++ b/.changeset/materialize-canonical-name.md @@ -1,10 +1,6 @@ --- "ensindexer": minor -"ensapi": minor "@ensnode/ensdb-sdk": minor -"@ensnode/ensnode-sdk": patch --- -**Materialize `Domain.canonicalName`, `canonicalLabelHashPath`, and `canonicalNode`** on every Canonical Domain, maintained synchronously by `canonicality-db-helpers.ts` during domain creation, canonicality flips, and label heals. Indexes: hash on `canonicalName` (exact lookup), GIN trigram on `canonicalName` (substring), GIN on `canonicalLabelHashPath` (heal cascade), hash on `canonicalNode` (resolver-record joins). - -**Omnigraph (breaking)**: restructure `Domain.canonical` into a nullable `DomainCanonical` object. Removes top-level `Domain.canonical: Boolean!`, `Domain.name: InterpretedName`, and `Domain.path: [DomainInterface]`; adds `Domain.canonical: DomainCanonical` (null when the Domain is not Canonical) with subfields `{ name: InterpretedName!, path: [DomainId!]!, node: Node! }`. `Domain.canonical.path` now returns DomainIds instead of nested Domain objects. +**Materialize `Domain.canonicalName`, `canonicalLabelHashPath`, and `canonicalNode`** on every Canonical Domain. Indexes: hash on `canonicalName` (exact lookup), GIN trigram on `canonicalName` (substring), GIN on `canonicalLabelHashPath` (heal cascade), hash on `canonicalNode` (resolver-record joins). diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index c68ba5634c..1f0227cbbf 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -79,11 +79,6 @@ export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); //////////////////////////////// // DomainCanonical //////////////////////////////// -/** - * Canonical-tree fields materialized on each Canonical Domain. Source is the parent Domain row; - * `canonicalName` and `canonicalNode` are direct column reads, `path` is resolved via - * `getCanonicalPath` (the canonical-edge upward CTE). - */ export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); DomainCanonicalRef.implement({ @@ -107,7 +102,7 @@ DomainCanonicalRef.implement({ path: t.field({ description: "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds.", - type: ["DomainId"], + type: [DomainInterfaceRef], nullable: false, resolve: async (domain, _args, context) => { const canonicalPath = await context.loaders.canonicalPath.load(domain.id); diff --git a/examples/enskit-react-example/src/AccountView.tsx b/examples/enskit-react-example/src/AccountView.tsx index 4747e6e142..d194a0fd46 100644 --- a/examples/enskit-react-example/src/AccountView.tsx +++ b/examples/enskit-react-example/src/AccountView.tsx @@ -15,7 +15,7 @@ const AccountDomainsQuery = graphql(` domains(first: $first, after: $after) { totalCount edges { - node { __typename id name owner { address } } + node { __typename id canonical { name } } } pageInfo { hasNextPage endCursor } } @@ -60,9 +60,9 @@ function RenderAccount({ address }: { address: NormalizedAddress }) {
    {domains.edges.map((edge) => (
  • - {edge.node.name ? ( - - {beautifyInterpretedName(edge.node.name)} + {edge.node.canonical ? ( + + {beautifyInterpretedName(edge.node.canonical.name)} ) : ( non-canonical domain diff --git a/examples/enskit-react-example/src/DomainView.tsx b/examples/enskit-react-example/src/DomainView.tsx index a263afed15..bafe374249 100644 --- a/examples/enskit-react-example/src/DomainView.tsx +++ b/examples/enskit-react-example/src/DomainView.tsx @@ -13,7 +13,7 @@ const DomainFragment = graphql(` fragment DomainFragment on Domain { __typename id - name + canonical { name } owner { id address } } `); @@ -47,8 +47,10 @@ function SubdomainLink({ data }: { data: FragmentOf }) { return (
  • - {domain.name ? ( - {beautifyInterpretedName(domain.name)} + {domain.canonical ? ( + + {beautifyInterpretedName(domain.canonical.name)} + ) : ( non-canonical domain )}{" "} @@ -84,7 +86,7 @@ function RenderDomain({ name }: { name: InterpretedName }) { return (
    -

    {beautifyInterpretedName(domain.name ?? name)}

    +

    {beautifyInterpretedName(domain.canonical?.name ?? name)}

    Owner:{" "} {domain.owner ? ( diff --git a/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index 6d78dd9a89..fdfa75affb 100644 --- a/examples/enskit-react-example/src/SearchView.tsx +++ b/examples/enskit-react-example/src/SearchView.tsx @@ -5,9 +5,9 @@ import { Link, useSearchParams } from "react-router"; const DomainsByNameQuery = graphql(` query DomainsByName($name: String!, $first: Int!, $after: String) { - domains(where: { name: $name, canonical: true }, first: $first, after: $after) { + domains(where: { name: $name }, first: $first, after: $after) { edges { - node { __typename id name } + node { __typename id canonical {name} } } pageInfo { hasNextPage @@ -90,9 +90,9 @@ export function SearchView() { {data?.domains?.edges.map((edge) => (

  • ({edge.node.__typename === "ENSv1Domain" ? "v1" : "v2"}){" "} - {edge.node.name && ( - - {beautifyInterpretedName(edge.node.name)} + {edge.node.canonical && ( + + {beautifyInterpretedName(edge.node.canonical.name)} )}
  • diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 78c89b723b..b8d1bb9ed0 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1303,8 +1303,8 @@ const introspection = { "ofType": { "kind": "NON_NULL", "ofType": { - "kind": "SCALAR", - "name": "DomainId" + "kind": "INTERFACE", + "name": "Domain" } } } diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index a55b5f0a74..4f54c54cd2 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -270,7 +270,7 @@ type DomainCanonical { """ The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds. """ - path: [DomainId!]! + path: [Domain!]! } type DomainEventsConnection { diff --git a/packages/enssdk/src/omnigraph/module.integration.test.ts b/packages/enssdk/src/omnigraph/module.integration.test.ts index fd441e8232..2ead8f6bf8 100644 --- a/packages/enssdk/src/omnigraph/module.integration.test.ts +++ b/packages/enssdk/src/omnigraph/module.integration.test.ts @@ -13,7 +13,7 @@ const HelloWorldQuery = graphql(` query HelloWorld { domain(by: { name: "eth" }) { id - name + canonical { name } owner { address } } } @@ -27,7 +27,7 @@ describe("omnigraph module (integration)", () => { // look, our semantic types! expectTypeOf(result.data!.domain!.id).toEqualTypeOf(); - expectTypeOf(result.data!.domain!.name).toEqualTypeOf(); + expectTypeOf(result.data!.domain!.canonical!.name).toEqualTypeOf(); expectTypeOf(result.data!.domain!.owner!.address).toEqualTypeOf
    (); // the 'eth' domain should exist From b3fd44c89f997e25ba6a047a7afdb7f628dd8918 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:07:49 -0500 Subject: [PATCH 03/15] fix: refactor domain-canonical and domain-inputs into their own files --- .../lib/find-domains/domain-cursor.ts | 2 +- .../find-domains-resolver-helpers.ts | 2 +- .../lib/find-domains/find-domains-resolver.ts | 5 +- apps/ensapi/src/omnigraph-api/schema.ts | 2 + .../src/omnigraph-api/schema/account.ts | 7 +- .../omnigraph-api/schema/domain-canonical.ts | 57 +++++++ .../src/omnigraph-api/schema/domain-inputs.ts | 99 +++++++++++ .../ensapi/src/omnigraph-api/schema/domain.ts | 159 +----------------- apps/ensapi/src/omnigraph-api/schema/query.ts | 4 +- .../src/omnigraph-api/schema/registry.ts | 7 +- .../find-domains/test-domain-pagination.ts | 2 +- 11 files changed, 175 insertions(+), 171 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts index 44fe5a0d05..fb9a34be59 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts @@ -2,7 +2,7 @@ import type { DomainId } from "enssdk"; import { cursors } from "@/omnigraph-api/lib/cursors"; import type { DomainOrderValue } from "@/omnigraph-api/lib/find-domains/types"; -import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain"; +import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; /** diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts index b26540e9d7..410a9ff7d9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts @@ -2,7 +2,7 @@ import { asc, desc, type SQL, sql } from "drizzle-orm"; import type { DomainCursor } from "@/omnigraph-api/lib/find-domains/domain-cursor"; import type { DomainsWithOrderingMetadata } from "@/omnigraph-api/lib/find-domains/layers/with-ordering-metadata"; -import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain"; +import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; /** diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts index 142030da67..4f411e4c75 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts @@ -16,13 +16,12 @@ import { PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; +import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; import { DOMAINS_DEFAULT_ORDER_BY, DOMAINS_DEFAULT_ORDER_DIR, - type Domain, - DomainInterfaceRef, type DomainsOrderBy, -} from "@/omnigraph-api/schema/domain"; +} from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; import { DomainCursors } from "./domain-cursor"; diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index b75da6d213..824a680a18 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -3,6 +3,8 @@ import { builder } from "@/omnigraph-api/builder"; import "./schema/account-id"; import "./schema/connection"; import "./schema/domain"; +import "./schema/domain-canonical"; +import "./schema/domain-inputs"; import "./schema/event"; import "./schema/label"; import "./schema/name-or-node"; diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 5d2e5f2d3c..d641b5e729 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -19,11 +19,8 @@ import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; -import { - AccountDomainsWhereInput, - DomainInterfaceRef, - DomainsOrderInput, -} from "@/omnigraph-api/schema/domain"; +import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; +import { AccountDomainsWhereInput, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import { AccountEventsWhereInput, EventRef } from "@/omnigraph-api/schema/event"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts new file mode 100644 index 0000000000..186f38d213 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -0,0 +1,57 @@ +import { builder } from "@/omnigraph-api/builder"; +import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; + +//////////////////////////////// +// DomainCanonical +//////////////////////////////// +export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); + +DomainCanonicalRef.implement({ + description: + "The materialized canonical-tree projection of a Canonical Domain — Canonical Name, " + + "leaf-to-root canonical path (as DomainIds), and namehash.", + fields: (t) => ({ + name: t.field({ + description: "The Canonical Name for this Domain.", + type: "InterpretedName", + nullable: false, + resolve: (domain) => { + if (!domain.canonicalName) { + throw new Error( + `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, + ); + } + return domain.canonicalName; + }, + }), + path: t.field({ + description: + "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds.", + type: [DomainInterfaceRef], + nullable: false, + resolve: async (domain, _args, context) => { + const canonicalPath = await context.loaders.canonicalPath.load(domain.id); + if (canonicalPath instanceof Error) throw canonicalPath; + if (canonicalPath === null) { + throw new Error( + `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' produced null canonical path.`, + ); + } + return canonicalPath; + }, + }), + node: t.field({ + description: "The namehash of this Domain's Canonical Name.", + type: "Node", + nullable: false, + resolve: (domain) => { + if (!domain.canonicalNode) { + throw new Error( + `Invariant(DomainCanonical.node): canonical Domain '${domain.id}' is missing canonicalNode.`, + ); + } + return domain.canonicalNode; + }, + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts new file mode 100644 index 0000000000..dd92d31a9d --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -0,0 +1,99 @@ +import { builder } from "@/omnigraph-api/builder"; +import { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-version"; +import { OrderDirection } from "@/omnigraph-api/schema/order-direction"; + +////////////////////// +// Inputs +////////////////////// + +export const DomainPermissionsWhereInput = builder.inputType("DomainPermissionsWhereInput", { + description: "Filter Permissions over this Domain by a specific User address.", + fields: (t) => ({ + user: t.field({ type: "Address" }), + }), +}); + +export const DomainIdInput = builder.inputType("DomainIdInput", { + description: "Reference a specific Domain.", + isOneOf: true, + fields: (t) => ({ + name: t.field({ type: "InterpretedName" }), + id: t.field({ type: "DomainId" }), + }), +}); + +export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { + description: "Filter for the top-level domains query.", + fields: (t) => ({ + name: t.string({ + required: true, + description: + "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", + }), + version: t.field({ + type: ENSProtocolVersion, + description: + "If set, filters the set of Domains to only those of the specified ENS protocol version.", + }), + }), +}); + +export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereInput", { + description: "Filter for Account.domains query.", + fields: (t) => ({ + name: t.string({ + description: + "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", + }), + canonical: t.boolean({ + description: + "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution).", + defaultValue: false, + }), + version: t.field({ + type: ENSProtocolVersion, + description: + "If set, filters the set of Domains to only those of the specified ENS protocol version.", + }), + }), +}); + +export const RegistryDomainsWhereInput = builder.inputType("RegistryDomainsWhereInput", { + description: "Filter for Registry.domains query.", + fields: (t) => ({ + name: t.string({ + description: "A partial Interpreted Name by which to filter Domains in this Registry.", + }), + }), +}); + +export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", { + description: "Filter for Domain.subdomains query.", + fields: (t) => ({ + name: t.string({ + description: "A partial Interpreted Name by which to filter subdomains.", + }), + }), +}); + +////////////////////// +// Ordering +////////////////////// + +export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { + description: "Fields by which domains can be ordered", + values: ["NAME", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const, +}); + +export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; + +export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { + description: "Ordering options for domains query. If no order is provided, the default is ASC.", + fields: (t) => ({ + by: t.field({ type: DomainsOrderBy, required: true }), + dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), + }), +}); + +export const DOMAINS_DEFAULT_ORDER_BY: typeof DomainsOrderBy.$inferType = "NAME"; +export const DOMAINS_DEFAULT_ORDER_DIR: typeof OrderDirection.$inferType = "ASC"; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 1f0227cbbf..2325b8a693 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -32,10 +32,14 @@ import { PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; -import { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-version"; +import { DomainCanonicalRef } from "@/omnigraph-api/schema/domain-canonical"; +import { + DomainPermissionsWhereInput, + DomainsOrderInput, + SubdomainsWhereInput, +} from "@/omnigraph-api/schema/domain-inputs"; import { EventRef, EventsWhereInput } from "@/omnigraph-api/schema/event"; 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 { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; @@ -76,61 +80,6 @@ export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); -//////////////////////////////// -// DomainCanonical -//////////////////////////////// -export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); - -DomainCanonicalRef.implement({ - description: - "The materialized canonical-tree projection of a Canonical Domain — Canonical Name, " + - "leaf-to-root canonical path (as DomainIds), and namehash.", - fields: (t) => ({ - name: t.field({ - description: "The Canonical Name for this Domain.", - type: "InterpretedName", - nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - return domain.canonicalName; - }, - }), - path: t.field({ - description: - "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds.", - type: [DomainInterfaceRef], - nullable: false, - resolve: async (domain, _args, context) => { - const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (canonicalPath instanceof Error) throw canonicalPath; - if (canonicalPath === null) { - throw new Error( - `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' produced null canonical path.`, - ); - } - return canonicalPath; - }, - }), - node: t.field({ - description: "The namehash of this Domain's Canonical Name.", - type: "Node", - nullable: false, - resolve: (domain) => { - if (!domain.canonicalNode) { - throw new Error( - `Invariant(DomainCanonical.node): canonical Domain '${domain.id}' is missing canonicalNode.`, - ); - } - return domain.canonicalNode; - }, - }), - }), -}); - ////////////////////////////////// // DomainInterface Implementation ////////////////////////////////// @@ -414,99 +363,3 @@ ENSv2DomainRef.implement({ }), }), }); - -////////////////////// -// Inputs -////////////////////// - -export const DomainPermissionsWhereInput = builder.inputType("DomainPermissionsWhereInput", { - description: "Filter Permissions over this Domain by a specific User address.", - fields: (t) => ({ - user: t.field({ type: "Address" }), - }), -}); - -export const DomainIdInput = builder.inputType("DomainIdInput", { - description: "Reference a specific Domain.", - isOneOf: true, - fields: (t) => ({ - name: t.field({ type: "InterpretedName" }), - id: t.field({ type: "DomainId" }), - }), -}); - -export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { - description: "Filter for the top-level domains query.", - fields: (t) => ({ - name: t.string({ - required: true, - description: - "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", - }), - version: t.field({ - type: ENSProtocolVersion, - description: - "If set, filters the set of Domains to only those of the specified ENS protocol version.", - }), - }), -}); - -export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereInput", { - description: "Filter for Account.domains query.", - fields: (t) => ({ - name: t.string({ - description: - "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", - }), - canonical: t.boolean({ - description: - "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution).", - defaultValue: false, - }), - version: t.field({ - type: ENSProtocolVersion, - description: - "If set, filters the set of Domains to only those of the specified ENS protocol version.", - }), - }), -}); - -export const RegistryDomainsWhereInput = builder.inputType("RegistryDomainsWhereInput", { - description: "Filter for Registry.domains query.", - fields: (t) => ({ - name: t.string({ - description: "A partial Interpreted Name by which to filter Domains in this Registry.", - }), - }), -}); - -export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", { - description: "Filter for Domain.subdomains query.", - fields: (t) => ({ - name: t.string({ - description: "A partial Interpreted Name by which to filter subdomains.", - }), - }), -}); - -////////////////////// -// Ordering -////////////////////// - -export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { - description: "Fields by which domains can be ordered", - values: ["NAME", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const, -}); - -export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; - -export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { - description: "Ordering options for domains query. If no order is provided, the default is ASC.", - fields: (t) => ({ - by: t.field({ type: DomainsOrderBy, required: true }), - dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), - }), -}); - -export const DOMAINS_DEFAULT_ORDER_BY: typeof DomainsOrderBy.$inferType = "NAME"; -export const DOMAINS_DEFAULT_ORDER_DIR: typeof OrderDirection.$inferType = "ASC"; diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 3969e54967..d76082e179 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -20,12 +20,12 @@ import { getDomainIdByInterpretedName } from "@/omnigraph-api/lib/get-domain-by- import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; +import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; import { DomainIdInput, - DomainInterfaceRef, DomainsOrderInput, DomainsWhereInput, -} from "@/omnigraph-api/schema/domain"; +} from "@/omnigraph-api/schema/domain-inputs"; import { PermissionsIdInput, PermissionsRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryIdInput, RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index 278807d804..098ad99eac 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -18,11 +18,8 @@ import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountIdInput, AccountIdRef } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; -import { - DomainInterfaceRef, - DomainsOrderInput, - RegistryDomainsWhereInput, -} from "@/omnigraph-api/schema/domain"; +import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; +import { DomainsOrderInput, RegistryDomainsWhereInput } from "@/omnigraph-api/schema/domain-inputs"; import { PermissionsRef } from "@/omnigraph-api/schema/permissions"; /////////////////////////////////// diff --git a/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts index abd213fb7f..fbcce9a407 100644 --- a/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts +++ b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "vitest"; -import type { DomainsOrderByValue, DomainsOrderInput } from "@/omnigraph-api/schema/domain"; +import type { DomainsOrderByValue, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirectionValue } from "@/omnigraph-api/schema/order-direction"; import type { PaginatedDomainResult } from "@/test/integration/find-domains/domain-pagination-queries"; import { From 90618b6f3e1a4ab93201b286685acd071123cfdd Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:16:25 -0500 Subject: [PATCH 04/15] docs(changeset): Omnigraph: The Domain interface now exposes `Domain.registry` and `Domain.subregistry` rather than being isolated to the concrete ENSv2 Domain entity, as in the unified model both ENSv1 and ENSv2 Domains have a parent and child Registry. --- .changeset/orange-taxes-vanish.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/orange-taxes-vanish.md diff --git a/.changeset/orange-taxes-vanish.md b/.changeset/orange-taxes-vanish.md new file mode 100644 index 0000000000..1d6e6aefeb --- /dev/null +++ b/.changeset/orange-taxes-vanish.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +Omnigraph: The Domain interface now exposes `Domain.registry` and `Domain.subregistry` rather than being isolated to the concrete ENSv2 Domain entity, as in the unified model both ENSv1 and ENSv2 Domains have a parent and child Registry. From 8554acc185badc4d34a82f0635e468e89eb63509 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:18:56 -0500 Subject: [PATCH 05/15] fix: move subregistry/registry to domain interface --- .../lib/get-registry-parent-domain.ts | 14 ++++ .../omnigraph-api/schema/domain-canonical.ts | 14 ++-- .../ensapi/src/omnigraph-api/schema/domain.ts | 65 +++++++++---------- 3 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts b/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts new file mode 100644 index 0000000000..6b0d0d07ae --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts @@ -0,0 +1,14 @@ +import type { DomainId, RegistryId } from "enssdk"; + +import { ensDb } from "@/lib/ensdb/singleton"; + +/** + * Returns the Registry's parent DomainId, if known. + */ +export async function getRegistryParentDomain(registryId: RegistryId): Promise { + const registry = await ensDb.query.registry.findFirst({ + where: (t, { eq }) => eq(t.id, registryId), + columns: { canonicalDomainId: true }, + }); + return registry?.canonicalDomainId ?? null; +} diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index 186f38d213..349ef596e4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -7,9 +7,7 @@ import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); DomainCanonicalRef.implement({ - description: - "The materialized canonical-tree projection of a Canonical Domain — Canonical Name, " + - "leaf-to-root canonical path (as DomainIds), and namehash.", + description: "Canonicality metadata for a Domain, including its name, node (namehash), and path.", fields: (t) => ({ name: t.field({ description: "The Canonical Name for this Domain.", @@ -21,15 +19,16 @@ DomainCanonicalRef.implement({ `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, ); } + return domain.canonicalName; }, }), path: t.field({ description: - "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds.", + "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain.", type: [DomainInterfaceRef], nullable: false, - resolve: async (domain, _args, context) => { + resolve: async (domain, args, context) => { const canonicalPath = await context.loaders.canonicalPath.load(domain.id); if (canonicalPath instanceof Error) throw canonicalPath; if (canonicalPath === null) { @@ -37,11 +36,13 @@ DomainCanonicalRef.implement({ `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' produced null canonical path.`, ); } + return canonicalPath; }, }), node: t.field({ - description: "The namehash of this Domain's Canonical Name.", + description: + "The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`.", type: "Node", nullable: false, resolve: (domain) => { @@ -50,6 +51,7 @@ DomainCanonicalRef.implement({ `Invariant(DomainCanonical.node): canonical Domain '${domain.id}' is missing canonicalNode.`, ); } + return domain.canonicalNode; }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 2325b8a693..bb6df75a07 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -25,6 +25,7 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r import { getDomainResolver } from "@/omnigraph-api/lib/get-domain-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; +import { getRegistryParentDomain } from "@/omnigraph-api/lib/get-registry-parent-domain"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { @@ -85,13 +86,13 @@ export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); ////////////////////////////////// DomainInterfaceRef.implement({ description: - "A Domain represents an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.", + "A Domain represents an on-chain Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.", fields: (t) => ({ ///////////// // Domain.id ///////////// id: t.field({ - description: "A unique reference to this Domain.", + description: "A unique and stable reference to this Domain.", type: "DomainId", nullable: false, resolve: (parent) => parent.id, @@ -102,7 +103,7 @@ DomainInterfaceRef.implement({ //////////////// label: t.field({ type: LabelRef, - description: "The Label this Domain represents in the ENS Namegraph", + description: "The Label this Domain represents in the ENS Namegraph.", nullable: false, resolve: (parent) => parent.label, }), @@ -112,7 +113,7 @@ DomainInterfaceRef.implement({ //////////////////// canonical: t.field({ description: - "The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical.", + "Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical.", type: DomainCanonicalRef, nullable: true, resolve: (domain) => (domain.canonical ? domain : null), @@ -123,16 +124,10 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry).", + "The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain.", type: DomainInterfaceRef, nullable: true, - resolve: async (domain) => { - const registry = await ensDb.query.registry.findFirst({ - where: (t, { eq }) => eq(t.id, domain.registryId), - columns: { canonicalDomainId: true }, - }); - return registry?.canonicalDomainId ?? null; - }, + resolve: async (domain) => getRegistryParentDomain(domain.registryId), }), //////////////// @@ -146,12 +141,32 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.ownerId, }), + /////////////////// + // Domain.registry + /////////////////// + registry: t.field({ + description: "The Registry under which this Domain exists.", + type: RegistryInterfaceRef, + nullable: false, + resolve: (parent) => parent.registryId, + }), + + ////////////////////// + // Domain.subregistry + ////////////////////// + subregistry: t.field({ + type: RegistryInterfaceRef, + description: "The Registry this Domain declares as its Subregistry, if exists.", + nullable: true, + resolve: (parent) => parent.subregistryId, + }), + /////////////////// // Domain.resolver /////////////////// resolver: t.field({ description: - "The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10.", + "The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.", type: ResolverRef, nullable: true, resolve: (parent) => getDomainResolver(parent.id), @@ -248,7 +263,7 @@ DomainInterfaceRef.implement({ // ENSv1Domain Implementation ////////////////////////////// ENSv1DomainRef.implement({ - description: "An ENSv1Domain represents an ENSv1 Domain.", + description: "An ENSv1Domain represents an on-chain ENSv1 Domain.", interfaces: [DomainInterfaceRef], isTypeOf: (domain) => isENSv1Domain(domain as DomainInterface), fields: (t) => ({ @@ -279,7 +294,7 @@ ENSv1DomainRef.implement({ // ENSv2Domain Implementation ////////////////////////////// ENSv2DomainRef.implement({ - description: "An ENSv2Domain represents an ENSv2 Domain.", + description: "An ENSv2Domain represents an on-chain ENSv2 Domain.", interfaces: [DomainInterfaceRef], isTypeOf: (domain) => isENSv2Domain(domain as DomainInterface), fields: (t) => ({ @@ -293,26 +308,6 @@ ENSv2DomainRef.implement({ resolve: (parent) => parent.tokenId, }), - /////////////////////// - // ENSv2Domain.registry - /////////////////////// - registry: t.field({ - description: "The Registry under which this ENSv2Domain exists.", - type: RegistryInterfaceRef, - nullable: false, - resolve: (parent) => parent.registryId, - }), - - ////////////////////////// - // ENSv2Domain.subregistry - ////////////////////////// - subregistry: t.field({ - type: RegistryInterfaceRef, - description: "The Registry this ENSv2Domain declares as its Subregistry, if exists.", - nullable: true, - resolve: (parent) => parent.subregistryId, - }), - /////////////////////////// // ENSv2Domain.permissions /////////////////////////// From 28f68f7582362b4cad361f4acd13a2ecbed646e7 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:31:13 -0500 Subject: [PATCH 06/15] fix: update example queries to use interface domain.registry/subregistry and add integration test --- .../schema/domain.integration.test.ts | 80 +++++++++++++++++++ apps/ensapi/src/omnigraph-api/yoga.ts | 8 +- .../src/omnigraph-api/example-queries.ts | 7 +- .../src/omnigraph/generated/introspection.ts | 42 ++++++++++ .../src/omnigraph/generated/schema.graphql | 60 ++++++++------ 5 files changed, 162 insertions(+), 35 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index 73caad6c50..5943bfc4a3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,9 +1,17 @@ import { ADDR_REVERSE_NODE, + asInterpretedLabel, type DomainId, + ETH_NODE, type InterpretedLabel, type InterpretedName, + labelhashInterpretedLabel, makeENSv1DomainId, + makeENSv1RegistryId, + makeENSv1VirtualRegistryId, + makeENSv2DomainId, + makeENSv2RegistryId, + makeStorageId, } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; @@ -128,6 +136,78 @@ describe("Domain.canonical", () => { }); }); +describe("Domain.registry and Domain.subregistry", () => { + type DomainRegistriesResult = { + domain: { + registry: { __typename: string; id: string }; + subregistry: { __typename: string; id: string } | null; + } | null; + }; + + const DomainRegistries = gql` + query DomainRegistries($id: DomainId!) { + domain(by: { id: $id }) { + registry { __typename id } + subregistry { __typename id } + } + } + `; + + it("exposes parent and child Registries on the ENSv1 .eth Domain", async () => { + const v1RootRegistry = getDatasourceContract( + "ens-test-env", + DatasourceNames.ENSRoot, + "ENSv1Registry", + ); + const id = makeENSv1DomainId(v1RootRegistry, ETH_NODE); + + await expect(request(DomainRegistries, { id })).resolves.toMatchObject({ + domain: { + registry: { + __typename: "ENSv1Registry", + id: makeENSv1RegistryId(v1RootRegistry), + }, + subregistry: null, + // TODO: The DevNet should in the future have some ENSv1 domains that are then migrated, and then the .eth ENSv1 domain will have a subregistry. + // subregistry: { + // __typename: "ENSv1VirtualRegistry", + // id: makeENSv1VirtualRegistryId(v1RootRegistry, ETH_NODE), + // }, + }, + }); + }); + + it("exposes parent and child Registries on the ENSv2 .eth Domain", async () => { + const v2RootRegistry = getDatasourceContract( + "ens-test-env", + DatasourceNames.ENSv2Root, + "RootRegistry", + ); + const v2EthRegistry = getDatasourceContract( + "ens-test-env", + DatasourceNames.ENSv2Root, + "ETHRegistry", + ); + const id = makeENSv2DomainId( + v2RootRegistry, + makeStorageId(labelhashInterpretedLabel(asInterpretedLabel("eth"))), + ); + + await expect(request(DomainRegistries, { id })).resolves.toMatchObject({ + domain: { + registry: { + __typename: "ENSv2Registry", + id: makeENSv2RegistryId(v2RootRegistry), + }, + subregistry: { + __typename: "ENSv2Registry", + id: makeENSv2RegistryId(v2EthRegistry), + }, + }, + }); + }); +}); + describe("Domain.subdomains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts index 528e80546d..dd13148fa0 100644 --- a/apps/ensapi/src/omnigraph-api/yoga.ts +++ b/apps/ensapi/src/omnigraph-api/yoga.ts @@ -26,12 +26,8 @@ export const yoga = createYoga({ label owner { address } registration { expiry } - ... on ENSv1Domain { - parent { label } - } - ... on ENSv2Domain { - registry { contract {chainId address}} - } + parent { label } + registry { contract { chainId address } } } } } diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 338d09accd..d0e6f226a7 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -106,16 +106,11 @@ query DomainByName($name: InterpretedName!) { label { interpreted hash } canonical { name node path } owner { address } + subregistry { contract { chainId address } } ... on ENSv1Domain { rootRegistryOwner { address } } - - ... on ENSv2Domain { - subregistry { - contract { chainId address } - } - } } }`, variables: { diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b8d1bb9ed0..0f1fbae42e 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1192,6 +1192,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "registry", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Registry" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -1252,6 +1264,15 @@ const introspection = { } ], "isDeprecated": false + }, + { + "name": "subregistry", + "type": { + "kind": "INTERFACE", + "name": "Registry" + }, + "args": [], + "isDeprecated": false } ], "interfaces": [], @@ -1834,6 +1855,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "registry", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Registry" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -1903,6 +1936,15 @@ const introspection = { } ], "isDeprecated": false + }, + { + "name": "subregistry", + "type": { + "kind": "INTERFACE", + "name": "Registry" + }, + "args": [], + "isDeprecated": false } ], "interfaces": [ diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 4f54c54cd2..caa71f2a48 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -213,21 +213,21 @@ scalar ChainId scalar CoinType """ -A Domain represents an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. +A Domain represents an on-chain Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ interface Domain { """ - The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. """ canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection - """A unique reference to this Domain.""" + """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph""" + """The Label this Domain represents in the ENS Namegraph.""" label: Label! """ @@ -236,7 +236,7 @@ interface Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). + The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. """ parent: Domain @@ -246,8 +246,11 @@ interface Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection + """The Registry under which this Domain exists.""" + registry: Registry! + """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. """ resolver: Resolver @@ -255,20 +258,25 @@ interface Domain { All Domains that are direct descendents of this Domain in the namegraph. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection + + """The Registry this Domain declares as its Subregistry, if exists.""" + subregistry: Registry } """ -The materialized canonical-tree projection of a Canonical Domain — Canonical Name, leaf-to-root canonical path (as DomainIds), and namehash. +Canonicality metadata for a Domain, including its name, node (namehash), and path. """ type DomainCanonical { """The Canonical Name for this Domain.""" name: InterpretedName! - """The namehash of this Domain's Canonical Name.""" + """ + The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`. + """ node: Node! """ - The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. Returned as DomainIds. + The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. """ path: [Domain!]! } @@ -354,20 +362,20 @@ enum ENSProtocolVersion { ENSv2 } -"""An ENSv1Domain represents an ENSv1 Domain.""" +"""An ENSv1Domain represents an on-chain ENSv1 Domain.""" type ENSv1Domain implements Domain { """ - The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. """ canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection - """A unique reference to this Domain.""" + """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph""" + """The Label this Domain represents in the ENS Namegraph.""" label: Label! """The namehash of this ENSv1 Domain.""" @@ -379,7 +387,7 @@ type ENSv1Domain implements Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). + The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. """ parent: Domain @@ -389,8 +397,11 @@ type ENSv1Domain implements Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection + """The Registry under which this Domain exists.""" + registry: Registry! + """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. """ resolver: Resolver @@ -403,6 +414,9 @@ type ENSv1Domain implements Domain { All Domains that are direct descendents of this Domain in the namegraph. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection + + """The Registry this Domain declares as its Subregistry, if exists.""" + subregistry: Registry } """ @@ -460,20 +474,20 @@ type ENSv1VirtualRegistry implements Registry { permissions: Permissions } -"""An ENSv2Domain represents an ENSv2 Domain.""" +"""An ENSv2Domain represents an on-chain ENSv2 Domain.""" type ENSv2Domain implements Domain { """ - The materialized canonical-tree projection of this Domain (Canonical Name, leaf-to-root canonical path, and namehash). Null when the Domain is not Canonical. + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. """ canonical: DomainCanonical """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection - """A unique reference to this Domain.""" + """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph""" + """The Label this Domain represents in the ENS Namegraph.""" label: Label! """ @@ -482,7 +496,7 @@ type ENSv2Domain implements Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph (`Domain.registryId` → `Registry.canonicalDomainId`). No edge-authentication check; available for canonical and non-canonical Domains alike. Null when the parent Registry has no canonical Domain set (e.g., a root Registry). + The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. """ parent: Domain @@ -497,11 +511,11 @@ type ENSv2Domain implements Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection - """The Registry under which this ENSv2Domain exists.""" + """The Registry under which this Domain exists.""" registry: Registry! """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. """ resolver: Resolver @@ -510,7 +524,7 @@ type ENSv2Domain implements Domain { """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection - """The Registry this ENSv2Domain declares as its Subregistry, if exists.""" + """The Registry this Domain declares as its Subregistry, if exists.""" subregistry: Registry """The ENSv2Domain's current Token Id.""" From 26d2fab1399ded29512b7c284c9088d41b8ec74d Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 12:59:15 -0500 Subject: [PATCH 07/15] update docs in canonicality helpers --- .../src/lib/ensv2/canonicality-db-helpers.ts | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 03c0bcea4e..c21e30214a 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -6,6 +6,7 @@ import type { DomainId, InterpretedName, LabelHash, + LabelHashPath, NormalizedAddress, RegistryId, } from "enssdk"; @@ -23,7 +24,9 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * upon canonical edges. A canonical edge between Registry R and parent Domain D requires both * unidirectional pointers to agree (`R.canonicalDomainId = D.id` ↔ `D.subregistryId = R.id`) * AND for D itself to be canonical. Root Registries (ENSv1 root, ENSv2 root) are canonical by - * definition and seeded as Canonical at `ensureRegistry`-time. + * definition and seeded as Canonical at `ensureRegistry`-time. This definition of canonicality is + * taken from ENSv2 and then applied to ENSv1, so naturally some ENSv1 Domains will not be considered + * canonical, since ENSv1 didn't necessitate the same concept. * * Concretely, this means a v1 Domain with a Bridged Resolver leaves its v1 children as non-canonical. * Example: mainnet `linea.eth` has a Bridged Resolver pointing at the Linea Chain's `linea.eth` @@ -33,10 +36,11 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * ENSv1VirtualRegistry agreement fails for `bridge.linea.eth` and it stays non-canonical (and so do * its theoretical children). Only the Linea-side `bridge.linea.eth` (the resolution-visible one) is * canonical. This mirrors the ENSv2 definition of Canonicality (nameability is derived from having - * the Root Registry as the oldest ancestor). + * the Root Registry as the oldest ancestor). This is an acceptable limitation to enforce the purity + * and universality of the following canonicality logic. * * The unidirectional pointers `Registry.canonicalDomainId` and `Domain.subregistryId` are written - * blindly when their onchain events fire, to store the on-chain state as-is. Edge authentication + * directly when their onchain events fire, to store the on-chain state as-is. Edge authentication * is done on-demand at query time. The boolean flags `Registry.canonical` and `Domain.canonical` * are materialized from membership in the canonical nametree, and are kept up-to-date by reconciling * during updates to the uni-directional pointers. @@ -44,7 +48,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade * the change through the canonical subgraph beneath it. Two paths: * - If the Registry has no descendants (`Registry.__hasChildren = false`, the dominant case - * for fresh ENSv1 virtual registries on first wire-up), the cascade is a single-row flag + * for fresh ENSv1 virtual registries on creation), the cascade is a single-row flag * flip done via an in-memory PK update. No raw SQL, no flush. This optimization no-ops * the expensive reconciliation CTE for all ENSv1 Domains. * - Otherwise, a single recursive-CTE batch UPDATE walks the canonical subgraph via the @@ -93,6 +97,7 @@ export async function ensureDomainInRegistry( // 2. `cascadeCanonicality` flips every descendant (canonical + the three materialized fields) // whenever a Registry's `canonical` flag flips, so re-runs of `ensureDomainInRegistry` // against an existing row always observe the row already in sync with the Registry. + // NOTE: this is the fast-path for ENSv1 Domains, where we avoid the UPDATE CTE if (registry.canonical) { // Invariant: callers ensure the Label row (via ensureLabel / ensureUnknownLabel) before this // function. The Label is required to materialize `canonicalName`. @@ -109,15 +114,20 @@ export async function ensureDomainInRegistry( ? await context.ensDb.find(ensIndexerSchema.domain, { id: registry.canonicalDomainId }) : null; - const canonicalLabelHashPath: LabelHash[] = [ + // construct the Canonical LabelHashPath + const canonicalLabelHashPath: LabelHashPath = [ labelHash, ...(parentDomain?.canonicalLabelHashPath ?? []), ]; + + // construct the Canonical Name const canonicalName = ( parentDomain?.canonicalName ? `${label.interpreted}.${parentDomain.canonicalName}` : label.interpreted ) as InterpretedName; + + // construct the Canonical Node const canonicalNode = namehashLabelHashPath(canonicalLabelHashPath); await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ @@ -157,9 +167,8 @@ export async function handleRegistryCanonicalDomainUpdated( ); } - const prevCanonicalDomainId = registry.canonicalDomainId ?? null; - // if this Registry's Canonical Domain isn't changing, no-op + const prevCanonicalDomainId = registry.canonicalDomainId ?? null; if (prevCanonicalDomainId === nextCanonicalDomainId) return; // set/unset the Registry's Canonical Domain (uni-directional Registry → Domain link) @@ -188,9 +197,8 @@ export async function handleSubregistryUpdated( throw new Error(`Invariant(handleSubregistryUpdated): Domain ${domainId} does not yet exist.`); } - const prevSubregistryId = domain.subregistryId; - // if the Subregistry isn't changing, no-op + const prevSubregistryId = domain.subregistryId; if (prevSubregistryId === nextSubregistryId) return; // set/unset the Domain's Subregistry (uni-directional Domain → Registry link) @@ -297,16 +305,17 @@ async function reconcileRegistryCanonicality( context: IndexingEngineContext, registryId: RegistryId, ): Promise { + // hilariously, we need to guard against any random Registry setting its Subregistry to the Root + // Registry, which would otherwise un-canonicalize the whole Canonical Nametree. because the + // Root Registries are always canonical, they never need reconciliation; no-op + if (isRootRegistryId(config.namespace, registryId)) return; + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); if (!registry) return; // determine the new canonicality from the current pointer state let nextCanonical: boolean; - if (isRootRegistryId(config.namespace, registryId)) { - // root registries are canonical by axiom (no parent edge to derive canonicality from); guard - // against any pointer-write event spuriously flipping the flag off - nextCanonical = true; - } else if (!registry.canonicalDomainId) { + if (!registry.canonicalDomainId) { nextCanonical = false; } else { const parentDomain = await context.ensDb.find(ensIndexerSchema.domain, { @@ -340,7 +349,8 @@ async function reconcileRegistryCanonicality( * * Selectivity comes from the GIN index `byCanonicalLabelHashPath` on `canonical_label_hash_path`. * Note: GIN indexes are applied at realtime by Ponder, not during backfill — backfill-time heal - * cascades degenerate to a sequential scan; see specs/materialized-name.md for cost analysis. + * cascades degenerate to a sequential scan; re-asses whether a lookup table would help, or perhaps + * introduce a non-ponder-managed index on this column via eventHandlerPreconditions. */ export async function cascadeLabelHeal( context: IndexingEngineContext, @@ -377,8 +387,11 @@ export async function cascadeLabelHeal( * The `IS DISTINCT FROM` filter skips rows already at the target value (the start registry's * flag is set in the same statement, and any descendants that happen to already be consistent * are no-op'd). + * + * Because a canonicalization update may affect an unbounded number of objects in the tree, we + * batch the subsequent updates to at least buffer the severity of this operation. */ -const CANONICAL_NODE_BATCH_SIZE = 10_000; +const CANONICAL_NODE_UPDATE_BATCH_SIZE = 10_000; async function cascadeCanonicality( context: IndexingEngineContext, @@ -452,11 +465,17 @@ async function cascadeCanonicality( // Phase B: when flipping to canonical, compute and write `canonical_node` per affected Domain. // When flipping to non-canonical, Phase A already nulled `canonical_node` — nothing to do. + // NOTE: this is necessary because there's no labelhash-aware namehash fn in postgres + // TODO: perhaps we could add a namehash fn in eventHandlerPreconditions and collapse this into + // a single update? + // NOTE: this step could be avoided if we didn't need to materialize the Canonical Node — if not + // necessary, we can simply rip it out. currently implied as necessary by + // https://github.com/namehash/ensnode/issues/1962 if (!nextCanonical) return; - const rows = changed.rows as { id: DomainId; canonical_label_hash_path: LabelHash[] }[]; - for (let i = 0; i < rows.length; i += CANONICAL_NODE_BATCH_SIZE) { - const batch = rows.slice(i, i + CANONICAL_NODE_BATCH_SIZE); + const rows = changed.rows as { id: DomainId; canonical_label_hash_path: LabelHashPath }[]; + for (let i = 0; i < rows.length; i += CANONICAL_NODE_UPDATE_BATCH_SIZE) { + const batch = rows.slice(i, i + CANONICAL_NODE_UPDATE_BATCH_SIZE); const ids = batch.map((r) => r.id); const nodes = batch.map((r) => namehashLabelHashPath(r.canonical_label_hash_path)); From b97403d28c80ed37a2b3d32dcebd1016460b7f8b Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 13:45:30 -0500 Subject: [PATCH 08/15] fix: no-op bug in reconciliation --- .../src/lib/ensv2/canonicality-db-helpers.ts | 81 ++++++++++++++----- .../src/lib/ensv2/label-db-helpers.ts | 8 +- .../src/lib/ensv2/namehash-label-hash-path.ts | 13 ++- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 57 +++++++------ .../src/ensindexer-abstract/ensv2.schema.ts | 3 +- 5 files changed, 99 insertions(+), 63 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index c21e30214a..bc711d7407 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -45,18 +45,25 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * are materialized from membership in the canonical nametree, and are kept up-to-date by reconciling * during updates to the uni-directional pointers. * - * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade - * the change through the canonical subgraph beneath it. Two paths: + * `reconcileRegistryCanonicality` cascades through the canonical subgraph beneath the Registry + * in two situations: (a) the Registry's `canonical` flag flipped, or (b) the Registry's canonical + * parent Domain identity changed while the flag stays canonical, which leaves descendants' + * materialized canonical-tree fields (`canonicalName`, `canonicalLabelHashPath`, `canonicalNode`) + * rooted at the previous parent's path and therefore stale. Situation (b) only arises when + * `Registry.canonicalDomainId` itself was updated (handled via `handleRegistryCanonicalDomainUpdated`); + * `handleSubregistryUpdated` cannot change which Domain is the canonical parent of a given Registry, + * only whether the existing pointer agrees. Two cascade paths: * - If the Registry has no descendants (`Registry.__hasChildren = false`, the dominant case * for fresh ENSv1 virtual registries on creation), the cascade is a single-row flag - * flip done via an in-memory PK update. No raw SQL, no flush. This optimization no-ops - * the expensive reconciliation CTE for all ENSv1 Domains. + * flip done via an in-memory PK update (only when the flag actually flipped — a parent-only + * change with no descendants has nothing to re-materialize). No raw SQL, no flush. This + * optimization no-ops the expensive reconciliation CTE for all ENSv1 Domains. * - Otherwise, a single recursive-CTE batch UPDATE walks the canonical subgraph via the * unidirectional pointers + inline agreement check, batch-updating every visited Registry * and its child Domains. This goes through `context.ensDb.sql`, which forces a Ponder cache * flush + invalidate. We accept that cost because it's bounded to Registries that have - * children AND whose canonicality actually flips — i.e. bridged-resolver attach/detach and - * ENSv2 reparenting on already-populated subtrees. + * children AND that need a cascade — i.e. bridged-resolver attach/detach and ENSv2 reparenting + * on already-populated subtrees. * * `__hasChildren` is a monotonic sentinel on `Registry` (false → true on the first child Domain * registered under it; never reset). See `ensureDomainInRegistry` for where it is flipped. @@ -176,9 +183,8 @@ export async function handleRegistryCanonicalDomainUpdated( .update(ensIndexerSchema.registry, { id: registryId }) .set({ canonicalDomainId: nextCanonicalDomainId }); - // the registry's pointer changed, so its canonical-edge agreement may have changed too — - // reconcile the registry's flag (which cascades through its descendants if it flips) - await reconcileRegistryCanonicality(context, registryId); + // the registry's pointer changed, so its canonical-edge agreement may have changed too + await reconcileRegistryCanonicality(context, registryId, prevCanonicalDomainId); } /** @@ -285,8 +291,8 @@ export async function handleBridgedResolverChange( /** * Recompute `Registry.canonical` from its current canonical-edge agreement and, if the flag - * flips, cascade the new value through the canonical subgraph beneath this Registry via a - * single recursive-CTE batch UPDATE. + * flips (or if the Registry remains canonical under a now-different parent), cascade through + * the canonical subgraph beneath this Registry via a single recursive-CTE batch UPDATE. * * Canonicality rule: * - Root Registries (ENSv1 root, ENSv2 root) are canonical by axiom. @@ -296,6 +302,15 @@ export async function handleBridgedResolverChange( * AND P.subregistryId = R.id // P points down to R (bidirectional agreement) * AND P.canonical // P itself is in the canonical nametree * + * `prevCanonicalDomainId` is the value of `Registry.canonicalDomainId` before whatever mutation + * prompted this reconcile. Callers that wrote a new value to that pointer + * (`handleRegistryCanonicalDomainUpdated`) pass the overwritten value; callers that did not + * touch the pointer (`handleSubregistryUpdated`) pass the unchanged current value. Reconcile + * compares it against the current state to detect parent-identity changes: when the pointer + * swings from one canonical-agreeing parent to another, the flag stays true but descendants' + * materialized `canonicalName` / `canonicalLabelHashPath` / `canonicalNode` are rooted at the + * prior parent and must be re-materialized from the new parent's path. + * * Termination of the cascade walk relies on the canonical subgraph being a tree: each Registry * has at most one canonical parent Domain (enforced by the bidirectional agreement check), so * the recursive CTE cannot revisit a node. If that invariant is ever violated and a cycle is @@ -304,6 +319,7 @@ export async function handleBridgedResolverChange( async function reconcileRegistryCanonicality( context: IndexingEngineContext, registryId: RegistryId, + prevCanonicalDomainId?: DomainId | null, ): Promise { // hilariously, we need to guard against any random Registry setting its Subregistry to the Root // Registry, which would otherwise un-canonicalize the whole Canonical Nametree. because the @@ -311,6 +327,8 @@ async function reconcileRegistryCanonicality( if (isRootRegistryId(config.namespace, registryId)) return; const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + // if there's no registry, we can no-op; this reconciliation is a no-op if a Domain has set a + // Subregistry that doesn't exist yet if (!registry) return; // determine the new canonicality from the current pointer state @@ -325,15 +343,30 @@ async function reconcileRegistryCanonicality( parentDomain != null && parentDomain.subregistryId === registryId && parentDomain.canonical; } - // if the canonicality flag isn't changing, no-op (no cascade, no flush) - if (registry.canonical === nextCanonical) return; + // cascade materializations if + // a) canonicality changed or + // b) relative location in the tree changed and the subtree will become canonical + // NOTE: that guarding the relative location change upon whether the registry becomes canonical + // allows us to avoid the cascade if a Registry just changes its parent from one non-canonical + // Domain to another + const canonicalityChanged = registry.canonical !== nextCanonical; + const canonicalDomainChanged = + prevCanonicalDomainId !== undefined && // only consider changed if set + prevCanonicalDomainId !== (registry.canonicalDomainId ?? null); // is changed if prev != next + + const needsMaterialization = canonicalityChanged || (nextCanonical && canonicalDomainChanged); + + // no-op if no update necessary + if (!needsMaterialization) return; if (registry.__hasChildren) { - // if the Registry has children, we use the CTE to bulk-update canonicality for this entire subtree + // bulk-update this subtree via the CTE; its WHERE clause detects flag-flip rows and + // stale-path rows alike, so no per-row hint is needed await cascadeCanonicality(context, registryId, nextCanonical); - } else { - // if Registry has no descendants, we can just update its own canonicality using ponder cache - // (this is the ENSv1 fast-path) + } else if (canonicalityChanged) { + // no descendants and the flag actually flipped: update only this Registry's own flag using + // the Ponder cache (ENSv1 fast-path). A parent-only change with no descendants has nothing + // to re-materialize, so we skip the write entirely. await context.ensDb .update(ensIndexerSchema.registry, { id: registryId }) .set({ canonical: nextCanonical }); @@ -384,9 +417,12 @@ export async function cascadeLabelHeal( * `namehashLabelHashPath` over each row's `canonical_label_hash_path`. Only runs when * `nextCanonical = true`; when flipping to false, Phase A already nulled `canonical_node`. * - * The `IS DISTINCT FROM` filter skips rows already at the target value (the start registry's - * flag is set in the same statement, and any descendants that happen to already be consistent - * are no-op'd). + * The Registry UPDATE's `IS DISTINCT FROM` filter skips rows already at the target value (the + * start registry's flag is set in the same statement, and any descendants that happen to already + * be consistent are no-op'd). The Domain UPDATE's WHERE filter touches a row when either its + * flag flipped OR (when staying canonical) its `canonicalLabelHashPath` differs from the + * freshly-computed path — this second clause handles the parent-identity-changed case where the + * flag stays canonical but materialized paths are stale. * * Because a canonicalization update may affect an unbounded number of objects in the tree, we * batch the subsequent updates to at least buffer the severity of this operation. @@ -459,7 +495,10 @@ async function cascadeCanonicality( canonical_node = NULL FROM domain_targets dt WHERE d.id = dt.domain_id - AND d.canonical IS DISTINCT FROM ${nextCanonical} + AND ( + d.canonical IS DISTINCT FROM ${nextCanonical} + OR (${nextCanonical} AND d.canonical_label_hash_path IS DISTINCT FROM dt.new_path) + ) RETURNING d.id, d.canonical_label_hash_path; `); diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts index c437a94d77..9959436d9a 100644 --- a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts @@ -28,8 +28,7 @@ export async function ensureLabel(context: IndexingEngineContext, label: Literal const labelHash = labelhashLiteralLabel(label); const interpreted = literalLabelToInterpretedLabel(label); - // Read prior value to detect heal-upgrades (encoded labelhash → real label, or any change). - // No row → first time we've seen this labelHash, so no existing canonical Domain references it. + // TODO: this re-implements labelExists, can likely DRY it up const prev = await context.ensDb.find(ensIndexerSchema.label, { labelHash }); await context.ensDb @@ -37,9 +36,8 @@ export async function ensureLabel(context: IndexingEngineContext, label: Literal .values({ labelHash, interpreted }) .onConflictDoUpdate({ interpreted }); - if (prev && prev.interpreted !== interpreted) { - await cascadeLabelHeal(context, labelHash); - } + // if this was a heal (was previous seen, now updated), cascade the update to the materialized names + if (prev && prev.interpreted !== interpreted) await cascadeLabelHeal(context, labelHash); } /** diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts index 0d2e8fba9e..4011de71e8 100644 --- a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts @@ -2,22 +2,21 @@ import { encodeLabelHash, type InterpretedLabel, interpretedLabelsToInterpretedName, - type LabelHash, + type LabelHashPath, type Node, namehashInterpretedName, } from "enssdk"; /** - * Namehash a leaf-first labelHash path (i.e. `Domain.canonicalLabelHashPath`) by encoding each - * labelHash as an EncodedLabelHash, joining into an InterpretedName, then namehashing. + * Namehash a LabelHashPath. * - * Used to derive `Domain.canonicalNode` from `Domain.canonicalLabelHashPath`. Robust to label - * heals — the namehash is over labelHashes, not interpreted labels. + * TODO: this could more accurately perform the namehash algorithm over the LabelHashes directly + * but we use this simple approach for now */ -export function namehashLabelHashPath(labelHashPath: LabelHash[]): Node { +export function namehashLabelHashPath(labelHashPath: LabelHashPath): Node { return namehashInterpretedName( interpretedLabelsToInterpretedName( - labelHashPath.map((lh) => encodeLabelHash(lh) as unknown as InterpretedLabel), + labelHashPath.reverse().map((lh) => encodeLabelHash(lh) as string as InterpretedLabel), ), ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 864b5e000b..91110f9adc 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -74,6 +74,34 @@ 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; + // Label Healing + // + // only attempt to heal label if it doesn't already exist + const exists = await labelExists(context, labelHash); + if (!exists) { + // If this is a direct subname of addr.reverse, we have 100% on-chain label discovery. + // + // Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse` + // subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root + // chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19 + // CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in + // the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted + // with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse). + if ( + parentNode === ADDR_REVERSE_NODE && + context.chain.id === getENSRootChainId(config.namespace) && + // Sepolia V2 Tenderly Private RPC is rate-limiting the debug_traceTransaction calls so we + // avoid addr.reverse healing for that namespace so indexing progresses smoothly + // TODO: remove this once Sepolia V2 is decomissioned + config.namespace !== ENSNamespaceIds.SepoliaV2 + ) { + const label = await healAddrReverseSubnameLabel(context, event, labelHash); + await ensureLabel(context, label); + } else { + await ensureUnknownLabel(context, labelHash); + } + } + // NOTE: 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 @@ -127,35 +155,6 @@ export default function () { }) .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); - // Label Healing — must run before `ensureDomainInRegistry` so the Label row exists when the - // canonical-tree materializer reads it. - // - // only attempt to heal label if it doesn't already exist - const exists = await labelExists(context, labelHash); - if (!exists) { - // If this is a direct subname of addr.reverse, we have 100% on-chain label discovery. - // - // Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse` - // subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root - // chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19 - // CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in - // the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted - // with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse). - if ( - parentNode === ADDR_REVERSE_NODE && - context.chain.id === getENSRootChainId(config.namespace) && - // Sepolia V2 Tenderly Private RPC is rate-limiting the debug_traceTransaction calls so we - // avoid addr.reverse healing for that namespace so indexing progresses smoothly - // TODO: remove this once Sepolia V2 is decomissioned - config.namespace !== ENSNamespaceIds.SepoliaV2 - ) { - const label = await healAddrReverseSubnameLabel(context, event, labelHash); - await ensureLabel(context, label); - } else { - await ensureUnknownLabel(context, labelHash); - } - } - await ensureDomainInRegistry(context, parentRegistryId, domainId, labelHash); // push event to domain history diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 37567c1e5f..11dd03c996 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -4,6 +4,7 @@ import type { InterpretedLabel, InterpretedName, LabelHash, + LabelHashPath, Node, NormalizedAddress, PermissionsId, @@ -295,7 +296,7 @@ export const domain = onchainTable( // (all NULL iff `canonical = false`). Maintained inline by `canonicality-db-helpers.ts`. // `canonicalLabelHashPath` is leaf-first; `canonicalNode` is the namehash over the path. canonicalName: t.text().$type(), - canonicalLabelHashPath: t.hex().array().$type(), + canonicalLabelHashPath: t.hex().array().$type(), canonicalNode: t.hex().$type(), // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin From 604cb124d824437b150eb23f663885f3a153e8df Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 13:49:29 -0500 Subject: [PATCH 09/15] docs: update comment --- apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index bc711d7407..5b836b1d47 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -328,7 +328,8 @@ async function reconcileRegistryCanonicality( const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); // if there's no registry, we can no-op; this reconciliation is a no-op if a Domain has set a - // Subregistry that doesn't exist yet + // Subregistry that doesn't exist yet. once the subregistry exists and sets its Canonical Domain, + // handleCanonialDomainUpdated will trigger the appropriate reconciliation if (!registry) return; // determine the new canonicality from the current pointer state From c9e968bedd2042c1c76bea4f2eb9834cb145d935 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 13:54:24 -0500 Subject: [PATCH 10/15] fix: correct order of the resulting LabelHashPath --- .../src/lib/ensv2/canonicality-db-helpers.ts | 20 +++++++++++-------- .../ensv2/namehash-label-hash-path.test.ts | 8 ++++---- .../src/lib/ensv2/namehash-label-hash-path.ts | 5 ++++- .../src/ensindexer-abstract/ensv2.schema.ts | 4 +++- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 5b836b1d47..3925bdd34d 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -121,10 +121,10 @@ export async function ensureDomainInRegistry( ? await context.ensDb.find(ensIndexerSchema.domain, { id: registry.canonicalDomainId }) : null; - // construct the Canonical LabelHashPath + // construct the Canonical LabelHashPath (head-first traversal order: root → leaf) const canonicalLabelHashPath: LabelHashPath = [ - labelHash, ...(parentDomain?.canonicalLabelHashPath ?? []), + labelHash, ]; // construct the Canonical Name @@ -377,7 +377,9 @@ async function reconcileRegistryCanonicality( /** * Propagate a Label heal to every canonical Domain whose `canonicalLabelHashPath` contains * `labelHash`. Re-renders `canonical_name` by joining each path element to its current - * `label.interpreted` value (preserving leaf-first ordering via WITH ORDINALITY). + * `label.interpreted` value. `canonicalLabelHashPath` is head-first (root → leaf), but + * `canonicalName` is the standard leaf-first ENS string (e.g. "vitalik.eth"), so the + * WITH ORDINALITY rows are joined in DESC ordinal order. * * `canonicalLabelHashPath` and `canonicalNode` are untouched — label heals don't change labelHashes. * @@ -393,7 +395,7 @@ export async function cascadeLabelHeal( await context.ensDb.sql.execute(sql` UPDATE ${ensIndexerSchema.domain} AS d SET canonical_name = ( - SELECT string_agg(l.interpreted, '.' ORDER BY p.ord ASC) + SELECT string_agg(l.interpreted, '.' ORDER BY p.ord DESC) FROM unnest(d.canonical_label_hash_path) WITH ORDINALITY AS p(lh, ord) JOIN ${ensIndexerSchema.label} l ON l.label_hash = p.lh ) @@ -454,10 +456,12 @@ async function cascadeCanonicality( UNION -- step downward via the canonical-edge agreement, extending parent_path / parent_name by - -- the linking Domain's labelHash / interpreted label. + -- the linking Domain's labelHash / interpreted label. The path is head-first + -- (root → leaf), so we APPEND the labelHash; the name is the standard leaf-first ENS + -- string ("vitalik.eth"), so we PREPEND the interpreted label. SELECT child_reg.id, - ARRAY[d.label_hash] || w.parent_path, + w.parent_path || ARRAY[d.label_hash], COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) FROM walk w JOIN ${ensIndexerSchema.domain} d @@ -471,10 +475,10 @@ async function cascadeCanonicality( domain_targets AS ( -- for each Registry in the walk, enumerate ALL of its child Domains (regardless of whether -- they themselves have a canonical-agreeing subregistry) and project the materialized - -- path / name. + -- path / name. Head-first path → APPEND labelHash; leaf-first name → PREPEND interpreted label. SELECT d.id AS domain_id, - ARRAY[d.label_hash] || w.parent_path AS new_path, + w.parent_path || ARRAY[d.label_hash] AS new_path, COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) AS new_name FROM walk w JOIN ${ensIndexerSchema.domain} d diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts index 45048468c9..7fa5a556dc 100644 --- a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.test.ts @@ -18,9 +18,8 @@ describe("namehashLabelHashPath", () => { expect(namehashLabelHashPath([eth])).toBe(namehashInterpretedName("eth" as InterpretedName)); }); - it("namehashes a leaf-first path equivalent to dot-joining the labels", () => { - // Path is leaf-first: ["wallet", "sub1", "sub2", "parent", "eth"] - const labels = ["wallet", "sub1", "sub2", "parent", "eth"]; + it("namehashes a head-first path equivalent to namehashing the dot-joined leaf-first name", () => { + const labels = ["eth", "parent", "sub2", "sub1", "wallet"]; const path = labels.map(labelHashOf); expect(namehashLabelHashPath(path)).toBe( @@ -34,7 +33,8 @@ describe("namehashLabelHashPath", () => { "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as LabelHash; const eth = labelHashOf("eth"); - expect(namehashLabelHashPath([unknown, eth])).toBe( + // head-first (root → leaf): [eth, unknown] represents `[].eth` + expect(namehashLabelHashPath([eth, unknown])).toBe( namehashInterpretedName(`[${unknown.slice(2)}].eth` as InterpretedName), ); }); diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts index 4011de71e8..4b97da69f0 100644 --- a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts @@ -10,13 +10,16 @@ import { /** * Namehash a LabelHashPath. * + * `LabelHashPath` is head-first (root → leaf), but ENS name strings are leaf-first + * ("vitalik.eth"), so we reverse before encoding each labelHash as `[]` and joining. + * * TODO: this could more accurately perform the namehash algorithm over the LabelHashes directly * but we use this simple approach for now */ export function namehashLabelHashPath(labelHashPath: LabelHashPath): Node { return namehashInterpretedName( interpretedLabelsToInterpretedName( - labelHashPath.reverse().map((lh) => encodeLabelHash(lh) as string as InterpretedLabel), + labelHashPath.toReversed().map((lh) => encodeLabelHash(lh) as string as InterpretedLabel), ), ); } diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 11dd03c996..a4340f6f98 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -294,7 +294,9 @@ export const domain = onchainTable( // Materialized canonical-tree fields. All three are set/cleared atomically with `canonical` // (all NULL iff `canonical = false`). Maintained inline by `canonicality-db-helpers.ts`. - // `canonicalLabelHashPath` is leaf-first; `canonicalNode` is the namehash over the path. + // `canonicalLabelHashPath` is head-first traversal order (root → leaf, per LabelHashPath); + // `canonicalName` is the standard leaf-first ENS string; `canonicalNode` is the namehash + // over the path. canonicalName: t.text().$type(), canonicalLabelHashPath: t.hex().array().$type(), canonicalNode: t.hex().$type(), From 38fca71a240edfe279cdca45be3955047472271c Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 12 May 2026 14:09:16 -0500 Subject: [PATCH 11/15] fix: further fix tests --- .../src/omnigraph-api/schema/domain.integration.test.ts | 8 ++++---- packages/ensnode-sdk/src/omnigraph-api/example-queries.ts | 2 +- packages/enssdk/src/omnigraph/module.integration.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index 5943bfc4a3..3462cdc181 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -75,20 +75,20 @@ describe("Domain.canonical", () => { canonical: { name: InterpretedName; node: string; - path: DomainId[]; + path: { id: DomainId }[]; } | null; } | null; }; const DomainCanonicalByName = gql` query DomainCanonicalByName($name: InterpretedName!) { - domain(by: { name: $name }) { id canonical { name node path } } + domain(by: { name: $name }) { id canonical { name node path { id } } } } `; const DomainCanonicalById = gql` query DomainCanonicalById($id: DomainId!) { - domain(by: { id: $id }) { id canonical { name node path } } + domain(by: { id: $id }) { id canonical { name node path { id } } } } `; @@ -115,7 +115,7 @@ describe("Domain.canonical", () => { domain: { canonical: { name: "wallet.sub1.sub2.parent.eth", - path: expect.arrayContaining([expect.any(String)]), + path: expect.arrayContaining([{ id: expect.any(String) }]), }, }, }); diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index d0e6f226a7..236925e17d 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -104,7 +104,7 @@ query DomainByName($name: InterpretedName!) { __typename id label { interpreted hash } - canonical { name node path } + canonical { name node path { id } } owner { address } subregistry { contract { chainId address } } diff --git a/packages/enssdk/src/omnigraph/module.integration.test.ts b/packages/enssdk/src/omnigraph/module.integration.test.ts index 2ead8f6bf8..05d101420d 100644 --- a/packages/enssdk/src/omnigraph/module.integration.test.ts +++ b/packages/enssdk/src/omnigraph/module.integration.test.ts @@ -33,7 +33,7 @@ describe("omnigraph module (integration)", () => { // the 'eth' domain should exist expect(result.data!.domain).toMatchObject({ id: expect.any(String), - name: "eth", + canonical: { name: "eth" }, owner: { address: expect.any(String) }, }); }); From 269e98f35794f84d9b06e6c08e473a3b2eb90086 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 08:35:01 -0500 Subject: [PATCH 12/15] fix: address PR review feedback (loop 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `Domain.resolver` → `Domain.assignedResolver` (breaking, omnigraph) - Add invariant guard in `ensureDomainInRegistry` when canonical parent lacks `canonicalName` - Fix typos: `decomissioned`, `handleCanonialDomainUpdated`, `re-asses` - Document parent N+1 in `getRegistryParentDomain` - Clarify JSDoc on `prevCanonicalDomainId` (omitted vs unchanged) - Doc tweaks: "canonical subgraph" → "canonical nametree"; remove "on-chain" qualifier from Domain descriptions - Tighten `Domain.canonical` and `Domain.label` descriptions per review - `domain.integration.test.ts`: use `await expect(...).resolves.toMatchObject({...})` - `SearchView.tsx`: skip non-canonical edges Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/assigned-resolver-rename.md | 5 ++ .../lib/get-registry-parent-domain.ts | 4 ++ .../schema/domain.integration.test.ts | 14 ++--- .../ensapi/src/omnigraph-api/schema/domain.ts | 18 +++---- .../schema/permissions.integration.test.ts | 12 ++--- .../schema/resolver.integration.test.ts | 10 ++-- .../find-events/event-pagination-queries.ts | 2 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 20 ++++--- .../src/lib/ensv2/namehash-label-hash-path.ts | 3 +- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 2 +- .../enskit-react-example/src/SearchView.tsx | 15 +++--- .../src/ensindexer-abstract/ensv2.schema.ts | 2 +- .../src/omnigraph-api/example-queries.ts | 2 +- .../src/omnigraph/generated/introspection.ts | 54 +++++++++---------- .../src/omnigraph/generated/schema.graphql | 48 ++++++++--------- 15 files changed, 115 insertions(+), 96 deletions(-) create mode 100644 .changeset/assigned-resolver-rename.md diff --git a/.changeset/assigned-resolver-rename.md b/.changeset/assigned-resolver-rename.md new file mode 100644 index 0000000000..aa1ffb2bde --- /dev/null +++ b/.changeset/assigned-resolver-rename.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: Renamed `Domain.resolver` to `Domain.assignedResolver` for clarity. The field's semantics are unchanged — it remains the Domain's _assigned_ Resolver, not its _effective_ Resolver. diff --git a/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts b/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts index 6b0d0d07ae..1e5cba357e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts @@ -4,6 +4,10 @@ import { ensDb } from "@/lib/ensdb/singleton"; /** * Returns the Registry's parent DomainId, if known. + * + * TODO: this lookup is unbatched and runs once per Domain whose `parent` is selected. Resolving + * `parent` on a large list of Domains incurs N+1 round-trips. Reintroduce a DataLoader over + * `registryId` to batch these into a single fetch per GraphQL execution. */ export async function getRegistryParentDomain(registryId: RegistryId): Promise { const registry = await ensDb.query.registry.findFirst({ 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 3462cdc181..26269b618b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -96,8 +96,9 @@ describe("Domain.canonical", () => { "materializes canonical.{name, path, node} for '$name'", async ({ name, canonical }) => { const result = await request(DomainCanonicalByName, { name }); - expect(result.domain?.canonical).not.toBeNull(); - expect(result.domain!.canonical!.name).toBe(canonical); + expect(result).toMatchObject({ + domain: { canonical: { name: canonical } }, + }); expect(result.domain!.canonical!.path.length).toBe(canonical.split(".").length); }, ); @@ -129,10 +130,11 @@ describe("Domain.canonical", () => { ); const id = makeENSv1DomainId(v1RootRegistry, ADDR_REVERSE_NODE); - const result = await request(DomainCanonicalById, { id }); - expect(result.domain?.id).toBe(id); - expect(result.domain?.canonical).not.toBeNull(); - expect(result.domain!.canonical!.name).toBe("addr.reverse"); + await expect( + request(DomainCanonicalById, { id }), + ).resolves.toMatchObject({ + domain: { id, canonical: { name: "addr.reverse" } }, + }); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index bb6df75a07..9868d4cc36 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -86,7 +86,7 @@ export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); ////////////////////////////////// DomainInterfaceRef.implement({ description: - "A Domain represents an on-chain Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.", + "Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.", fields: (t) => ({ ///////////// // Domain.id @@ -103,7 +103,7 @@ DomainInterfaceRef.implement({ //////////////// label: t.field({ type: LabelRef, - description: "The Label this Domain represents in the ENS Namegraph.", + description: "The Label associated with this Domain in the ENS Namegraph.", nullable: false, resolve: (parent) => parent.label, }), @@ -113,7 +113,7 @@ DomainInterfaceRef.implement({ //////////////////// canonical: t.field({ description: - "Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical.", + "Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.", type: DomainCanonicalRef, nullable: true, resolve: (domain) => (domain.canonical ? domain : null), @@ -161,10 +161,10 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.subregistryId, }), - /////////////////// - // Domain.resolver - /////////////////// - resolver: t.field({ + /////////////////////////// + // Domain.assignedResolver + /////////////////////////// + assignedResolver: t.field({ description: "The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.", type: ResolverRef, @@ -263,7 +263,7 @@ DomainInterfaceRef.implement({ // ENSv1Domain Implementation ////////////////////////////// ENSv1DomainRef.implement({ - description: "An ENSv1Domain represents an on-chain ENSv1 Domain.", + description: "An ENSv1Domain represents an ENSv1 Domain.", interfaces: [DomainInterfaceRef], isTypeOf: (domain) => isENSv1Domain(domain as DomainInterface), fields: (t) => ({ @@ -294,7 +294,7 @@ ENSv1DomainRef.implement({ // ENSv2Domain Implementation ////////////////////////////// ENSv2DomainRef.implement({ - description: "An ENSv2Domain represents an on-chain ENSv2 Domain.", + description: "An ENSv2Domain represents an ENSv2 Domain.", interfaces: [DomainInterfaceRef], isTypeOf: (domain) => isENSv2Domain(domain as DomainInterface), fields: (t) => ({ diff --git a/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts index 9e3c9e0a67..5ab7edc20b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts @@ -284,7 +284,7 @@ describe("Resolver.permissions", () => { const ResolverPermissions = gql` query ResolverPermissions($name: InterpretedName!) { domain(by: { name: $name }) { - resolver { permissions { id contract { chainId address } } } + assignedResolver { permissions { id contract { chainId address } } } } } `; @@ -292,7 +292,7 @@ describe("Resolver.permissions", () => { it("resolves permissions from a resolver", async () => { const result = await request<{ domain: { - resolver: { + assignedResolver: { permissions: { id: PermissionsId; contract: AccountId; @@ -302,12 +302,12 @@ describe("Resolver.permissions", () => { }>(ResolverPermissions, { name: NAME_WITH_RESOLVER }); expect( - result.domain.resolver, + result.domain.assignedResolver, `expected ${NAME_WITH_RESOLVER} to have a resolver`, ).toBeDefined(); - expect(result.domain.resolver.permissions.id).toBeTruthy(); - expect(result.domain.resolver.permissions.contract.address).toBeTruthy(); - expect(result.domain.resolver.permissions.contract.chainId).toBeTruthy(); + expect(result.domain.assignedResolver.permissions.id).toBeTruthy(); + expect(result.domain.assignedResolver.permissions.contract.address).toBeTruthy(); + expect(result.domain.assignedResolver.permissions.contract.chainId).toBeTruthy(); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.integration.test.ts index e532dce946..298c5d1ce6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.integration.test.ts @@ -22,7 +22,7 @@ const DEVNET_NAME_WITH_OWNED_RESOLVER = asInterpretedName("example.eth"); describe("Resolver.events", () => { type ResolverEventsResult = { domain: { - resolver: { + assignedResolver: { events: GraphQLConnection; }; }; @@ -31,7 +31,7 @@ describe("Resolver.events", () => { const ResolverEvents = gql` query ResolverEvents($name: InterpretedName!) { domain(by: { name: $name }) { - resolver { + assignedResolver { events { edges { node { @@ -51,7 +51,7 @@ describe("Resolver.events", () => { name: DEVNET_NAME_WITH_OWNED_RESOLVER, }); - const events = flattenConnection(result.domain.resolver.events); + const events = flattenConnection(result.domain.assignedResolver.events); expect(events.length).toBeGreaterThan(0); }); @@ -60,8 +60,8 @@ describe("Resolver.events", () => { describe("Resolver.events pagination", () => { testEventPagination(async (variables) => { const result = await request<{ - domain: { resolver: { events: PaginatedGraphQLConnection } }; + domain: { assignedResolver: { events: PaginatedGraphQLConnection } }; }>(ResolverEventsPaginated, { name: DEVNET_NAME_WITH_OWNED_RESOLVER, ...variables }); - return result.domain.resolver.events; + return result.domain.assignedResolver.events; }); }); diff --git a/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts b/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts index e7fc735cfe..d836d2c224 100644 --- a/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts +++ b/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts @@ -96,7 +96,7 @@ export const ResolverEventsPaginated = gql` $before: String ) { domain(by: { name: $name }) { - resolver { + assignedResolver { events(first: $first, after: $after, last: $last, before: $before) { edges { cursor node { ...EventFragment } } pageInfo { ...PageInfoFragment } diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 3925bdd34d..3046afc5b0 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -45,7 +45,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * are materialized from membership in the canonical nametree, and are kept up-to-date by reconciling * during updates to the uni-directional pointers. * - * `reconcileRegistryCanonicality` cascades through the canonical subgraph beneath the Registry + * `reconcileRegistryCanonicality` cascades through the canonical nametree beneath the Registry * in two situations: (a) the Registry's `canonical` flag flipped, or (b) the Registry's canonical * parent Domain identity changed while the flag stays canonical, which leaves descendants' * materialized canonical-tree fields (`canonicalName`, `canonicalLabelHashPath`, `canonicalNode`) @@ -121,6 +121,14 @@ export async function ensureDomainInRegistry( ? await context.ensDb.find(ensIndexerSchema.domain, { id: registry.canonicalDomainId }) : null; + // If we found a canonical parent Domain, it must itself be materialized. Otherwise we'd + // silently store a truncated `canonicalName` (just `label.interpreted`) for a non-root Domain. + if (parentDomain && !parentDomain.canonicalName) { + throw new Error( + `Invariant(ensureDomainInRegistry): canonical parentDomain '${parentDomain.id}' is missing canonicalName.`, + ); + } + // construct the Canonical LabelHashPath (head-first traversal order: root → leaf) const canonicalLabelHashPath: LabelHashPath = [ ...(parentDomain?.canonicalLabelHashPath ?? []), @@ -305,7 +313,7 @@ export async function handleBridgedResolverChange( * `prevCanonicalDomainId` is the value of `Registry.canonicalDomainId` before whatever mutation * prompted this reconcile. Callers that wrote a new value to that pointer * (`handleRegistryCanonicalDomainUpdated`) pass the overwritten value; callers that did not - * touch the pointer (`handleSubregistryUpdated`) pass the unchanged current value. Reconcile + * touch the pointer (`handleSubregistryUpdated`) omit the argument (leaving it `undefined`). Reconcile * compares it against the current state to detect parent-identity changes: when the pointer * swings from one canonical-agreeing parent to another, the flag stays true but descendants' * materialized `canonicalName` / `canonicalLabelHashPath` / `canonicalNode` are rooted at the @@ -329,7 +337,7 @@ async function reconcileRegistryCanonicality( const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); // if there's no registry, we can no-op; this reconciliation is a no-op if a Domain has set a // Subregistry that doesn't exist yet. once the subregistry exists and sets its Canonical Domain, - // handleCanonialDomainUpdated will trigger the appropriate reconciliation + // handleRegistryCanonicalDomainUpdated will trigger the appropriate reconciliation if (!registry) return; // determine the new canonicality from the current pointer state @@ -385,7 +393,7 @@ async function reconcileRegistryCanonicality( * * Selectivity comes from the GIN index `byCanonicalLabelHashPath` on `canonical_label_hash_path`. * Note: GIN indexes are applied at realtime by Ponder, not during backfill — backfill-time heal - * cascades degenerate to a sequential scan; re-asses whether a lookup table would help, or perhaps + * cascades degenerate to a sequential scan; re-assess whether a lookup table would help, or perhaps * introduce a non-ponder-managed index on this column via eventHandlerPreconditions. */ export async function cascadeLabelHeal( @@ -405,12 +413,12 @@ export async function cascadeLabelHeal( } /** - * Walk the canonical subgraph rooted at `registryId` and set `canonical = nextCanonical` on + * Walk the canonical nametree rooted at `registryId` and set `canonical = nextCanonical` on * every Registry and Domain it visits, additionally materializing canonical-tree fields on * every affected Domain. * * Two phases: - * - Phase A is a single statement: one recursive CTE that enumerates the canonical subgraph + * - Phase A is a single statement: one recursive CTE that enumerates the canonical nametree * by following unidirectional pointers + agreement check, while carrying the partial * `parent_path` / `parent_name` accumulators down the tree. A data-modifying CTE batch-updates * Registry rows; the trailing UPDATE batch-updates Domain rows with the materialized diff --git a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts index 4b97da69f0..4ddb2da20a 100644 --- a/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts +++ b/apps/ensindexer/src/lib/ensv2/namehash-label-hash-path.ts @@ -13,8 +13,7 @@ import { * `LabelHashPath` is head-first (root → leaf), but ENS name strings are leaf-first * ("vitalik.eth"), so we reverse before encoding each labelHash as `[]` and joining. * - * TODO: this could more accurately perform the namehash algorithm over the LabelHashes directly - * but we use this simple approach for now + * TODO: optimize by performing the namehash algorithm over the LabelHashes directly */ export function namehashLabelHashPath(labelHashPath: LabelHashPath): Node { return namehashInterpretedName( diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 91110f9adc..b505c90bc0 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -92,7 +92,7 @@ export default function () { context.chain.id === getENSRootChainId(config.namespace) && // Sepolia V2 Tenderly Private RPC is rate-limiting the debug_traceTransaction calls so we // avoid addr.reverse healing for that namespace so indexing progresses smoothly - // TODO: remove this once Sepolia V2 is decomissioned + // TODO: remove this once Sepolia V2 is decommissioned config.namespace !== ENSNamespaceIds.SepoliaV2 ) { const label = await healAddrReverseSubnameLabel(context, event, labelHash); diff --git a/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index fdfa75affb..4128237af4 100644 --- a/examples/enskit-react-example/src/SearchView.tsx +++ b/examples/enskit-react-example/src/SearchView.tsx @@ -87,16 +87,17 @@ export function SearchView() { <> {fetching &&

    Loading...

    }
      - {data?.domains?.edges.map((edge) => ( -
    • - ({edge.node.__typename === "ENSv1Domain" ? "v1" : "v2"}){" "} - {edge.node.canonical && ( + {data?.domains?.edges.map((edge) => { + if (!edge.node.canonical) return null; + return ( +
    • + ({edge.node.__typename === "ENSv1Domain" ? "v1" : "v2"}){" "} {beautifyInterpretedName(edge.node.canonical.name)} - )} -
    • - ))} + + ); + })}
    {data?.domains && data.domains.edges.length === 0 && !fetching &&

    No matches.

    } {data?.domains?.pageInfo.hasNextPage && ( diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index a4340f6f98..a90f2e2e06 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -27,7 +27,7 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * While the initial approach was a highly materialized view of the ENS protocol, abstracting away * as many on-chain details as possible, in practice—due to the sheer complexity of the protocol at * resolution-time—full materialization of resolution behavior is impractical. The canonical - * subgraph, however, is materialized inline via synchronous handler-side cascades; see + * nametree, however, is materialized inline via synchronous handler-side cascades; see * `Domain.canonical*` fields and `canonicality-db-helpers.ts`. * * As a result, this schema takes a balanced approach. It mimics on-chain state as closely as possible, diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 236925e17d..f9241e13d4 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -329,7 +329,7 @@ query AccountResolverPermissions($address: Address!) { query: ` query DomainResolver($name: InterpretedName!) { domain(by: { name: $name }) { - resolver { + assignedResolver { records { edges { node { node keys coinTypes } } } permissions { resources { edges { node { resource users { edges { node { user { address } roles } } } } } } } events { totalCount edges { node { topics data timestamp } } } diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 0f1fbae42e..e6948b9c45 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1049,6 +1049,15 @@ const introspection = { "kind": "INTERFACE", "name": "Domain", "fields": [ + { + "name": "assignedResolver", + "type": { + "kind": "OBJECT", + "name": "Resolver" + }, + "args": [], + "isDeprecated": false + }, { "name": "canonical", "type": { @@ -1204,15 +1213,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "resolver", - "type": { - "kind": "OBJECT", - "name": "Resolver" - }, - "args": [], - "isDeprecated": false - }, { "name": "subdomains", "type": { @@ -1700,6 +1700,15 @@ const introspection = { "kind": "OBJECT", "name": "ENSv1Domain", "fields": [ + { + "name": "assignedResolver", + "type": { + "kind": "OBJECT", + "name": "Resolver" + }, + "args": [], + "isDeprecated": false + }, { "name": "canonical", "type": { @@ -1867,15 +1876,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "resolver", - "type": { - "kind": "OBJECT", - "name": "Resolver" - }, - "args": [], - "isDeprecated": false - }, { "name": "rootRegistryOwner", "type": { @@ -2264,6 +2264,15 @@ const introspection = { "kind": "OBJECT", "name": "ENSv2Domain", "fields": [ + { + "name": "assignedResolver", + "type": { + "kind": "OBJECT", + "name": "Resolver" + }, + "args": [], + "isDeprecated": false + }, { "name": "canonical", "type": { @@ -2464,15 +2473,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "resolver", - "type": { - "kind": "OBJECT", - "name": "Resolver" - }, - "args": [], - "isDeprecated": false - }, { "name": "subdomains", "type": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index caa71f2a48..a0ccebc94e 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -213,11 +213,16 @@ scalar ChainId scalar CoinType """ -A Domain represents an on-chain Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. +Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ interface Domain { """ - Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. + """ + assignedResolver: Resolver + + """ + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree. """ canonical: DomainCanonical @@ -227,7 +232,7 @@ interface Domain { """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph.""" + """The Label associated with this Domain in the ENS Namegraph.""" label: Label! """ @@ -249,11 +254,6 @@ interface Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. - """ - resolver: Resolver - """ All Domains that are direct descendents of this Domain in the namegraph. """ @@ -362,10 +362,15 @@ enum ENSProtocolVersion { ENSv2 } -"""An ENSv1Domain represents an on-chain ENSv1 Domain.""" +"""An ENSv1Domain represents an ENSv1 Domain.""" type ENSv1Domain implements Domain { """ - Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. + """ + assignedResolver: Resolver + + """ + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree. """ canonical: DomainCanonical @@ -375,7 +380,7 @@ type ENSv1Domain implements Domain { """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph.""" + """The Label associated with this Domain in the ENS Namegraph.""" label: Label! """The namehash of this ENSv1 Domain.""" @@ -400,11 +405,6 @@ type ENSv1Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. - """ - resolver: Resolver - """ The rootRegistryOwner of this Domain, i.e. the owner() of this Domain within the ENSv1 Registry. """ @@ -474,10 +474,15 @@ type ENSv1VirtualRegistry implements Registry { permissions: Permissions } -"""An ENSv2Domain represents an on-chain ENSv2 Domain.""" +"""An ENSv2Domain represents an ENSv2 Domain.""" type ENSv2Domain implements Domain { """ - Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not Canonical. + The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. + """ + assignedResolver: Resolver + + """ + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree. """ canonical: DomainCanonical @@ -487,7 +492,7 @@ type ENSv2Domain implements Domain { """A unique and stable reference to this Domain.""" id: DomainId! - """The Label this Domain represents in the ENS Namegraph.""" + """The Label associated with this Domain in the ENS Namegraph.""" label: Label! """ @@ -514,11 +519,6 @@ type ENSv2Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. - """ - resolver: Resolver - """ All Domains that are direct descendents of this Domain in the namegraph. """ From c73219cf48da2abfcb7148185aa274856fc7e766 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 08:43:11 -0500 Subject: [PATCH 13/15] fix: lint --- apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts | 1 - 1 file changed, 1 deletion(-) 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 26269b618b..91f335b72f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -8,7 +8,6 @@ import { labelhashInterpretedLabel, makeENSv1DomainId, makeENSv1RegistryId, - makeENSv1VirtualRegistryId, makeENSv2DomainId, makeENSv2RegistryId, makeStorageId, From 57523d5a30bd73838e2f938536d297024e52aa0d Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 08:46:57 -0500 Subject: [PATCH 14/15] fix: batch Domain.parent lookups via DataLoader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the unbatched `getRegistryParentDomain` per-domain query with a per-request `registryParentDomain` loader that uses `inArray` over the batched registry IDs — single round-trip per GraphQL execution regardless of how many Domains resolve `parent`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensapi/src/omnigraph-api/context.ts | 19 ++++++++++++++++++- .../lib/get-registry-parent-domain.ts | 18 ------------------ .../ensapi/src/omnigraph-api/schema/domain.ts | 4 ++-- 3 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index 18e37a8911..2c851477a8 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -1,6 +1,9 @@ import DataLoader from "dataloader"; import { getUnixTime } from "date-fns"; -import type { CanonicalPath, DomainId } from "enssdk"; +import { inArray } from "drizzle-orm"; +import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; + +import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { getCanonicalPath } from "./lib/get-canonical-path"; @@ -15,6 +18,19 @@ const createCanonicalPathLoader = () => Promise.all(domainIds.map((id) => getCanonicalPath(id).catch(errorAsValue))), ); +const createRegistryParentDomainLoader = () => + new DataLoader(async (registryIds) => { + const rows = await ensDb + .select({ + id: ensIndexerSchema.registry.id, + canonicalDomainId: ensIndexerSchema.registry.canonicalDomainId, + }) + .from(ensIndexerSchema.registry) + .where(inArray(ensIndexerSchema.registry.id, registryIds as RegistryId[])); + const byId = new Map(rows.map((r) => [r.id, r.canonicalDomainId ?? null])); + return registryIds.map((id) => byId.get(id) ?? null); + }); + /** * Constructs a new GraphQL Context per-request. * @@ -24,5 +40,6 @@ export const context = () => ({ now: BigInt(getUnixTime(new Date())), loaders: { canonicalPath: createCanonicalPathLoader(), + registryParentDomain: createRegistryParentDomainLoader(), }, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts b/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts deleted file mode 100644 index 1e5cba357e..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/get-registry-parent-domain.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { DomainId, RegistryId } from "enssdk"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -/** - * Returns the Registry's parent DomainId, if known. - * - * TODO: this lookup is unbatched and runs once per Domain whose `parent` is selected. Resolving - * `parent` on a large list of Domains incurs N+1 round-trips. Reintroduce a DataLoader over - * `registryId` to batch these into a single fetch per GraphQL execution. - */ -export async function getRegistryParentDomain(registryId: RegistryId): Promise { - const registry = await ensDb.query.registry.findFirst({ - where: (t, { eq }) => eq(t.id, registryId), - columns: { canonicalDomainId: true }, - }); - return registry?.canonicalDomainId ?? null; -} diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 9868d4cc36..03c2a1391f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -25,7 +25,6 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r import { getDomainResolver } from "@/omnigraph-api/lib/get-domain-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; -import { getRegistryParentDomain } from "@/omnigraph-api/lib/get-registry-parent-domain"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { @@ -127,7 +126,8 @@ DomainInterfaceRef.implement({ "The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain.", type: DomainInterfaceRef, nullable: true, - resolve: async (domain) => getRegistryParentDomain(domain.registryId), + resolve: async (domain, _args, context) => + context.loaders.registryParentDomain.load(domain.registryId), }), //////////////// From 371f4297c774aec3194f1684f1c72f9b16895989 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 09:39:46 -0500 Subject: [PATCH 15/15] fix: address loop-2 review feedback + docs/examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - Phase B Domain UPDATE: switch to multi-arg `unnest(ids, nodes)` for contractually-aligned row pairing (Copilot/VADE). - `domain_targets` CTE: add inline comment explaining why the agreement filter is intentionally omitted (canonical nametree is a strict tree). - `reconcileRegistryCanonicality`: document the no-op fall-through when `__hasChildren=false` and only parent identity changed (Greptile note). - `Domain.parent` description: call out new semantics — unidirectional walk; non-canonical Domains may have non-null parent. - `DomainCanonical.path`: TODO to derive from the materialized `canonicalLabelHashPath` instead of the dataloader walk. - Changeset notes the parent-semantics change. Docs / examples: - Update Omnigraph queries throughout the monorepo to use the new `canonical { name }` shape (`Domain.name` removed in this PR). - Touches README.md, packages/enssdk/README.md, integrate docs (index, enssdk, omnigraph-graphql-api), and both example apps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/ensapi-canonical-name.md | 2 ++ README.md | 2 +- .../omnigraph-api/schema/domain-canonical.ts | 3 +++ .../ensapi/src/omnigraph-api/schema/domain.ts | 2 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 16 ++++++++++---- .../src/content/docs/docs/integrate/index.mdx | 3 +-- .../integrate/integration-options/enssdk.mdx | 22 +++++++++---------- .../omnigraph-graphql-api.mdx | 10 ++++----- examples/enssdk-example/src/index.ts | 4 ++-- .../omnigraph-graphql-example/src/index.ts | 8 +++---- packages/enssdk/README.md | 2 +- .../src/omnigraph/generated/schema.graphql | 6 ++--- packages/enssdk/src/omnigraph/module.test.ts | 16 ++++++++------ 13 files changed, 55 insertions(+), 41 deletions(-) diff --git a/.changeset/ensapi-canonical-name.md b/.changeset/ensapi-canonical-name.md index 20fa96b440..5a75fbba2f 100644 --- a/.changeset/ensapi-canonical-name.md +++ b/.changeset/ensapi-canonical-name.md @@ -3,3 +3,5 @@ --- **Omnigraph (breaking)**: restructure `Domain.canonical` into a nullable `DomainCanonical` object. Removes top-level `Domain.canonical: Boolean!`, `Domain.name: InterpretedName`, and `Domain.path: [DomainInterface]`; adds `Domain.canonical: DomainCanonical` (null when the Domain is not Canonical) with subfields `{ name: InterpretedName!, path: [Domain!]!, node: Node! }`. + +**Omnigraph (semantic change)**: `Domain.parent` now follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement. Previously, `parent` was effectively null for non-canonical Domains and always pointed at a canonical Domain when non-null. With this change, a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Consumers that relied on `parent ⇒ canonical` should additionally check `domain.canonical`. diff --git a/README.md b/README.md index 12dc0e6d8b..4101b5a737 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ An indexer aggregates and reorganizes the representation of ENS's state to make query Domains($adress: String!) { domains(where: { owner: $address }) { id - name + canonical { name } ... } } diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index 349ef596e4..67b5850321 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -28,6 +28,9 @@ DomainCanonicalRef.implement({ "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain.", type: [DomainInterfaceRef], nullable: false, + // TODO: derive `path` from the materialized `canonicalLabelHashPath` column instead of + // walking the canonicalPath dataloader. Each ancestor's DomainId can be reconstructed from + // the path prefix and the parent Registry chain, then batched through `DomainInterfaceRef`. resolve: async (domain, args, context) => { const canonicalPath = await context.loaders.canonicalPath.load(domain.id); if (canonicalPath instanceof Error) throw canonicalPath; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 03c2a1391f..a4bab1a337 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -123,7 +123,7 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain.", + "The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain.", type: DomainInterfaceRef, nullable: true, resolve: async (domain, _args, context) => diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 3046afc5b0..7cf2713965 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -380,6 +380,9 @@ async function reconcileRegistryCanonicality( .update(ensIndexerSchema.registry, { id: registryId }) .set({ canonical: nextCanonical }); } + // else: `needsMaterialization` was true (`nextCanonical && canonicalDomainChanged`) but + // `__hasChildren = false` — the Registry stayed canonical under a new parent identity, and + // there are no descendants whose materialized fields could be stale. No write needed. } /** @@ -484,6 +487,14 @@ async function cascadeCanonicality( -- for each Registry in the walk, enumerate ALL of its child Domains (regardless of whether -- they themselves have a canonical-agreeing subregistry) and project the materialized -- path / name. Head-first path → APPEND labelHash; leaf-first name → PREPEND interpreted label. + -- + -- The agreement filter is intentionally omitted here. Membership in the canonical nametree + -- is determined per-Domain via Domain.canonical, and every Domain that belongs to a canonical + -- Registry inherits that Registry canonical flag (see ensureDomainInRegistry). When the + -- Registry flag flips (or its identity-as-parent changes), every Domain row under it must + -- follow — including ones whose own subregistryId does not agree, because those Domains + -- never seed a separate canonical subtree (the canonical nametree is a strict tree, enforced + -- by the bidirectional agreement check on the walk CTE). SELECT d.id AS domain_id, w.parent_path || ARRAY[d.label_hash] AS new_path, @@ -534,10 +545,7 @@ async function cascadeCanonicality( await context.ensDb.sql.execute(sql` UPDATE ${ensIndexerSchema.domain} AS d SET canonical_node = upd.canonical_node - FROM ( - SELECT unnest(${ids}::text[]) AS id, - unnest(${nodes}::text[]) AS canonical_node - ) upd + FROM unnest(${ids}::text[], ${nodes}::text[]) AS upd(id, canonical_node) WHERE d.id = upd.id; `); } diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/index.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/index.mdx index 9a9fdfe4b9..ed27281e8e 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/index.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/index.mdx @@ -50,7 +50,6 @@ const DomainFragment = graphql(` fragment DomainFragment on Domain { __typename id - name canonical { name } owner { id address } } @@ -142,7 +141,7 @@ const HelloWorldQuery = graphql(` query HelloWorld { domain(by: { name: "eth" }) { id - name + canonical { name } owner { address } } } diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk.mdx index 73266b0c44..b3c593f401 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk.mdx @@ -125,7 +125,7 @@ const HelloWorldQuery = graphql(` query HelloWorld($name: InterpretedName!) { domain(by: { name: $name }) { __typename - name + canonical { name } owner { address } } } @@ -144,7 +144,7 @@ async function main() { const { domain } = result.data; - console.log(`Name: ${domain.name ? beautifyInterpretedName(domain.name) : ""}`); + console.log(`Name: ${domain.canonical ? beautifyInterpretedName(domain.canonical.name) : ""}`); console.log(`Version: ${domain.__typename}`); console.log(`Owner: ${domain.owner?.address ?? "0x0"}`); } @@ -159,7 +159,7 @@ A few things to notice: - `graphql(...)` parses your query at typecheck time. Hover over `result.data` and you'll see it's typed exactly to your selection set — try removing `owner { address }` from the query and watch the access below become a type error. - `domain` is a union of `ENSv1Domain | ENSv2Domain` (both implement the `Domain` interface). The Omnigraph unifies ENSv1 and ENSv2 behind the same query — `__typename` tells you which one you got. -- `name` is `null` for non-canonical Domains (e.g. Domains whose name cannot be inferred). **Always** guard the access; TypeScript will help you. +- `canonical` is `null` for non-canonical Domains (e.g. Domains whose name cannot be inferred). When non-null, `canonical.name` is the Domain's Canonical Name. **Always** guard the access; TypeScript will help you. ## 6. List subdomains @@ -170,12 +170,12 @@ const HelloWorldQuery = graphql(` query HelloWorld($name: InterpretedName!) { domain(by: { name: $name }) { __typename - name + canonical { name } owner { address } subdomains(first: 20) { totalCount edges { - node { name owner { address } } + node { canonical { name } owner { address } } } } } @@ -195,25 +195,25 @@ async function main() { const { domain } = result.data; - console.log(`Name: ${domain.name ? beautifyInterpretedName(domain.name) : ""}`); + console.log(`Name: ${domain.canonical ? beautifyInterpretedName(domain.canonical.name) : ""}`); console.log(`Version: ${domain.__typename}`); console.log(`Owner: ${domain.owner?.address ?? "0x0"}`); console.log(`\nSubdomains (showing 20 of ${domain.subdomains?.totalCount ?? 0}):`); for (const { node } of domain.subdomains?.edges ?? []) { - const subName = node.name ? beautifyInterpretedName(node.name) : ""; + const subName = node.canonical ? beautifyInterpretedName(node.canonical.name) : ""; console.log(` - ${subName} — Owner ${node.owner?.address ?? "0x0"}`); } } ``` -Notice we're now writing the same name/owner rendering twice — once for the parent Domain and once inside the subdomain loop. We'll fix that next. +Notice we're now writing the same canonical/owner rendering twice — once for the parent Domain and once inside the subdomain loop. We'll fix that next. To page beyond the first 20, the connection also exposes `pageInfo { hasNextPage endCursor }` and accepts an `after: String` cursor — see [Relay's connection spec](https://relay.dev/graphql/connections.htm). ## 7. Extract a typed fragment -Notice we're selecting the same fields (`name`, `owner { address }`) on the parent Domain _and_ on each subdomain, and rendering them the same way. Extract a `DomainFragment` to deduplicate the selection — and get a reusable, fully-typed function that formats a Domain. +Notice we're selecting the same fields (`canonical { name }`, `owner { address }`) on the parent Domain _and_ on each subdomain, and rendering them the same way. Extract a `DomainFragment` to deduplicate the selection — and get a reusable, fully-typed function that formats a Domain. ```ts title="src/index.ts" ins={3,11-17,23,26,31,34-40,56,59} import { asInterpretedName, beautifyInterpretedName } from "enssdk"; @@ -229,7 +229,7 @@ const client = createEnsNodeClient({ url: ENSNODE_URL }).extend(omnigraph); const DomainFragment = graphql(` fragment DomainFragment on Domain { __typename - name + canonical { name } owner { address } } `); @@ -252,7 +252,7 @@ const HelloWorldQuery = graphql( function formatDomain(data: FragmentOf): string { // type-safe access to fragment data! const domain = readFragment(DomainFragment, data); - const name = domain.name ? beautifyInterpretedName(domain.name) : ""; + const name = domain.canonical ? beautifyInterpretedName(domain.canonical.name) : ""; const owner = domain.owner?.address ?? "0x0"; return `${name} (${domain.__typename}) — Owner ${owner}`; } diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-graphql-api.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-graphql-api.mdx index 02959ab709..c8a91f8ad5 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-graphql-api.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-graphql-api.mdx @@ -32,7 +32,7 @@ A minimum-viable hello world over `curl`: ```sh curl -sS -X POST \ -H 'Content-Type: application/json' \ - -d '{"query":"{ domain(by: { name: \"eth\" }) { name owner { address } } }"}' \ + -d '{"query":"{ domain(by: { name: \"eth\" }) { canonical { name } owner { address } } }"}' \ https://api.alpha.ensnode.io/api/omnigraph ``` @@ -78,11 +78,11 @@ const HELLO_WORLD_QUERY = /* GraphQL */ ` query HelloWorld($name: InterpretedName!) { domain(by: { name: $name }) { __typename - name + canonical { name } owner { address } subdomains(first: 20) { totalCount - edges { node { __typename name owner { address } } } + edges { node { __typename canonical { name } owner { address } } } } } } @@ -90,7 +90,7 @@ const HELLO_WORLD_QUERY = /* GraphQL */ ` interface Domain { __typename: "ENSv1Domain" | "ENSv2Domain"; - name: string | null; + canonical: { name: string } | null; owner: { address: string } | null; } @@ -107,7 +107,7 @@ interface QueryResult { } function formatDomain(domain: Domain): string { - const name = domain.name ?? ""; + const name = domain.canonical?.name ?? ""; const owner = domain.owner?.address ?? "0x0"; return `${name} (${domain.__typename}) — Owner ${owner}`; } diff --git a/examples/enssdk-example/src/index.ts b/examples/enssdk-example/src/index.ts index 613e566bce..4243533518 100644 --- a/examples/enssdk-example/src/index.ts +++ b/examples/enssdk-example/src/index.ts @@ -13,7 +13,7 @@ const client = createEnsNodeClient({ url: ENSNODE_URL }).extend(omnigraph); const DomainFragment = graphql(` fragment DomainFragment on Domain { __typename - name + canonical { name } owner { address } } `); @@ -36,7 +36,7 @@ const HelloWorldQuery = graphql( function formatDomain(data: FragmentOf): string { // type-safe access to fragment data! const domain = readFragment(DomainFragment, data); - const name = domain.name ? beautifyInterpretedName(domain.name) : ""; + const name = domain.canonical ? beautifyInterpretedName(domain.canonical.name) : ""; const owner = domain.owner?.address ?? "0x0"; return `${name} (${domain.__typename}) — Owner ${owner}`; } diff --git a/examples/omnigraph-graphql-example/src/index.ts b/examples/omnigraph-graphql-example/src/index.ts index acee928a6e..5b8f44b8c4 100644 --- a/examples/omnigraph-graphql-example/src/index.ts +++ b/examples/omnigraph-graphql-example/src/index.ts @@ -9,11 +9,11 @@ const HELLO_WORLD_QUERY = /* GraphQL */ ` query HelloWorld($name: InterpretedName!) { domain(by: { name: $name }) { __typename - name + canonical { name } owner { address } subdomains(first: 20) { totalCount - edges { node { __typename name owner { address } } } + edges { node { __typename canonical { name } owner { address } } } } } } @@ -21,7 +21,7 @@ const HELLO_WORLD_QUERY = /* GraphQL */ ` interface Domain { __typename: "ENSv1Domain" | "ENSv2Domain"; - name: string | null; + canonical: { name: string } | null; owner: { address: string } | null; } @@ -40,7 +40,7 @@ interface QueryResult { } function formatDomain(domain: Domain): string { - const name = domain.name ?? ""; + const name = domain.canonical?.name ?? ""; const owner = domain.owner?.address ?? "0x0"; return `${name} (${domain.__typename}) — Owner ${owner}`; } diff --git a/packages/enssdk/README.md b/packages/enssdk/README.md index d753ee4ca3..4d157c0ec1 100644 --- a/packages/enssdk/README.md +++ b/packages/enssdk/README.md @@ -32,7 +32,7 @@ const client = createEnsNodeClient({ url: "https://api.alpha.ensnode.io" }) const MyQuery = graphql(` query MyQuery($name: Name!) { domain(by: { name: $name }) { - name + canonical { name } registration { expiry } } } diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index a0ccebc94e..62c50d1879 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -241,7 +241,7 @@ interface Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. """ parent: Domain @@ -392,7 +392,7 @@ type ENSv1Domain implements Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. """ parent: Domain @@ -501,7 +501,7 @@ type ENSv2Domain implements Domain { owner: Account """ - The direct parent Domain via a single unidirectional walk up the namegraph. Null when the Domain's parent Registry does not declare a parent Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. """ parent: Domain diff --git a/packages/enssdk/src/omnigraph/module.test.ts b/packages/enssdk/src/omnigraph/module.test.ts index ac647a18b1..ad634c784b 100644 --- a/packages/enssdk/src/omnigraph/module.test.ts +++ b/packages/enssdk/src/omnigraph/module.test.ts @@ -20,7 +20,7 @@ describe("omnigraph module", () => { }); it("sends a POST request with string query", async () => { - const mockResponse = { data: { domain: { name: "nick.eth" } } }; + const mockResponse = { data: { domain: { canonical: { name: "nick.eth" } } } }; const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockResponse), @@ -29,14 +29,14 @@ describe("omnigraph module", () => { const client = createMockClient(mockFetch); const result = await client.omnigraph.query({ - query: 'query { domain(by: { name: "nick.eth" }) { name } }', + query: 'query { domain(by: { name: "nick.eth" }) { canonical { name } } }', }); expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/omnigraph", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - query: 'query { domain(by: { name: "nick.eth" }) { name } }', + query: 'query { domain(by: { name: "nick.eth" }) { canonical { name } } }', variables: undefined, }), signal: undefined, @@ -54,7 +54,7 @@ describe("omnigraph module", () => { const client = createMockClient(mockFetch); await client.omnigraph.query({ - query: "query($name: String!) { domain(by: { name: $name }) { name } }", + query: "query($name: String!) { domain(by: { name: $name }) { canonical { name } } }", variables: { name: "nick.eth" }, }); @@ -72,7 +72,7 @@ describe("omnigraph module", () => { const client = createMockClient(mockFetch); await client.omnigraph.query({ - query: "query { domains { name } }", + query: "query { domains { canonical { name } } }", signal: controller.signal, }); @@ -91,7 +91,9 @@ describe("omnigraph module", () => { const client = createMockClient(mockFetch); await expect( - client.omnigraph.query({ query: 'query { domain(by: { name: "eth" }) { name } }' }), + client.omnigraph.query({ + query: 'query { domain(by: { name: "eth" }) { canonical { name } } }', + }), ).rejects.toThrow(`Omnigraph query failed: 401 Unauthorized\n${errorBody}`); }); @@ -102,7 +104,7 @@ describe("omnigraph module", () => { }); const client = createMockClient(mockFetch); - const doc = parse('query { domain(by: { name: "nick.eth" }) { name } }'); + const doc = parse('query { domain(by: { name: "nick.eth" }) { canonical { name } } }'); await client.omnigraph.query({ query: doc });