Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
95f2e2a
feat: update abis to latest
shrugs May 4, 2026
c2404ef
checkpoint: initial canonicality implementation
shrugs May 5, 2026
3b8eeca
feat: expose canonicality in omnigraph
shrugs May 5, 2026
4537ed6
test: assert canonicality field on Domain and Registry
shrugs May 5, 2026
1426de2
fix: bot review feedback (greploop iter 1)
shrugs May 6, 2026
9e8b6fa
test: align integration tests with materialized canonical model
shrugs May 6, 2026
5995fc2
fix: bot review feedback (greploop iter 2)
shrugs May 6, 2026
aad2f1c
fix: re-add MAX_DEPTH guard to getCanonicalPath
shrugs May 6, 2026
499a2f4
docs: note that TOCTOU is not a concern inside Ponder event handlers
shrugs May 6, 2026
3149920
checkpoint: pre-canonicality parallel table extraction
shrugs May 6, 2026
856d30e
checkpoint: post parallel refactor, pre-reconciliation
shrugs May 6, 2026
fd0bc68
checkpoint: canonicality refactor testing
shrugs May 6, 2026
11e582d
feat: fixup tests
shrugs May 6, 2026
2ad23ae
fix: regenerate gqlschema
shrugs May 6, 2026
4bc1b48
fix: address bot review feedback (loop 1)
shrugs May 6, 2026
daaf3ea
fix: changeset
shrugs May 6, 2026
264af22
fix: use maybeGetENSv2RootRegistryId for v1-only namespaces
shrugs May 6, 2026
cc60281
checkpoint: custom sql
shrugs May 7, 2026
bcf0ef8
docs: address bot review feedback on canonicality docstrings
shrugs May 8, 2026
56b1765
docs: address remaining bot review feedback
shrugs May 8, 2026
57bc265
docs: changeset for @ensnode/ensnode-sdk breaking changes
shrugs May 8, 2026
ce477b0
feat(ensapi)!: drop DomainsWhereInput.canonical filter
shrugs May 8, 2026
7bec55d
fix: update comments and gate bridged resolver canonicality
shrugs May 8, 2026
7c1bc5c
fix: update comments
shrugs May 8, 2026
76fc32b
docs: address remaining bot review nits
shrugs May 8, 2026
df23444
fix: address bot review nits + memoize bridged resolver configs
shrugs May 8, 2026
82e3898
refactor: collapse bridged resolver config cache into single function
shrugs May 8, 2026
e77f7fc
fix: remove unnecessary changeset
shrugs May 8, 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
5 changes: 5 additions & 0 deletions .changeset/canonical-fields-omnigraph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
Comment thread
shrugs marked this conversation as resolved.
---

**Omnigraph**: expose `Domain.canonical` and `Registry.canonical` on the Omnigraph schema. Both are non-null `Boolean!` fields indicating whether the entity participates in the Canonical Nametree.
Comment thread
shrugs marked this conversation as resolved.
5 changes: 5 additions & 0 deletions .changeset/drop-domains-where-canonical.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

**Omnigraph (breaking)**: drop the `canonical: Boolean = false` field from `DomainsWhereInput` (used by `Query.domains`). Every nameable Domain is canonical by definition, so the filter was redundant; the query now always scopes to Canonical Domains. Consumers passing `where: { canonical: true }` should drop the field; consumers relying on `canonical: false` (or default) to surface non-canonical Domains via this query no longer can — read `Domain.canonical` directly or scope by `Account.domains` / `Registry.domains` instead.
5 changes: 5 additions & 0 deletions .changeset/ensnode-sdk-root-registry-bridged-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

**Breaking (`@ensnode/ensnode-sdk`)**: `getRootRegistryIds` is removed; use the new `isRootRegistryId(namespace, registryId)` predicate to test root membership instead. `getRootRegistryId` (singular, "preferred root") is unchanged.
5 changes: 5 additions & 0 deletions .changeset/resolver-bridged-registry-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

**Omnigraph (breaking)**: `Resolver.bridged` is no longer an `AccountId` scalar; it now returns the bridged target `Registry` interface. Consumers should change their selection from `bridged` (scalar) to `bridged { ... }` (Registry interface) — the new shape exposes the full `Registry` and allows navigation into the bridged sub-registry's canonical Domain etc.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Fail fast and loudly on invalid inputs.
- Schema changes never require a migration step. Ponder only runs fully-compatible indexes against existing schemas; otherwise the index is dropped and rebuilt from scratch. Do not propose, plan, or write migration code for the ensindexer drizzle schema.
- Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar.
- Access entities by primary key only. Ponder's cache layer keys on PK; filters or complex selects force a flush to Postgres and are extremely unperformant in the hot path. If you need a non-PK lookup at index time, design the schema so the lookup key is the primary key.
- TOCTOU is never a concern inside Ponder event handlers. Ponder serializes events per chain and runs each handler in a transaction, so a read-modify-write against the same row from two handlers cannot interleave. Bot reviewers will sometimes flag this — dismiss those comments. (Note: TOCTOU IS a concern in ENSApi when reading `ensIndexerSchema` — both parallel and serial reads can see different snapshots of the indexer's writes.)

## Workflow

Expand Down
13 changes: 8 additions & 5 deletions apps/ensapi/src/lib/resolution/forward-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
ForwardResolutionProtocolStep,
type ForwardResolutionResult,
getDatasourceContract,
getENSv1Registry,
getENSv1RootRegistry,
maybeGetDatasourceContract,
PluginName,
type ResolverRecordsSelection,
Expand Down Expand Up @@ -99,7 +99,7 @@ export async function resolveForward<SELECTION extends ResolverRecordsSelection>
// initially be ENS Root Registry: see `_resolveForward` for additional context.
return _resolveForward(interpretedName, selection, {
...options,
registry: getENSv1Registry(config.namespace),
registry: getENSv1RootRegistry(config.namespace),
});
}

