diff --git a/.changeset/eight-beans-behave.md b/.changeset/eight-beans-behave.md new file mode 100644 index 000000000..b486ff26d --- /dev/null +++ b/.changeset/eight-beans-behave.md @@ -0,0 +1,8 @@ +--- +"ensapi": minor +--- + +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/apps/ensapi/src/graphql-api/builder.ts b/apps/ensapi/src/graphql-api/builder.ts index e659cc071..233429e84 100644 --- a/apps/ensapi/src/graphql-api/builder.ts +++ b/apps/ensapi/src/graphql-api/builder.ts @@ -7,18 +7,16 @@ import type { ChainId, CoinType, DomainId, - ENSNamespaceId, InterpretedName, Node, RegistryId, ResolverId, } from "@ensnode/ensnode-sdk"; +import type { context } from "@/graphql-api/context"; + export const builder = new SchemaBuilder<{ - Context: { - namespace: ENSNamespaceId; - now: bigint; - }; + Context: ReturnType; Scalars: { BigInt: { Input: bigint; Output: bigint }; Address: { Input: Address; Output: Address }; diff --git a/apps/ensapi/src/graphql-api/context.ts b/apps/ensapi/src/graphql-api/context.ts new file mode 100644 index 000000000..c57fd01f6 --- /dev/null +++ b/apps/ensapi/src/graphql-api/context.ts @@ -0,0 +1,35 @@ +import DataLoader from "dataloader"; +import { getUnixTime } from "date-fns"; + +import type { CanonicalPath, ENSv1DomainId, ENSv2DomainId } from "@ensnode/ensnode-sdk"; + +import { getV1CanonicalPath, getV2CanonicalPath } from "./lib/get-canonical-path"; + +/** + * A Promise.catch handler that provides the thrown error as a resolved value, useful for Dataloaders. + */ +const errorAsValue = (error: unknown) => + error instanceof Error ? error : new Error(String(error)); + +const createV1CanonicalPathLoader = () => + new DataLoader(async (domainIds) => + Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))), + ); + +const createV2CanonicalPathLoader = () => + new DataLoader(async (domainIds) => + Promise.all(domainIds.map((id) => getV2CanonicalPath(id).catch(errorAsValue))), + ); + +/** + * Constructs a new GraphQL Context per-request. + * + * @dev make sure that anything that is per-request (like dataloaders) are newly created in this fn + */ +export const context = () => ({ + now: BigInt(getUnixTime(new Date())), + loaders: { + v1CanonicalPath: createV1CanonicalPathLoader(), + v2CanonicalPath: createV2CanonicalPathLoader(), + }, +}); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts new file mode 100644 index 000000000..814d8afcc --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -0,0 +1,301 @@ +import { and, eq, like, Param, sql } from "drizzle-orm"; +import { alias, unionAll } from "drizzle-orm/pg-core"; +import type { Address } from "viem"; + +import * as schema from "@ensnode/ensnode-schema"; +import { + type DomainId, + type ENSv1DomainId, + type ENSv2DomainId, + interpretedLabelsToLabelHashPath, + type LabelHashPath, + type Name, + parsePartialInterpretedName, +} from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; +import { makeLogger } from "@/lib/logger"; + +const logger = makeLogger("find-domains"); + +const MAX_DEPTH = 16; + +interface DomainFilter { + name?: Name | undefined | null; + owner?: Address | undefined | null; +} + +/** + * Find Domains by Canonical Name. + * + * @throws if neither `name` or `owner` are provided + * @throws if `name` is provided but is not a valid Partial InterpretedName + * + * ## Terminology: + * + * - 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. + * - a 'Partial InterpretedName' is a partial InterpretedName (ex: 'examp', 'example.', 'sub1.sub2.paren') + * + * ## Background: + * + * Materializing the set of Canonical Names in ENSv2 is non-trivial and more or less impossible + * within the confines of Ponder's cache semantics. Additionally retroactive label healing (due to + * new labels being discovered on-chain) is likely impossible within those constraints as well. If we + * were to implement a naive cache-unfriendly version of canonical name materialization, indexing time + * would increase dramatically. + * + * The overall user story we're trying to support is 'autocomplete' or 'search (my) domains'. More + * specifically, given a partial InterpretedName as input (ex: 'examp', 'example.', 'sub1.sub2.paren'), + * produce a set of Domains addressable by the provided partial InterpretedName. + * + * While complicated to do so, it is more correct to perform this calculation at query-time rather + * than at index-time, given the constraints above. + * + * ## Algorithm + * + * 1. parse Partial InterpretedName into concrete path and partial fragment + * i.e. for a `name` like "sub1.sub2.paren": + * - concrete = ["sub1", "sub2"] + * - partial = 'paren' + * 2. validate inputs + * 3. for both v1Domains and v2Domains + * a. construct a subquery that filters the set of Domains to those with the specific concrete path + * b. if provided, filter the head domains of that path by `partial` + * c. if provided, filter the leaf domains of that path by `owner` + * 4. construct a union of the two result sets and return + */ +export function findDomains({ name, owner }: DomainFilter) { + // 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 || ""); + + // validate depth to prevent arbitrary recursion in CTEs + if (concrete.length > MAX_DEPTH) { + throw new Error(`Invariant(findDomains): Name depth exceeds maximum of ${MAX_DEPTH} labels.`); + } + + logger.debug({ input: { name, owner, concrete, partial } }); + + // a name input is valid if it was parsed to something other than just empty string + const validName = concrete.length > 0 || partial !== ""; + const validOwner = !!owner; + + // Invariant: one of name or owner must be provided + // TODO: maybe this should be zod... + if (!validName && !validOwner) { + throw new Error(`Invariant(findDomains): One of 'name' or 'owner' must be provided.`); + } + + const labelHashPath = interpretedLabelsToLabelHashPath(concrete); + + // compose subquery by concrete LabelHashPath + const v1DomainsByLabelHashPathQuery = v1DomainsByLabelHashPath(labelHashPath); + const v2DomainsByLabelHashPathQuery = v2DomainsByLabelHashPath(labelHashPath); + + // alias for the head domains (to get its labelHash for partial matching) + const v1HeadDomain = alias(schema.v1Domain, "v1HeadDomain"); + const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain"); + + // join on leafId (the autocomplete result), filter by owner and partial + const v1Domains = db + .select({ id: sql`${schema.v1Domain.id}`.as("id") }) + .from(schema.v1Domain) + .innerJoin( + v1DomainsByLabelHashPathQuery, + eq(schema.v1Domain.id, v1DomainsByLabelHashPathQuery.leafId), + ) + .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)) + .leftJoin(schema.label, eq(schema.label.labelHash, v1HeadDomain.labelHash)) + .where( + and( + owner ? eq(schema.v1Domain.ownerId, owner) : undefined, + // TODO: determine if it's necessary to additionally escape user input for LIKE operator + // Note: if label is NULL (unlabeled domain), LIKE returns NULL and filters out the row. + // This is intentional - we can't match partial text against unknown labels. + partial ? like(schema.label.value, `${partial}%`) : undefined, + ), + ); + + // join on leafId (the autocomplete result), filter by owner and partial + const v2Domains = db + .select({ id: sql`${schema.v2Domain.id}`.as("id") }) + .from(schema.v2Domain) + .innerJoin( + v2DomainsByLabelHashPathQuery, + eq(schema.v2Domain.id, v2DomainsByLabelHashPathQuery.leafId), + ) + .innerJoin(v2HeadDomain, eq(v2HeadDomain.id, v2DomainsByLabelHashPathQuery.headId)) + .leftJoin(schema.label, eq(schema.label.labelHash, v2HeadDomain.labelHash)) + .where( + and( + owner ? eq(schema.v2Domain.ownerId, owner) : undefined, + // TODO: determine if it's necessary to additionally escape user input for LIKE operator + // Note: if label is NULL (unlabeled domain), LIKE returns NULL and filters out the row. + // This is intentional - we can't match partial text against unknown labels. + partial ? like(schema.label.value, `${partial}%`) : undefined, + ), + ); + + // union the two subqueries and return + return db.$with("domains").as(unionAll(v1Domains, v2Domains)); +} + +/** + * Compose a query for v1Domains that have the specified children path. + * + * For a search like "sub1.sub2.paren": + * - concrete = ["sub1", "sub2"] + * - partial = 'paren' + * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] + * + * We find v1Domains matching the concrete path and return both: + * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check + * - headId: the parent of the path (whose label should match partial "paren") + * + * Algorithm: Start from the deepest child (leaf) and traverse UP to find the head. + * This is more efficient than starting from all domains and traversing down. + */ +function v1DomainsByLabelHashPath(labelHashPath: LabelHashPath) { + // If no concrete path, return all domains (leaf = head = self) + // Postgres will optimize this simple subquery when joined + if (labelHashPath.length === 0) { + return db + .select({ + leafId: sql`${schema.v1Domain.id}`.as("leafId"), + headId: sql`${schema.v1Domain.id}`.as("headId"), + }) + .from(schema.v1Domain) + .as("v1_path"); + } + + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; + const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; + + // Use a recursive CTE starting from the deepest child and traversing UP + // The query: + // 1. Starts with domains matching the leaf labelHash (deepest child) + // 2. Recursively joins parents, verifying each ancestor's labelHash + // 3. Returns both the leaf (for result/ownership) and head (for partial match) + return db + .select({ + // https://github.com/drizzle-team/drizzle-orm/issues/1242 + leafId: sql`v1_path_check.leaf_id`.as("leafId"), + headId: sql`v1_path_check.head_id`.as("headId"), + }) + .from( + sql`( + WITH RECURSIVE upward_check AS ( + -- Base case: find the deepest children (leaves of the concrete path) + SELECT + d.id AS leaf_id, + d.parent_id AS current_id, + 1 AS depth + FROM ${schema.v1Domain} d + WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] + + UNION ALL + + -- Recursive step: traverse UP, verifying each ancestor's labelHash + SELECT + upward_check.leaf_id, + pd.parent_id AS current_id, + upward_check.depth + 1 + FROM upward_check + JOIN ${schema.v1Domain} pd + ON pd.id = upward_check.current_id + WHERE upward_check.depth < ${pathLength} + AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] + ) + SELECT leaf_id, current_id AS head_id + FROM upward_check + WHERE depth = ${pathLength} + ) AS v1_path_check`, + ) + .as("v1_path"); +} + +/** + * Compose a query for v2Domains that have the specified children path. + * + * For a search like "sub1.sub2.paren": + * - concrete = ["sub1", "sub2"] + * - partial = 'paren' + * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] + * + * We find v2Domains matching the concrete path and return both: + * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check + * - headId: the parent of the path (whose label should match partial "paren") + * + * Algorithm: Start from the deepest child (leaf) and traverse UP via registryCanonicalDomain. + * For v2, parent relationship is: domain.registryId -> registryCanonicalDomain -> parent domainId + */ +function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { + // If no concrete path, return all domains (leaf = head = self) + // Postgres will optimize this simple subquery when joined + if (labelHashPath.length === 0) { + return db + .select({ + leafId: sql`${schema.v2Domain.id}`.as("leafId"), + headId: sql`${schema.v2Domain.id}`.as("headId"), + }) + .from(schema.v2Domain) + .as("v2_path"); + } + + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; + const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; + + // Use a recursive CTE starting from the deepest child and traversing UP + // The query: + // 1. Starts with domains matching the leaf labelHash (deepest child) + // 2. Recursively joins parents via registryCanonicalDomain, verifying each ancestor's labelHash + // 3. Returns both the leaf (for result/ownership) and head (for partial match) + return db + .select({ + // https://github.com/drizzle-team/drizzle-orm/issues/1242 + leafId: sql`v2_path_check.leaf_id`.as("leafId"), + headId: sql`v2_path_check.head_id`.as("headId"), + }) + .from( + sql`( + WITH RECURSIVE upward_check AS ( + -- Base case: find the deepest children (leaves of the concrete path) + -- and get their parent via registryCanonicalDomain + -- Note: JOIN (not LEFT JOIN) is intentional - we only match domains + -- with a complete canonical path to the searched FQDN + SELECT + d.id AS leaf_id, + rcd.domain_id AS current_id, + 1 AS depth + FROM ${schema.v2Domain} d + JOIN ${schema.registryCanonicalDomain} rcd + ON rcd.registry_id = d.registry_id + WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] + + UNION ALL + + -- Recursive step: traverse UP via registryCanonicalDomain + -- Note: JOIN (not LEFT JOIN) is intentional - see base case comment + SELECT + upward_check.leaf_id, + rcd.domain_id AS current_id, + upward_check.depth + 1 + FROM upward_check + JOIN ${schema.v2Domain} pd + ON pd.id = upward_check.current_id + JOIN ${schema.registryCanonicalDomain} rcd + ON rcd.registry_id = pd.registry_id + WHERE upward_check.depth < ${pathLength} + AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] + ) + SELECT leaf_id, current_id AS head_id + FROM upward_check + WHERE depth = ${pathLength} + ) AS v2_path_check`, + ) + .as("v2_path"); +} 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 87428b37b..ff7a231c2 100644 --- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -6,23 +6,74 @@ import * as schema from "@ensnode/ensnode-schema"; import { type CanonicalPath, type DomainId, + type ENSv1DomainId, + type ENSv2DomainId, getENSv2RootRegistryId, type RegistryId, + ROOT_NODE, } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; const MAX_DEPTH = 16; -const ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); +const ENSv2_ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); /** - * Provide the canonical parents from the Root Registry to `domainId`. - * i.e. reverse traversal of the namegraph + * Provide the canonical parents for an ENSv1 Domain. * - * TODO: this implementation has undefined canonical name behavior, need to updated based on proposed - * reverse mapping + * i.e. reverse traversal of the nametree */ -export async function getCanonicalPath(domainId: DomainId): Promise { +export async function getV1CanonicalPath(domainId: ENSv1DomainId): Promise { + const result = await db.execute(sql` + WITH RECURSIVE upward AS ( + -- Base case: start from the target domain + SELECT + d.id AS domain_id, + d.parent_id, + d.label_hash, + 1 AS depth + FROM ${schema.v1Domain} d + WHERE d.id = ${domainId} + + UNION ALL + + -- Step upward: domain -> parent domain + SELECT + pd.id AS domain_id, + pd.parent_id, + pd.label_hash, + upward.depth + 1 + FROM upward + JOIN ${schema.v1Domain} pd + ON pd.id = upward.parent_id + WHERE upward.depth < ${MAX_DEPTH} + ) + SELECT * + FROM upward + ORDER BY depth; + `); + + const rows = result.rows as { domain_id: ENSv1DomainId; parent_id: ENSv1DomainId }[]; + + if (rows.length === 0) { + throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`); + } + + // v1Domains are canonical if the TLD's parent is ROOT_NODE (ROOT_NODE itself does not exist in the index) + const tld = rows[rows.length - 1]; + const isCanonical = tld.parent_id === ROOT_NODE; + + if (!isCanonical) return null; + + return rows.map((row) => row.domain_id); +} + +/** + * Provide the canonical parents for an ENSv2 Domain. + * + * i.e. reverse traversal of the namegraph via registry_canonical_domains + */ +export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise { const result = await db.execute(sql` WITH RECURSIVE upward AS ( -- Base case: start from the target domain @@ -36,18 +87,18 @@ export async function getCanonicalPath(domainId: DomainId): Promise registry -> parent domain + -- Step upward: domain -> registry -> canonical parent domain SELECT pd.id AS domain_id, pd.registry_id, pd.label_hash, upward.depth + 1 FROM upward - JOIN ${schema.registry} r - ON r.id = upward.registry_id + JOIN ${schema.registryCanonicalDomain} rcd + ON rcd.registry_id = upward.registry_id JOIN ${schema.v2Domain} pd - ON pd.subregistry_id = r.id - WHERE r.id != ${ROOT_REGISTRY_ID} + ON pd.id = rcd.domain_id + WHERE upward.registry_id != ${ENSv2_ROOT_REGISTRY_ID} AND upward.depth < ${MAX_DEPTH} ) SELECT * @@ -62,7 +113,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise { - const labelHashPath = interpretedNameToLabelHashPath(name); + const labelHashPath = interpretedLabelsToLabelHashPath(interpretedNameToInterpretedLabels(name)); // https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index 61cf6060c..c79011cdc 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -1,12 +1,12 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, asc, desc, eq, gt, lt } from "drizzle-orm"; -import { unionAll } from "drizzle-orm/pg-core"; import type { Address } from "viem"; import * as schema from "@ensnode/ensnode-schema"; -import type { PermissionsUserId } from "@ensnode/ensnode-sdk"; +import type { DomainId, PermissionsUserId } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; +import { findDomains } from "@/graphql-api/lib/find-domains"; import { getModelId } from "@/graphql-api/lib/get-model-id"; import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; @@ -14,7 +14,7 @@ import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-regi 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 { DomainInterfaceRef } from "@/graphql-api/schema/domain"; +import { AccountDomainsWhereInput, DomainInterfaceRef } from "@/graphql-api/schema/domain"; import { PermissionsUserRef } from "@/graphql-api/schema/permissions"; import { db } from "@/lib/db"; @@ -62,41 +62,31 @@ AccountRef.implement({ domains: t.connection({ description: "TODO", type: DomainInterfaceRef, + args: { + where: t.arg({ type: AccountDomainsWhereInput, required: false }), + }, resolve: (parent, args, context) => resolveCursorConnection( { ...DEFAULT_CONNECTION_ARGS, args }, async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - const v1Domains = db - .select({ id: schema.v1Domain.id }) - .from(schema.v1Domain) - .where(eq(schema.v1Domain.ownerId, parent.id)) - .leftJoin(schema.label, eq(schema.v1Domain.labelHash, schema.label.labelHash)); - - const v2Domains = db - .select({ id: schema.v2Domain.id }) - .from(schema.v2Domain) - .where(eq(schema.v2Domain.ownerId, parent.id)) - .leftJoin(schema.label, eq(schema.v2Domain.labelHash, schema.label.labelHash)); - - // use any to ignore id column type mismatch (ENSv1DomainId & ENSv2DomainId) - const domains = db.$with("domains").as(unionAll(v1Domains, v2Domains as any)); + // construct query for relevant domains + const domains = findDomains({ ...args.where, owner: parent.id }); + // execute with pagination constraints const results = await db .with(domains) .select() .from(domains) .where( and( - ...[ - // NOTE: using any because drizzle infers id as ENSv1DomainId - before && lt(domains.id, cursors.decode(before)), - after && gt(domains.id, cursors.decode(after)), - ].filter((c) => !!c), + before ? lt(domains.id, cursors.decode(before)) : undefined, + after ? gt(domains.id, cursors.decode(after)) : undefined, ), ) .orderBy(inverted ? desc(domains.id) : asc(domains.id)) .limit(limit); + // provide full Domain entities via dataloader return rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany( results.map((result) => result.id), @@ -122,15 +112,15 @@ AccountRef.implement({ db.query.permissionsUser.findMany({ where: (t, { lt, gt, and, eq }) => and( - ...[ - // this user's permissions - eq(t.user, parent.id), - // optionally filtered by contract - args.in && and(eq(t.chainId, args.in.chainId), eq(t.address, args.in.address)), - // optionall filtered by cursor - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + // this user's permissions + eq(t.user, parent.id), + // optionally filtered by contract + args.in + ? and(eq(t.chainId, args.in.chainId), eq(t.address, args.in.address)) + : undefined, + // optionall filtered by cursor + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -164,13 +154,13 @@ AccountRef.implement({ ) .where( and( - ...[ - eq(schema.permissionsUser.user, parent.id), - before !== undefined && - lt(schema.permissionsUser.id, cursors.decode(before)), - after !== undefined && - gt(schema.permissionsUser.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(schema.permissionsUser.user, parent.id), + before + ? lt(schema.permissionsUser.id, cursors.decode(before)) + : undefined, + after + ? gt(schema.permissionsUser.id, cursors.decode(after)) + : undefined, ), ) .orderBy(inverted ? desc(schema.permissionsUser.id) : asc(schema.permissionsUser.id)) @@ -206,13 +196,13 @@ AccountRef.implement({ ) .where( and( - ...[ - eq(schema.permissionsUser.user, parent.id), - before !== undefined && - lt(schema.permissionsUser.id, cursors.decode(before)), - after !== undefined && - gt(schema.permissionsUser.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(schema.permissionsUser.user, parent.id), + before + ? lt(schema.permissionsUser.id, cursors.decode(before)) + : undefined, + after + ? gt(schema.permissionsUser.id, cursors.decode(after)) + : undefined, ), ) .orderBy(inverted ? desc(schema.permissionsUser.id) : asc(schema.permissionsUser.id)) diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 9fefd7014..d74aa5ad8 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -5,6 +5,7 @@ import { type ENSv1DomainId, type ENSv2DomainId, getCanonicalId, + interpretedLabelsToInterpretedName, type RegistrationId, } from "@ensnode/ensnode-sdk"; @@ -12,6 +13,7 @@ import { builder } from "@/graphql-api/builder"; import { getDomainResolver } from "@/graphql-api/lib/get-domain-resolver"; import { getLatestRegistration } from "@/graphql-api/lib/get-latest-registration"; import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { AccountRef } from "@/graphql-api/schema/account"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; @@ -98,68 +100,60 @@ DomainInterfaceRef.implement({ resolve: async ({ label }) => label.value, }), - //////////////////// - // Domain.canonical - //////////////////// - // TODO: pending ENS team canonicalName implementation - // canonical: t.field({ - // description: "TODO", - // type: "Name", - // nullable: true, - // resolve: async ({ id }, args, context) => { - // // TODO: dataloader the getCanonicalPath(domainId) function - // const canonicalPath = await getCanonicalPath(id); - // if (!canonicalPath) return null; - - // const domains = await rejectAnyErrors( - // DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), - // ); - - // return interpretedLabelsToInterpretedName( - // canonicalPath.map((domainId) => { - // const found = domains.find((d) => d.id === domainId); - // if (!found) throw new Error(`Invariant`); - // return found.label.value; - // }), - // ); - // }, - // }), - - ////////////////// - // Domain.parents - ////////////////// - // TODO: pending ENS team canonicalName implementation - // parents: t.field({ - // description: "TODO", - // type: [DomainInterfaceRef], - // nullable: true, - // resolve: async ({ id }, args, context) => { - // // TODO: dataloader the getCanonicalPath(domainId) function - // const canonicalPath = await getCanonicalPath(id); - // if (!canonicalPath) return null; - - // const domains = await rejectErrors( - // DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), - // ); - - // return domains.slice(1); - // }, - // }), - - ////////////////// - // Domain.aliases - ////////////////// - // TODO: pending ENS team canonicalName implementation, maybe impossible to implement - // aliases: t.field({ - // description: "TODO", - // type: ["Name"], - // nullable: false, - // resolve: async (parent) => { - // // a domain's aliases are all of the paths from root to this domain for which it can be - // // resolved. naively reverse-traverse the namegaph until the root is reached... yikes. - // return []; - // }, - // }), + /////////////// + // Domain.name + /////////////// + name: t.field({ + description: "TODO", + type: "Name", + nullable: true, + resolve: async (domain, args, context) => { + const canonicalPath = isENSv1Domain(domain) + ? await context.loaders.v1CanonicalPath.load(domain.id) + : await context.loaders.v2CanonicalPath.load(domain.id); + if (!canonicalPath) return null; + + // TODO: this could be more efficient if the get*CanonicalPath helpers included the label + // join for us. + 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.value; + }); + + return interpretedLabelsToInterpretedName(labels); + }, + }), + + // TODO: maybe supply partial names as well? perhaps a Domain.name.canonical and Domain.name.partial and so on? + + /////////////// + // Domain.path + /////////////// + path: t.field({ + description: "TODO", + type: [DomainInterfaceRef], + nullable: true, + resolve: async (domain, args, context) => { + const canonicalPath = isENSv1Domain(domain) + ? await context.loaders.v1CanonicalPath.load(domain.id) + : await context.loaders.v2CanonicalPath.load(domain.id); + if (!canonicalPath) return null; + + return await rejectAnyErrors( + DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), + ); + }, + }), ////////////////////// // Domain.owner @@ -204,11 +198,9 @@ DomainInterfaceRef.implement({ db.query.registration.findMany({ where: (t, { lt, gt, and, eq }) => and( - ...[ - eq(t.domainId, parent.id), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.domainId, parent.id), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? asc(t.index) : desc(t.index)), limit, @@ -249,11 +241,9 @@ ENSv1DomainRef.implement({ db.query.v1Domain.findMany({ where: (t, { lt, gt, and, eq }) => and( - ...[ - eq(t.parentId, parent.id), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.parentId, parent.id), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -326,3 +316,18 @@ export const DomainIdInput = builder.inputType("DomainIdInput", { id: t.field({ type: "DomainId" }), }), }); + +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 }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/permissions.ts b/apps/ensapi/src/graphql-api/schema/permissions.ts index b86f6e5cc..4aaddb2da 100644 --- a/apps/ensapi/src/graphql-api/schema/permissions.ts +++ b/apps/ensapi/src/graphql-api/schema/permissions.ts @@ -101,12 +101,10 @@ PermissionsRef.implement({ db.query.permissionsResource.findMany({ where: (t, { lt, gt, eq, and }) => and( - ...[ - eq(t.chainId, parent.chainId), - eq(t.address, parent.address), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -165,13 +163,11 @@ PermissionsResourceRef.implement({ db.query.permissionsUser.findMany({ where: (t, { lt, gt, eq, and }) => and( - ...[ - eq(t.chainId, parent.chainId), - eq(t.address, parent.address), - eq(t.resource, parent.resource), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + eq(t.resource, parent.resource), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index 95bb8bf0d..16a6c093b 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -1,6 +1,10 @@ +import config from "@/config"; + import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and, asc, desc, gt, lt } from "drizzle-orm"; import { + type DomainId, type ENSv1DomainId, type ENSv2DomainId, getENSv2RootRegistryId, @@ -12,7 +16,9 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; +import { findDomains } from "@/graphql-api/lib/find-domains"; import { getDomainIdByInterpretedName } from "@/graphql-api/lib/get-domain-by-fqdn"; +import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { AccountRef } from "@/graphql-api/schema/account"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; @@ -20,6 +26,7 @@ import { cursors } from "@/graphql-api/schema/cursors"; import { DomainIdInput, DomainInterfaceRef, + DomainsWhereInput, ENSv1DomainRef, ENSv2DomainRef, } from "@/graphql-api/schema/domain"; @@ -29,12 +36,52 @@ import { RegistryIdInput, RegistryRef } from "@/graphql-api/schema/registry"; import { ResolverIdInput, ResolverRef } from "@/graphql-api/schema/resolver"; import { db } from "@/lib/db"; -// don't want them to get familiar/accustom to these methods until their necessity is certain +// don't want them to get familiar/accustomed to these methods until their necessity is certain const INCLUDE_DEV_METHODS = process.env.NODE_ENV !== "production"; builder.queryType({ fields: (t) => ({ ...(INCLUDE_DEV_METHODS && { + ///////////////////////////// + // Query.domains (Testing) + ///////////////////////////// + domains: t.connection({ + description: "TODO", + type: DomainInterfaceRef, + args: { + where: t.arg({ type: DomainsWhereInput, required: true }), + }, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + // construct query for relevant domains + const domains = findDomains(args.where); + + // execute with pagination constraints + const results = await db + .with(domains) + .select() + .from(domains) + .where( + and( + before ? lt(domains.id, cursors.decode(before)) : undefined, + after ? gt(domains.id, cursors.decode(after)) : undefined, + ), + ) + .orderBy(inverted ? desc(domains.id) : asc(domains.id)) + .limit(limit); + + // provide full Domain entities via dataloader + return rejectAnyErrors( + DomainInterfaceRef.getDataloader(context).loadMany( + results.map((result) => result.id), + ), + ); + }, + ), + }), + ///////////////////////////// // Query.v1Domains (Testing) ///////////////////////////// @@ -48,10 +95,8 @@ builder.queryType({ db.query.v1Domain.findMany({ where: (t, { lt, gt, and }) => and( - ...[ - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -73,10 +118,8 @@ builder.queryType({ db.query.v2Domain.findMany({ where: (t, { lt, gt, and }) => and( - ...[ - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -98,10 +141,8 @@ builder.queryType({ db.query.resolver.findMany({ where: (t, { lt, gt, and }) => and( - ...[ - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -122,10 +163,8 @@ builder.queryType({ db.query.registration.findMany({ where: (t, { lt, gt, and }) => and( - ...[ - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -201,7 +240,7 @@ builder.queryType({ description: "TODO", type: RegistryRef, nullable: false, - resolve: (parent, args, context) => getENSv2RootRegistryId(context.namespace), + resolve: () => getENSv2RootRegistryId(config.namespace), }), }), }); diff --git a/apps/ensapi/src/graphql-api/schema/registration.ts b/apps/ensapi/src/graphql-api/schema/registration.ts index 6d8a7c530..a431d401e 100644 --- a/apps/ensapi/src/graphql-api/schema/registration.ts +++ b/apps/ensapi/src/graphql-api/schema/registration.ts @@ -140,12 +140,10 @@ RegistrationInterfaceRef.implement({ db.query.renewal.findMany({ where: (t, { eq, lt, gt, and }) => and( - ...[ - eq(t.domainId, parent.domainId), - eq(t.registrationIndex, parent.index), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.domainId, parent.domainId), + eq(t.registrationIndex, parent.index), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, diff --git a/apps/ensapi/src/graphql-api/schema/registry.ts b/apps/ensapi/src/graphql-api/schema/registry.ts index 8eb66e6bf..bf3196060 100644 --- a/apps/ensapi/src/graphql-api/schema/registry.ts +++ b/apps/ensapi/src/graphql-api/schema/registry.ts @@ -47,11 +47,9 @@ RegistryRef.implement({ db.query.v2Domain.findMany({ where: (t, { lt, gt, and, eq }) => and( - ...[ - eq(t.subregistryId, parent.id), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.subregistryId, parent.id), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -73,11 +71,9 @@ RegistryRef.implement({ db.query.v2Domain.findMany({ where: (t, { lt, gt, eq, and }) => and( - ...[ - eq(t.registryId, parent.id), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.registryId, parent.id), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, diff --git a/apps/ensapi/src/graphql-api/schema/resolver.ts b/apps/ensapi/src/graphql-api/schema/resolver.ts index cf6059765..393cb23e0 100644 --- a/apps/ensapi/src/graphql-api/schema/resolver.ts +++ b/apps/ensapi/src/graphql-api/schema/resolver.ts @@ -1,3 +1,5 @@ +import config from "@/config"; + import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { namehash } from "viem"; @@ -86,12 +88,10 @@ ResolverRef.implement({ db.query.resolverRecords.findMany({ where: (t, { lt, gt, and, eq }) => and( - ...[ - eq(t.chainId, parent.chainId), - eq(t.address, parent.address), - before !== undefined && lt(t.id, cursors.decode(before)), - after !== undefined && gt(t.id, cursors.decode(after)), - ].filter((c) => !!c), + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + before ? lt(t.id, cursors.decode(before)) : undefined, + after ? gt(t.id, cursors.decode(after)) : undefined, ), orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), limit, @@ -139,7 +139,7 @@ ResolverRef.implement({ description: "TODO", type: AccountIdRef, nullable: true, - resolve: (parent, args, context) => isBridgedResolver(context.namespace, parent), + resolve: (parent) => isBridgedResolver(config.namespace, parent), }), //////////////////////// diff --git a/apps/ensapi/src/graphql-api/yoga.ts b/apps/ensapi/src/graphql-api/yoga.ts index 41af36f98..7bc2841c5 100644 --- a/apps/ensapi/src/graphql-api/yoga.ts +++ b/apps/ensapi/src/graphql-api/yoga.ts @@ -2,11 +2,9 @@ // import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth"; // import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens"; -import config from "@/config"; - -import { getUnixTime } from "date-fns"; import { createYoga } from "graphql-yoga"; +import { context } from "@/graphql-api/context"; import { schema } from "@/graphql-api/schema"; import { makeLogger } from "@/lib/logger"; @@ -15,13 +13,7 @@ const logger = makeLogger("ensnode-graphql"); export const yoga = createYoga({ graphqlEndpoint: "*", schema, - context: () => ({ - // inject config's namespace into context, feel cleaner than accessing from @/config directly - namespace: config.namespace, - - // generate a bigint UnixTimestamp per-request for handlers to use - now: BigInt(getUnixTime(new Date())), - }), + context, graphiql: { defaultQuery: `query DomainsByOwner { account(address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") { diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 0e37f6115..b07233950 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -216,16 +216,15 @@ async function findResolverWithIndex( where: (t, { inArray, and, or, eq }) => and( or( - ...[ - // filter for Domain-Resolver Relationship in the current Registry - and(eq(t.chainId, registry.chainId), eq(t.address, registry.address)), - // OR, if the registry is the ENS Root Registry, also include records from RegistryOld - isENSv1Registry(config.namespace, registry) && - and( + // filter for Domain-Resolver Relationship in the current Registry + and(eq(t.chainId, registry.chainId), eq(t.address, registry.address)), + // OR, if the registry is the ENS Root Registry, also include records from RegistryOld + isENSv1Registry(config.namespace, registry) + ? and( eq(t.chainId, ENSv1RegistryOld.chainId), eq(t.address, ENSv1RegistryOld.address), - ), - ].filter((c) => !!c), + ) + : undefined, ), // filter for Domain-Resolver Relations for the following DomainIds inArray(t.domainId, domainIds), diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 7ca7f25b7..43c2d8453 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -1,13 +1,20 @@ +import config from "@/config"; + import { type Context, ponder } from "ponder:registry"; import schema from "ponder:schema"; import { type Address, hexToBigInt, labelhash } from "viem"; +import { DatasourceNames } from "@ensnode/datasources"; import { type AccountId, + accountIdEqual, getCanonicalId, + getDatasourceContract, + getENSv2RootRegistry, interpretAddress, isRegistrationFullyExpired, type LiteralLabel, + labelhashLiteralLabel, makeENSv2DomainId, makeLatestRegistrationId, makeRegistryId, @@ -78,6 +85,27 @@ export default function () { }) .onConflictDoNothing(); + // TODO(ensv2): hoist this access once all namespaces declare ENSv2 contracts + const ENSV2_ROOT_REGISTRY = getENSv2RootRegistry(config.namespace); + const ENSV2_L2_ETH_REGISTRY = getDatasourceContract( + config.namespace, + DatasourceNames.ENSv2ETHRegistry, + "ETHRegistry", + ); + + // 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)), + ); + await context.db + .insert(schema.registryCanonicalDomain) + .values({ registryId: registryId, domainId }) + .onConflictDoUpdate({ domainId }); + } + // ensure discovered Label await ensureLabel(context, label); @@ -189,15 +217,31 @@ export default function () { const canonicalId = getCanonicalId(tokenId); const domainId = makeENSv2DomainId(registryAccountId, canonicalId); - // console.log(`SubregistryUpdated: ${subregistry} \n ↳ ${domainId}`); - // update domain's subregistry if (subregistry === null) { + // TODO(canonical-names): this last-write-wins heuristic breaks if a domain ever unsets its + // subregistry. i.e. the (sub)Registry's Canonical Domain becomes null, making it disjoint because + // we don't track other domains who have set it as a Subregistry. This is acceptable for now, + // and obviously isn't an issue once ENS Team implements Canonical Names + const previous = await context.db.find(schema.v2Domain, { id: domainId }); + if (previous?.subregistryId) { + await context.db.delete(schema.registryCanonicalDomain, { + registryId: previous.subregistryId, + }); + } + await context.db.update(schema.v2Domain, { id: domainId }).set({ subregistryId: null }); } else { const subregistryAccountId: AccountId = { chainId: context.chain.id, address: subregistry }; const subregistryId = makeRegistryId(subregistryAccountId); + // TODO(canonical-names): this implements last-write-wins heuristic for a Registry's canonical name, + // replace with real logic once ENS Team implements Canonical Names + await context.db + .insert(schema.registryCanonicalDomain) + .values({ registryId: subregistryId, domainId }) + .onConflictDoUpdate({ domainId }); + await context.db.update(schema.v2Domain, { id: domainId }).set({ subregistryId }); } }, diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index 6da59a2c6..2b7f4a37f 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -43,12 +43,12 @@ export default { contracts: { ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi - address: "0x0dcd1bf9a1b36ce34237eeafef220932846bcd82", + address: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", startBlock: 0, }, ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi - address: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", + address: "0x0165878a594ca255338adfa4d48449f69242eb8f", startBlock: 0, }, Resolver: { diff --git a/packages/ensnode-schema/src/schemas/ensv2.schema.ts b/packages/ensnode-schema/src/schemas/ensv2.schema.ts index 7426940d9..7e3a8cb55 100644 --- a/packages/ensnode-schema/src/schemas/ensv2.schema.ts +++ b/packages/ensnode-schema/src/schemas/ensv2.schema.ts @@ -170,6 +170,7 @@ export const v1Domain = onchainTable( (t) => ({ byParent: index().on(t.parentId), byOwner: index().on(t.ownerId), + byLabelHash: index().on(t.labelHash), }), ); @@ -221,6 +222,7 @@ export const v2Domain = onchainTable( (t) => ({ byRegistry: index().on(t.registryId), byOwner: index().on(t.ownerId), + byLabelHash: index().on(t.labelHash), }), ); @@ -476,11 +478,32 @@ export const relations_permissionsUser = relations(permissionsUser, ({ one }) => // Labels ////////// -export const label = onchainTable("labels", (t) => ({ - labelHash: t.hex().primaryKey().$type(), - value: t.text().notNull().$type(), -})); +export const label = onchainTable( + "labels", + (t) => ({ + labelHash: t.hex().primaryKey().$type(), + value: t.text().notNull().$type(), + }), + (t) => ({ + byValue: index().on(t.value), + }), +); export const label_relations = relations(label, ({ many }) => ({ domains: many(v2Domain), })); + +/////////////////// +// Canonical Names +/////////////////// + +// TODO(canonical-names): this table will be refactored away once Canonical Names are implemented in +// ENSv2, and we'll be able to store this information directly on the Registry entity, but until +// then we need a place to track canonical domain references without requiring that a Registry contract +// has emitted an event (and therefore is indexed) +// TODO(canonical-names): this table can also disappear once the Signal pattern is implemented for +// Registry contracts, ensuring that they are indexed during construction and are available for storage. +export const registryCanonicalDomain = onchainTable("registry_canonical_domains", (t) => ({ + registryId: t.text().primaryKey().$type(), + domainId: t.text().notNull().$type(), +})); diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts index 3c4e2bfda..24a423308 100644 --- a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; -import { encodeLabelHash, type InterpretedLabel, type LiteralLabel } from "../../ens"; +import { + encodeLabelHash, + type InterpretedLabel, + type InterpretedName, + type LiteralLabel, + type Name, +} from "../../ens"; import { labelhashLiteralLabel } from "../labelhash"; import { + constructSubInterpretedName, interpretedLabelsToInterpretedName, literalLabelsToInterpretedName, literalLabelToInterpretedLabel, + parsePartialInterpretedName, } from "./interpreted-names-and-labels"; const ENCODED_LABELHASH_LABEL = /^\[[\da-f]{64}\]$/; @@ -45,6 +53,10 @@ const UNNORMALIZED_LABELS = [ "A".repeat(300), // Long non-normalized ] as LiteralLabel[]; +const EXAMPLE_ENCODED_LABEL_HASH = encodeLabelHash( + labelhashLiteralLabel("example" as LiteralLabel), +); + describe("interpretation", () => { describe("interpretLiteralLabel", () => { it("should return normalized labels unchanged", () => { @@ -114,4 +126,91 @@ describe("interpretation", () => { ).toEqual(`a.b.c.${interpretedLabelThatLooksLikeALabelHash}`); }); }); + + describe("parsePartialInterpretedName", () => { + it.each([ + // empty input + ["", [], ""], + // partial only (no concrete labels) + ["t", [], "t"], + ["test", [], "test"], + ["exam", [], "exam"], + ["🔥", [], "🔥"], + // concrete TLD with empty partial + ["eth.", ["eth"], ""], + ["base.", ["base"], ""], + // concrete TLD with partial SLD + ["test.eth", ["test"], "eth"], + ["example.eth", ["example"], "eth"], + ["demo.eth", ["demo"], "eth"], + ["parent.eth", ["parent"], "eth"], + ["bridge.eth", ["bridge"], "eth"], + ["examp.eth", ["examp"], "eth"], + // concrete SLD with empty partial + ["sub.parent.eth.", ["sub", "parent", "eth"], ""], + // concrete SLD with partial 3LD + ["sub2.parent.eth", ["sub2", "parent"], "eth"], + ["linked.parent.eth", ["linked", "parent"], "eth"], + // deeper nesting + ["sub1.sub2.parent.eth", ["sub1", "sub2", "parent"], "eth"], + ["wallet.sub1.sub2.parent.eth", ["wallet", "sub1", "sub2", "parent"], "eth"], + ["wallet.linked.parent.eth", ["wallet", "linked", "parent"], "eth"], + // partial at various depths + ["wal.sub1.sub2.parent.eth", ["wal", "sub1", "sub2", "parent"], "eth"], + ["w.sub1.sub2.parent.eth", ["w", "sub1", "sub2", "parent"], "eth"], + // with encoded labelhashes in concrete + [`${EXAMPLE_ENCODED_LABEL_HASH}.eth`, [EXAMPLE_ENCODED_LABEL_HASH], "eth"], + // with encoded labelhash in partial + [ + `example.${EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20)}`, + ["example"], + EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20), + ], + ] as [Name, string[], string][])( + "parsePartialInterpretedName(%j) → { concrete: %j, partial: %j }", + (input, expectedConcrete, expectedPartial) => { + expect(parsePartialInterpretedName(input)).toEqual({ + concrete: expectedConcrete, + partial: expectedPartial, + }); + }, + ); + + it.each([ + "Test.eth", // uppercase in concrete + "EXAMPLE.eth", // uppercase in concrete + "test\0.eth", // null in concrete + "sub.Parent.eth", // uppercase in middle + ] as Name[])("throws for invalid concrete label: %j", (input) => { + expect(() => parsePartialInterpretedName(input)).toThrow(); + }); + }); + + describe("constructSubInterpretedName", () => { + it.each([ + // label only (no parent) + ["eth", undefined, "eth"], + ["eth", "", "eth"], + ["test", undefined, "test"], + ["vitalik", undefined, "vitalik"], + // label + parent + ["test", "eth", "test.eth"], + ["vitalik", "eth", "vitalik.eth"], + ["sub", "parent.eth", "sub.parent.eth"], + ["wallet", "sub.parent.eth", "wallet.sub.parent.eth"], + // with encoded labelhash as label + [EXAMPLE_ENCODED_LABEL_HASH, "eth", `${EXAMPLE_ENCODED_LABEL_HASH}.eth`], + [EXAMPLE_ENCODED_LABEL_HASH, undefined, EXAMPLE_ENCODED_LABEL_HASH], + // with encoded labelhash in parent + ["sub", `${EXAMPLE_ENCODED_LABEL_HASH}.eth`, `sub.${EXAMPLE_ENCODED_LABEL_HASH}.eth`], + // emoji labels + ["🔥", "eth", "🔥.eth"], + ["wallet", "🔥.eth", "wallet.🔥.eth"], + ] as [InterpretedLabel, InterpretedName | undefined, InterpretedName][])( + "constructSubInterpretedName(%j, %j) → %j", + (label, parent, expected) => { + expect(constructSubInterpretedName(label, parent)).toEqual(expected); + }, + ); + }); }); diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts index dada395d9..5f28383ad 100644 --- a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts @@ -94,7 +94,7 @@ export function isInterpetedLabel(label: Label): label is InterpretedLabel { // if it looks like an encoded labelhash, it must be one if (label.startsWith("[")) { const labelHash = encodedLabelToLabelhash(label); - if (labelHash === null) return false; + return labelHash != null; } // otherwise label must be normalized @@ -106,14 +106,14 @@ export function isInterpretedName(name: Name): name is InterpretedName { } /** - * Converts an InterpretedName into a LabelHashPath. + * Converts InterpretedLabels into a LabelHashPath. */ -export function interpretedNameToLabelHashPath(name: InterpretedName): LabelHashPath { - return interpretedNameToInterpretedLabels(name) +export function interpretedLabelsToLabelHashPath(labels: InterpretedLabel[]): LabelHashPath { + return labels .map((label) => { if (!isInterpetedLabel(label)) { throw new Error( - `Invariant(interpretedNameToLabelHashPath): Expected InterpretedLabel, received '${label}'.`, + `Invariant(interpretedLabelsToLabelHashPath): Expected InterpretedLabel, received '${label}'.`, ); } @@ -126,3 +126,52 @@ export function interpretedNameToLabelHashPath(name: InterpretedName): LabelHash }) .toReversed(); } + +/** + * Constructs a new InterpretedName from an InterpretedLabel (child) and InterpretedName (parent). + * + * If no parent is available the InterpretedLabel is cast to an InterpretedName and returned. + * + * @dev the following is safe due to InterpretedLabel/InterpretedName semantics, see above. + */ +export function constructSubInterpretedName( + label: InterpretedLabel, + name: InterpretedName | undefined, +): InterpretedName { + if (name === undefined || name === "") return label as Name as InterpretedName; + return [label, name].join(".") as InterpretedName; +} + +/** + * Given a `labelHash` and optionally its healed InterpretedLabel, return an InterpretedLabel. + */ +export function ensureInterpretedLabel( + labelHash: LabelHash, + label: InterpretedLabel | undefined, +): InterpretedLabel { + return label ?? (encodeLabelHash(labelHash) as InterpretedLabel); +} + +/** + * Parses a Partial InterpretedName into concrete InterpretedLabels and the partial Label. + * + * @throws if the provided `partialInterpretedName` is not composed of concrete InterpretedLabels. + */ +export function parsePartialInterpretedName(partialInterpretedName: Name): { + concrete: InterpretedLabel[]; + partial: string; +} { + if (partialInterpretedName === "") return { concrete: [], partial: "" }; + + const concrete = partialInterpretedName.split("."); + // biome-ignore lint/style/noNonNullAssertion: there's always at least one element after a .split + const partial = concrete.pop()!; + + if (!concrete.every(isInterpetedLabel)) { + throw new Error( + `Invariant(parsePartialInterpretedName): Concrete portion of Partial InterpretedName contains segments that are not InterpretedLabels.\n${JSON.stringify(concrete)}`, + ); + } + + return { concrete, partial }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9bd52334..db5fded60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ catalogs: specifier: 10.1.0 version: 10.1.0 ponder: - specifier: 0.16.1 - version: 0.16.1 + specifier: 0.16.2 + version: 0.16.2 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -478,7 +478,7 @@ importers: version: 2.9.1 ponder: specifier: 'catalog:' - version: 0.16.1(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6) + version: 0.16.2(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@4.3.6) @@ -836,7 +836,7 @@ importers: dependencies: ponder: specifier: 'catalog:' - version: 0.16.1(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.2(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) @@ -1036,7 +1036,7 @@ importers: version: 4.11.7 ponder: specifier: 'catalog:' - version: 0.16.1(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.2(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) tsup: specifier: 'catalog:' version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -7218,8 +7218,8 @@ packages: graphql: ^16.10.0 hono: ^4.6.19 - ponder@0.16.1: - resolution: {integrity: sha512-AnLE0S2EQtqBXh37kR13n1CFOzAhiBzZlu1+FwnI2Xbq4yHo81Txzwb6nSmDMQufRVPUoMBWe8ZqSuC8OpdZiA==} + ponder@0.16.2: + resolution: {integrity: sha512-cUTtcrGxSt5ReJc0z7UuJWirHtQZ33KAepaIrp2W+Smzi8FJ2Odm5TedrtdlmHnaagUpl8eagpRUBsoXrPYXYw==} engines: {node: '>=18.14'} hasBin: true peerDependencies: @@ -16240,7 +16240,7 @@ snapshots: graphql: 16.11.0 hono: 4.11.7 - ponder@0.16.1(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.2(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.27.1 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -16322,7 +16322,7 @@ snapshots: - utf-8-validate - zod - ponder@0.16.1(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6): + ponder@0.16.2(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(hono@4.11.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6): dependencies: '@babel/code-frame': 7.27.1 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a51db9d6a..0b90d6812 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,7 @@ catalog: lucide-react: ^0.548.0 pg-connection-string: ^2.9.1 pino: 10.1.0 - ponder: 0.16.1 + ponder: 0.16.2 tailwindcss: ^4.1.18 tailwindcss-animate: ^1.0.7 tailwind-merge: ^3.4.0