diff --git a/.changeset/dull-rabbits-take.md b/.changeset/dull-rabbits-take.md new file mode 100644 index 000000000..3ad95ff0e --- /dev/null +++ b/.changeset/dull-rabbits-take.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +ENSv2 GraphQL API: BREAKING: Removes Account.domains in favor of `Query.domains` with `owner` specified. diff --git a/.changeset/eight-beans-behave.md b/.changeset/eight-beans-behave.md index b486ff26d..88ae9c1fd 100644 --- a/.changeset/eight-beans-behave.md +++ b/.changeset/eight-beans-behave.md @@ -5,4 +5,3 @@ The experimental ENSv2 API now supports the following Domain filters, namely matching indexed Domains by name prefix. - `Query.domains(where: { name?: "example.et", owner?: "0xdead...beef" })` -- `Account.domains(where?: { name: "example.et" })` diff --git a/.changeset/whole-ways-grin.md b/.changeset/whole-ways-grin.md index 7c371beae..7febb97bb 100644 --- a/.changeset/whole-ways-grin.md +++ b/.changeset/whole-ways-grin.md @@ -2,4 +2,4 @@ "ensapi": minor --- -ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME, dir: ASC })`. The supported Order criteria are `NAME`, `REGISTRATION_TIMESTAMP`, and `REGISTRATION_EXPIRY` in either `ASC` or `DESC` orders, defaulting to `NAME` and `ASC`. +ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Query.domains(order: { by: NAME, dir: ASC })`. The supported Order criteria are `NAME`, `REGISTRATION_TIMESTAMP`, and `REGISTRATION_EXPIRY` in either `ASC` or `DESC` orders, defaulting to `NAME` and `ASC`. diff --git a/.changeset/wide-trains-camp.md b/.changeset/wide-trains-camp.md new file mode 100644 index 000000000..5e7b75d02 --- /dev/null +++ b/.changeset/wide-trains-camp.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Adds a `canonical?: boolean` filter to the where filter in `Query.domains`. When specified, the resulting set of Domains is composed exclusively of Canonical Domains. diff --git a/apps/ensapi/src/graphql-api/lib/canonical-registries-cte.ts b/apps/ensapi/src/graphql-api/lib/canonical-registries-cte.ts new file mode 100644 index 000000000..966f71f04 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/canonical-registries-cte.ts @@ -0,0 +1,49 @@ +import config from "@/config"; + +import { sql } from "drizzle-orm"; + +import * as schema from "@ensnode/ensnode-schema"; +import { getENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; + +/** + * The maximum depth to traverse the ENSv2 namegraph in order to construct the set of Canonical + * Registries. + * + * Note that the set of Canonical Registries in the ENSv2 Namegraph is a _tree_, enforced by the + * requirement that each Registry maintain a reverse-pointer to its Canonical Domain, a form of + * 'edge authentication': if the reverse-pointer doesn't agree with the forward-pointer, the edge + * is not traversed, making cycles within the direced graph impossible. + * + * So while technically not necessary, including the depth constraint avoids the possibility of an + * infinite runaway query in the event that the indexed namegraph is somehow corrupted or otherwise + * introduces a canonical cycle. + */ +const CANONICAL_REGISTRIES_MAX_DEPTH = 16; + +/** + * Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all + * Canonical Registries. A Canonical Registry is an ENSv2 Registry that is the Root Registry or the + * (sub)Registry of a Domain in a Canonical Registry. + * + * TODO: could this be optimized further, perhaps as a materialized view? + */ +export const getCanonicalRegistriesCTE = () => + db + .select({ registryId: sql`registry_id`.as("registryId") }) + .from( + sql`( + WITH RECURSIVE canonical_registries AS ( + SELECT ${getENSv2RootRegistryId(config.namespace)}::text AS registry_id, 0 AS depth + UNION ALL + SELECT rcd.registry_id, cr.depth + 1 + FROM ${schema.registryCanonicalDomain} rcd + JOIN ${schema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id + JOIN canonical_registries cr ON cr.registry_id = parent.registry_id + WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} + ) + SELECT registry_id FROM canonical_registries + ) AS canonical_registries_cte`, + ) + .as("canonical_registries"); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts index 4db68e95f..16f6f4d68 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts @@ -137,6 +137,8 @@ export function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { FROM ${schema.v2Domain} d JOIN ${schema.registryCanonicalDomain} rcd ON rcd.registry_id = d.registry_id + JOIN ${schema.v2Domain} rcd_parent + ON rcd_parent.id = rcd.domain_id AND rcd_parent.subregistry_id = d.registry_id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL @@ -152,6 +154,8 @@ export function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { ON pd.id = upward_check.current_id JOIN ${schema.registryCanonicalDomain} rcd ON rcd.registry_id = pd.registry_id + JOIN ${schema.v2Domain} rcd_parent + ON rcd_parent.id = rcd.domain_id AND rcd_parent.subregistry_id = pd.registry_id WHERE upward_check.depth < ${pathLength} AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] ) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index 5fba95062..ecc7aa2ff 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -43,8 +43,7 @@ function getOrderValueFromResult( } /** - * Shared GraphQL API resolver for domains connection queries, used by Query.domains and - * Account.domains. + * GraphQL API resolver for domains connection queries, used by Query.domains. * * @param context - The GraphQL Context, required for Dataloader access * @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts index 23029890f..b387dc766 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +vi.mock("@/config", () => ({ default: { namespace: "mainnet" } })); vi.mock("@/lib/db", () => ({ db: {} })); vi.mock("@/graphql-api/lib/find-domains/find-domains-by-labelhash-path", () => ({})); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 5297cc9ae..855d34d9d 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -8,6 +8,7 @@ import { parsePartialInterpretedName, } from "@ensnode/ensnode-sdk"; +import { getCanonicalRegistriesCTE } from "@/graphql-api/lib/canonical-registries-cte"; import type { DomainCursor } from "@/graphql-api/lib/find-domains/domain-cursor"; import { v1DomainsByLabelHashPath, @@ -34,6 +35,8 @@ const FIND_DOMAINS_MAX_DEPTH = 8; * * ## Terminology: * + * - a 'Canonical Registry' is an ENSv2 Registry that is the Root Registry or the (sub)Registry of a + * Domain in a Canonical Registry. * - a 'Canonical Domain' is a Domain connected to either the ENSv1 Root or the ENSv2 Root. All ENSv1 * Domains are Canonical Domains, but an ENSv2 Domain may not be Canonical, for example if it exists * in a disjoint nametree or its Registry does not declare a Canonical Domain. @@ -76,7 +79,10 @@ const FIND_DOMAINS_MAX_DEPTH = 8; * condenses into something manageable. This is left as a todo, though, as it's not yet clear * whether the ability to iterate all Canonical Domains is a requirement. */ -export function findDomains({ name, owner }: FindDomainsWhereArg) { +export function findDomains({ name, owner, canonical }: FindDomainsWhereArg) { + // coerce `canonical` input to boolean + const onlyCanonical = canonical === true; + // NOTE: if name is not provided, parse empty string to simplify control-flow, validity checked below // NOTE: throws if name is not a Partial InterpretedName const { concrete, partial } = parsePartialInterpretedName(name || ""); @@ -88,7 +94,7 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { ); } - logger.debug({ input: { name, owner, concrete, partial } }); + logger.debug({ input: { name, owner, onlyCanonical, concrete, partial } }); // a name input is valid if it was parsed to something other than just empty string const validName = concrete.length > 0 || partial !== ""; @@ -125,7 +131,7 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { ) .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)); - const v2DomainsBase = db + const v2DomainsBaseQuery = db .select({ domainId: sql`${schema.v2Domain.id}`.as("domainId"), ownerId: schema.v2Domain.ownerId, @@ -138,6 +144,15 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { ) .innerJoin(v2HeadDomain, eq(v2HeadDomain.id, v2DomainsByLabelHashPathQuery.headId)); + // conditionally join against the set of Canonical Registries if filtering by `canonical` + const canonicalRegistries = getCanonicalRegistriesCTE(); + const v2DomainsBase = onlyCanonical + ? v2DomainsBaseQuery.innerJoin( + canonicalRegistries, + eq(schema.v2Domain.registryId, canonicalRegistries.registryId), + ) + : v2DomainsBaseQuery; + // Union v1 and v2 base queries into a single subquery const domainsBase = unionAll(v1DomainsBase, v2DomainsBase).as("domainsBase"); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/types.ts b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts index 55d823a4b..82417251f 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/types.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts @@ -24,6 +24,14 @@ export interface FindDomainsWhereArg { * owned by the specified Address. */ owner?: Address | null; + + /** + * When `true`, only Canonical Domains are returned. All v1Domains are Canonical, and v2Domains + * are filtered to those whose registry is reachable from the ENSv2 Root Registry. + * + * When `false` or omitted, all Domains are returned, regardless of whether they are Canonical or not. + */ + canonical?: boolean | null; } /** diff --git a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts index ff7a231c2..b14e819f4 100644 --- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -97,7 +97,7 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise parent.id, }), - /////////////////// - // Account.domains - /////////////////// - domains: t.connection({ - description: "TODO", - type: DomainInterfaceRef, - args: { - where: t.arg({ type: AccountDomainsWhereInput, required: false }), - order: t.arg({ type: DomainsOrderInput }), - }, - resolve: (parent, args, context) => - resolveFindDomains(context, { - ...args, - where: { - ...args.where, - owner: parent.id, - }, - }), - }), - /////////////////////// // Account.permissions /////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 7fbe0dd7b..c2e15eaab 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -321,15 +321,19 @@ export const DomainIdInput = builder.inputType("DomainIdInput", { export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { description: "Filter for domains query. Requires one of name or owner.", fields: (t) => ({ - name: t.string(), - owner: t.field({ type: "Address" }), - }), -}); - -export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereInput", { - description: "Filter for Account.domains query.", - fields: (t) => ({ - name: t.string({ required: true }), + name: t.string({ + description: + "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", + }), + owner: t.field({ + type: "Address", + description: "Filter the set of Domains by those owned by the specified Address.", + }), + canonical: t.boolean({ + description: + "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). If false, the set of Domains is not filtered, and may include ENSv2 Domains not reachable by ENS Forward Resolution.", + defaultValue: false, + }), }), }); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 35c698aef..ad7ea7a28 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -32,6 +32,8 @@ import { toJson } from "@/lib/json-stringify-with-bigints"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; +const ETH_LABELHASH = labelhashLiteralLabel("eth" as LiteralLabel); + const pluginName = PluginName.ENSv2; export default function () { @@ -95,10 +97,7 @@ export default function () { // if this Registry is Bridged, we know its Canonical Domain and can set it here // TODO(bridged-registries): generalize this to future ENSv2 Bridged Resolvers if (accountIdEqual(registry, ENSV2_L2_ETH_REGISTRY)) { - const domainId = makeENSv2DomainId( - ENSV2_ROOT_REGISTRY, - getCanonicalId(labelhashLiteralLabel("eth" as LiteralLabel)), - ); + const domainId = makeENSv2DomainId(ENSV2_ROOT_REGISTRY, getCanonicalId(ETH_LABELHASH)); await context.db .insert(schema.registryCanonicalDomain) .values({ registryId: registryId, domainId }) diff --git a/packages/ensnode-schema/src/schemas/ensv2.schema.ts b/packages/ensnode-schema/src/schemas/ensv2.schema.ts index d4cb6c645..c0b09778f 100644 --- a/packages/ensnode-schema/src/schemas/ensv2.schema.ts +++ b/packages/ensnode-schema/src/schemas/ensv2.schema.ts @@ -1,4 +1,4 @@ -import { index, onchainEnum, onchainTable, primaryKey, relations, uniqueIndex } from "ponder"; +import { index, onchainEnum, onchainTable, primaryKey, relations, sql, uniqueIndex } from "ponder"; import type { Address, Hash } from "viem"; import type { @@ -221,6 +221,7 @@ export const v2Domain = onchainTable( }), (t) => ({ byRegistry: index().on(t.registryId), + bySubregistry: index().on(t.subregistryId).where(sql`${t.subregistryId} IS NOT NULL`), byOwner: index().on(t.ownerId), byLabelHash: index().on(t.labelHash), }),