Expand Down Expand Up @@ -239,14 +239,17 @@ async function _resolveForward<SELECTION extends ResolverRecordsSelection>(
/////////////////////////////////////
if (accelerate && canAccelerate) {
const resolver = { chainId, address: activeResolver };
const bridgesTo = isBridgedResolver(config.namespace, resolver);
if (bridgesTo) {
const bridged = isBridgedResolver(config.namespace, resolver);
if (bridged) {
return withEnsProtocolStep(
TraceableENSProtocol.ForwardResolution,
ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver,
{},
() =>
_resolveForward(name, selection, { ...options, registry: bridgesTo.registry }),
_resolveForward(name, selection, {
...options,
registry: bridged.targetRegistry,
}),
);
}

Expand Down
8 changes: 8 additions & 0 deletions apps/ensapi/src/omnigraph-api/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* The maximum depth of a name supported by Omnigraph traversal libs, to avoid runaway walks across
* the namegraph. ENS names have no formal depth limit, but we cap traversal to fail loudly rather
* than risk an unbounded recursive CTE.
*
* If this depth turns out to be problematic in practice, we can increase it as necessary.
*/
export const MAX_SUPPORTED_NAME_DEPTH = 16;

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,44 @@ export type BaseDomainSet = ReturnType<typeof domainsBase>;
/**
* Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata.
*
* Returns `{ domainId, ownerId, registryId, parentId, labelHash, sortableLabel }` where `parentId`
* is derived via the domain's registry → canonical domain link (`registryCanonicalDomain`)
* and `sortableLabel` is the domain's own interpreted label, used for NAME ordering, and can be
* overridden by later layers.
* Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`.
* - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of
* this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by
* `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to
* the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree.
* - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering
* - all other values are directly sourced from Domain
*
* All downstream filters (owner, parent, registry, name, canonical) operate on this shape.
*/
export function domainsBase() {
// alias for parent Registry / parent Domain joins so we can reference them distinctly from
// the base Domain's own `registryId` column.
const parentRegistry = alias(ensIndexerSchema.registry, "parentRegistry");
const parentDomain = alias(ensIndexerSchema.domain, "parentDomain");

return (
ensDb
.select({
domainId: sql<DomainId>`${ensIndexerSchema.domain.id}`.as("domainId"),
ownerId: sql<NormalizedAddress | null>`${ensIndexerSchema.domain.ownerId}`.as("ownerId"),
registryId: sql<RegistryId>`${ensIndexerSchema.domain.registryId}`.as("registryId"),
parentId: sql<DomainId | null>`${parentDomain.id}`.as("parentId"),
canonical: sql<boolean>`${ensIndexerSchema.domain.canonical}`.as("canonical"),
labelHash: sql<string>`${ensIndexerSchema.domain.labelHash}`.as("labelHash"),
sortableLabel: sql<string | null>`${ensIndexerSchema.label.interpreted}`.as(
"sortableLabel",
),
})
.from(ensIndexerSchema.domain)
// parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain.
// The `parentDomain.subregistryId = domain.registryId` clause performs edge authentication.
.leftJoin(
ensIndexerSchema.registryCanonicalDomain,
eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId),
)
// walk up to the parent Registry by this Domain's `registryId`, then to the parent Domain
// it points at, requiring `parentDomain.subregistryId` to agree back. The two joins +
// agreement predicate are the bidirectional canonical-edge check.
.leftJoin(parentRegistry, eq(parentRegistry.id, ensIndexerSchema.domain.registryId))
.leftJoin(
parentDomain,
and(
eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId),
eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId),
eq(parentDomain.id, parentRegistry.canonicalDomainId),
eq(parentDomain.subregistryId, parentRegistry.id),
),
)
// join label for labelHash/sortableLabel
Expand All @@ -67,6 +71,7 @@ export function selectBase(base: BaseDomainSet) {
ownerId: base.ownerId,
registryId: base.registryId,
parentId: base.parentId,
canonical: base.canonical,
labelHash: base.labelHash,
sortableLabel: base.sortableLabel,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import { eq } from "drizzle-orm";

import { ensDb } from "@/lib/ensdb/singleton";

import { getCanonicalRegistriesCTE } from "../canonical-registries-cte";
import { type BaseDomainSet, selectBase } from "./base-domain-set";

/**
* Filter a base domain set to only include Canonical Domains.
*/
export function filterByCanonical(base: BaseDomainSet) {
const canonicalRegistries = getCanonicalRegistriesCTE();

return ensDb
.select(selectBase(base))
.from(base)
.innerJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId))
.where(eq(base.canonical, true))
.as("baseDomains");
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const FILTER_BY_NAME_MAX_DEPTH = 8;
* - 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 {@link registryCanonicalDomain}.
* Algorithm: Start from the deepest child (leaf) and traverse UP via `registry.canonicalDomainId`.
*/
function domainsByLabelHashPath(labelHashPath: LabelHashPath) {
// If no concrete path, return all domains (leaf = head = self)
Expand All @@ -47,13 +47,15 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) {
const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`;
const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`;

// Recursive CTE starting from the deepest child and traversing UP via registryCanonicalDomain.
// Recursive CTE starting from the deepest child and traversing UP via the bidirectional
// canonical-edge agreement (`registries.canonical_domain_id = domains.id` AND
// `domains.subregistry_id = registries.id`).
// 1. Start with domains matching the leaf labelHash (deepest child)
// 2. Recursively join parents via rcd, verifying each ancestor's labelHash
// 2. Recursively join parents via the agreement check, verifying each ancestor's labelHash
// 3. Return both the leaf (for result/ownership) and head (for partial match)
//
// NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete
// canonical path to the searched FQDN.
// canonical path to the searched `labelHashPath`.
return ensDb
.select({
// https://github.com/drizzle-team/drizzle-orm/issues/1242
Expand All @@ -64,34 +66,36 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) {
sql`(
WITH RECURSIVE upward_check AS (
-- Base case: find the deepest children (leaves of the concrete path) and walk one step
-- up via registryCanonicalDomain. The parent.subregistry_id = d.registry_id clause
-- performs edge authentication.
-- up via the agreement check (parent_registry.canonical_domain_id = parent.id AND
-- parent.subregistry_id = parent_registry.id).
SELECT
d.id AS leaf_id,
parent.id AS current_id,
1 AS depth
FROM ${ensIndexerSchema.domain} d
JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd
ON rcd.registry_id = d.registry_id
JOIN ${ensIndexerSchema.registry} parent_registry
ON parent_registry.id = d.registry_id
JOIN ${ensIndexerSchema.domain} parent
ON parent.id = rcd.domain_id AND parent.subregistry_id = d.registry_id
ON parent.id = parent_registry.canonical_domain_id
AND parent.subregistry_id = parent_registry.id
WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}]

UNION ALL

-- Recursive step: traverse UP via registryCanonicalDomain, verifying each ancestor's
-- labelHash. The np.subregistry_id = pd.registry_id clause performs edge authentication.
-- Recursive step: traverse UP via the agreement check, verifying each ancestor's
-- labelHash.
SELECT
upward_check.leaf_id,
np.id AS current_id,
upward_check.depth + 1
FROM upward_check
JOIN ${ensIndexerSchema.domain} pd
ON pd.id = upward_check.current_id
JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd
ON rcd.registry_id = pd.registry_id
JOIN ${ensIndexerSchema.registry} pdr
ON pdr.id = pd.registry_id
JOIN ${ensIndexerSchema.domain} np
ON np.id = rcd.domain_id AND np.subregistry_id = pd.registry_id
ON np.id = pdr.canonical_domain_id
AND np.subregistry_id = pdr.id
WHERE upward_check.depth < ${pathLength}
AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth]
)
Expand Down Expand Up @@ -124,7 +128,7 @@ export function filterByName(base: BaseDomainSet, name?: string | null) {
}

if (concrete.length === 0) {
// No path traversal — sortableLabel is already the domain's own label from the base set
// no path traversal — sortableLabel is already the domain's own label from the base set
return ensDb
.select(selectBase(base))
.from(base)
Expand All @@ -136,20 +140,20 @@ export function filterByName(base: BaseDomainSet, name?: string | null) {
.as("baseDomains");
}

// Build path traversal CTE over the unified `domain` table.
// build path traversal CTE over the unified `domain` table.
const labelHashPath = interpretedLabelsToLabelHashPath(concrete);
const pathResults = domainsByLabelHashPath(labelHashPath);

// Alias for head domain lookup (to get headLabelHash for label join)
// alias for head domain lookup (to get headLabelHash for label join)
const headDomain = alias(ensIndexerSchema.domain, "headDomain");
const headLabel = alias(ensIndexerSchema.label, "headLabel");

// Join base set with path results, look up head domain's label, override sortableLabel.
// The inner join on pathResults scopes results to domains matching the concrete path.
// join base set with path results, look up head domain's label, override sortableLabel
// the INNER JOIN on pathResults scopes results to domains matching the concrete path
return ensDb
.select({
...selectBase(base),
// Override sortableLabel with head domain's label for NAME ordering
// override sortableLabel with head domain's label for NAME ordering
sortableLabel: sql<string | null>`${headLabel.interpreted}`.as("sortableLabel"),
})
.from(base)
Expand Down
Loading
Loading