Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/dull-rabbits-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

ENSv2 GraphQL API: BREAKING: Removes Account.domains in favor of `Query.domains` with `owner` specified.
1 change: 0 additions & 1 deletion .changeset/eight-beans-behave.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" })`
2 changes: 1 addition & 1 deletion .changeset/whole-ways-grin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
5 changes: 5 additions & 0 deletions .changeset/wide-trains-camp.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions apps/ensapi/src/graphql-api/lib/canonical-registries-cte.ts
Original file line number Diff line number Diff line change
@@ -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<string>`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");
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand 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]
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => ({}));

Expand Down
21 changes: 18 additions & 3 deletions apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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 || "");
Expand All @@ -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 !== "";
Expand Down Expand Up @@ -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<DomainId>`${schema.v2Domain.id}`.as("domainId"),
ownerId: schema.v2Domain.ownerId,
Expand All @@ -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");

Expand Down
8 changes: 8 additions & 0 deletions apps/ensapi/src/graphql-api/lib/find-domains/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion apps/ensapi/src/graphql-api/lib/get-canonical-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise<Canon
JOIN ${schema.registryCanonicalDomain} rcd
ON rcd.registry_id = upward.registry_id
JOIN ${schema.v2Domain} pd
ON pd.id = rcd.domain_id
ON pd.id = rcd.domain_id AND pd.subregistry_id = upward.registry_id
WHERE upward.registry_id != ${ENSv2_ROOT_REGISTRY_ID}
AND upward.depth < ${MAX_DEPTH}
)
Expand Down
26 changes: 0 additions & 26 deletions apps/ensapi/src/graphql-api/schema/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,12 @@ import * as schema from "@ensnode/ensnode-schema";
import type { PermissionsUserId } from "@ensnode/ensnode-sdk";

import { builder } from "@/graphql-api/builder";
import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver";
import { getModelId } from "@/graphql-api/lib/get-model-id";
import { AccountIdInput } from "@/graphql-api/schema/account-id";
import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-registries-permissions";
import { AccountResolverPermissionsRef } from "@/graphql-api/schema/account-resolver-permissions";
import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants";
import { cursors } from "@/graphql-api/schema/cursors";
import {
AccountDomainsWhereInput,
DomainInterfaceRef,
DomainsOrderInput,
} from "@/graphql-api/schema/domain";
import { PermissionsUserRef } from "@/graphql-api/schema/permissions";
import { db } from "@/lib/db";

Expand Down Expand Up @@ -59,26 +53,6 @@ AccountRef.implement({
resolve: (parent) => 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
///////////////////////
Expand Down
22 changes: 13 additions & 9 deletions apps/ensapi/src/graphql-api/schema/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion packages/ensnode-schema/src/schemas/ensv2.schema.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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),
}),
Expand Down