Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d3276df
deps: ponder to latest
shrugs Jan 28, 2026
9e6becc
checkpoint: initial canonical name impl
shrugs Jan 28, 2026
6a3edab
fix: remove materialized canonical names
shrugs Jan 29, 2026
abe7d52
checkpoint: basic findDomains implementation and canonical path calcu…
shrugs Jan 29, 2026
5667c0d
checkpoint: v1 filter by name subquery
shrugs Jan 29, 2026
b69a70c
Merge branch 'main' into feat/canonical-name-heuristic
shrugs Jan 30, 2026
fdb9a07
checkpoint: implementing partial matches
shrugs Jan 30, 2026
ffd0e32
checkpoint: verifying partials implementation
shrugs Jan 30, 2026
02e28bd
feat: correctly connect bridged ensv2 resolver
shrugs Jan 30, 2026
d95f670
feat: bot nits, indexes, Account.domains where filter
shrugs Jan 30, 2026
78fef2d
Merge branch 'main' into feat/canonical-name-heuristic
shrugs Jan 30, 2026
fb23495
fix: defer contract loading for ensv2
shrugs Jan 30, 2026
fbe258d
fix: remove botched escaping
shrugs Jan 30, 2026
8f80f4e
fix: suble isInterpretedLabel bug, add tests
shrugs Jan 30, 2026
b1c90ba
bot fixes and tests
shrugs Jan 30, 2026
1492b04
docs(changeset): ENSV2 blah blah
shrugs Jan 30, 2026
96cc186
better changeset
shrugs Jan 30, 2026
dc5c1ec
Merge branch 'main' into feat/canonical-name-heuristic
shrugs Feb 1, 2026
1a1cdf9
fix: unset canonical names when subregistry is unset
shrugs Feb 1, 2026
10f7b53
fix: dataload the canonical paths
shrugs Feb 1, 2026
1fd80db
fix: switch back to new Param
shrugs Feb 1, 2026
58e0cbd
fix: update comment so bots stop complaining
shrugs Feb 1, 2026
65d6ef9
fix dataloader error handling, more comments for bots
shrugs Feb 1, 2026
c8af3cc
Merge branch 'main' into feat/canonical-name-heuristic
shrugs Feb 2, 2026
86f23fe
fix: lowercase address
shrugs Feb 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/eight-beans-behave.md
Original file line number Diff line number Diff line change
@@ -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" })`
Comment on lines +7 to +8
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset examples show "example.et" which appears to be a typo or an incomplete example. Since this feature supports partial name matching, this might be intentional to demonstrate partial matching, but it could be confusing. Consider clarifying whether this is showing a partial match example or using a complete example like "example.eth".

Suggested change
- `Query.domains(where: { name?: "example.et", owner?: "0xdead...beef" })`
- `Account.domains(where?: { name: "example.et" })`
- `Query.domains(where: { name?: "example.eth", owner?: "0xdead...beef" })`
- `Account.domains(where?: { name: "example.eth" })`

Copilot uses AI. Check for mistakes.
8 changes: 3 additions & 5 deletions apps/ensapi/src/graphql-api/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof context>;
Scalars: {
BigInt: { Input: bigint; Output: bigint };
Address: { Input: Address; Output: Address };
Expand Down
35 changes: 35 additions & 0 deletions apps/ensapi/src/graphql-api/context.ts
Original file line number Diff line number Diff line change
@@ -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<ENSv1DomainId, CanonicalPath | null>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);

const createV2CanonicalPathLoader = () =>
new DataLoader<ENSv2DomainId, CanonicalPath | null>(async (domainIds) =>
Comment on lines +15 to +20
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DataLoader return type is declared as CanonicalPath | null, but the batch function uses .catch(errorAsValue) which converts errors to Error instances. This means the actual return type is Error | CanonicalPath | null. DataLoaders should be typed to match their actual return values. The type should be DataLoader<ENSv1DomainId, CanonicalPath | null | Error> to accurately reflect what the loader can return.

Suggested change
new DataLoader<ENSv1DomainId, CanonicalPath | null>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);
const createV2CanonicalPathLoader = () =>
new DataLoader<ENSv2DomainId, CanonicalPath | null>(async (domainIds) =>
new DataLoader<ENSv1DomainId, CanonicalPath | null | Error>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);
const createV2CanonicalPathLoader = () =>
new DataLoader<ENSv2DomainId, CanonicalPath | null | Error>(async (domainIds) =>

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +20
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DataLoader return type is declared as CanonicalPath | null, but the batch function uses .catch(errorAsValue) which converts errors to Error instances. This means the actual return type is Error | CanonicalPath | null. DataLoaders should be typed to match their actual return values. The type should be DataLoader<ENSv2DomainId, CanonicalPath | null | Error> to accurately reflect what the loader can return.

Suggested change
new DataLoader<ENSv1DomainId, CanonicalPath | null>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);
const createV2CanonicalPathLoader = () =>
new DataLoader<ENSv2DomainId, CanonicalPath | null>(async (domainIds) =>
new DataLoader<ENSv1DomainId, CanonicalPath | null | Error>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);
const createV2CanonicalPathLoader = () =>
new DataLoader<ENSv2DomainId, CanonicalPath | null | Error>(async (domainIds) =>

Copilot uses AI. Check for mistakes.
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(),
},
});
301 changes: 301 additions & 0 deletions apps/ensapi/src/graphql-api/lib/find-domains.ts
Original file line number Diff line number Diff line change
@@ -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<DomainId>`${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<DomainId>`${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,
Comment on lines +134 to +137
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LIKE operator is being used with user input without escaping special characters like %, _, or . PostgreSQL LIKE treats % as a wildcard for any characters and _ as a wildcard for a single character. User input containing these characters could lead to unintended matches or performance issues. Consider sanitizing the partial input by escaping these special characters, or use a different matching strategy.

Copilot uses AI. Check for mistakes.
),
);

// 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<ENSv1DomainId>`${schema.v1Domain.id}`.as("leafId"),
headId: sql<ENSv1DomainId>`${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<ENSv1DomainId>`v1_path_check.leaf_id`.as("leafId"),
headId: sql<ENSv1DomainId>`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<ENSv2DomainId>`${schema.v2Domain.id}`.as("leafId"),
headId: sql<ENSv2DomainId>`${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<ENSv2DomainId>`v2_path_check.leaf_id`.as("leafId"),
headId: sql<ENSv2DomainId>`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}]
Comment on lines +274 to +277
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive CTE in v2DomainsByLabelHashPath uses INNER JOINs on registryCanonicalDomain in both the base case and recursive step. This means domains without an entry in registryCanonicalDomain will be excluded from results. While the comment explains this is intentional for canonical path matching, consider the operational implications: if registryCanonicalDomain entries are missing or delayed during indexing, valid domains might not appear in search results until those entries are created. This could affect user experience during initial indexing or if there are gaps in the data.

Copilot uses AI. Check for mistakes.

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");
}
Loading
Loading