Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
---

**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 namegraph.
10 changes: 8 additions & 2 deletions apps/ensapi/src/lib/resolution/forward-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type AccountId,
asInterpretedName,
ENS_ROOT_NAME,
ENS_ROOT_NODE,
type InterpretedName,
isNormalizedName,
type Node,
Expand Down Expand Up @@ -239,14 +240,19 @@ async function _resolveForward<SELECTION extends ResolverRecordsSelection>(
/////////////////////////////////////
if (accelerate && canAccelerate) {
const resolver = { chainId, address: activeResolver };
const bridgesTo = isBridgedResolver(config.namespace, resolver);
// Forward Resolution recurses with the bridged target's AccountId; `originatingNode`
// doesn't affect that projection, so a sentinel suffices.
const bridgesTo = isBridgedResolver(config.namespace, resolver, ENS_ROOT_NODE);
if (bridgesTo) {
return withEnsProtocolStep(
TraceableENSProtocol.ForwardResolution,
ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver,
{},
() =>
_resolveForward(name, selection, { ...options, registry: bridgesTo.registry }),
_resolveForward(name, selection, {
...options,
registry: { chainId: bridgesTo.chainId, address: bridgesTo.address },
}),
);
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { and, eq, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { eq, sql } from "drizzle-orm";
import type { DomainId, NormalizedAddress, RegistryId } from "enssdk";

import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
Expand All @@ -12,41 +11,33 @@ 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 }`.
*
* All downstream filters (owner, parent, registry, name, canonical) operate on this shape.
*/
export function domainsBase() {
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"),
parentId: sql<DomainId | null>`${ensIndexerSchema.registry.canonicalDomainId}`.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.
// parent: materialized via `registry.canonicalDomainId`. The bidirectional invariant
// (`Domain.canonicalSubregistryId` ↔ `Registry.canonicalDomainId`) guarantees consistency,
// so no edge-auth join is required.
.leftJoin(
ensIndexerSchema.registryCanonicalDomain,
eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId),
)
.leftJoin(
parentDomain,
and(
eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId),
eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId),
),
ensIndexerSchema.registry,
eq(ensIndexerSchema.registry.id, ensIndexerSchema.domain.registryId),
)
// join label for labelHash/sortableLabel
.leftJoin(
Expand All @@ -67,6 +58,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,18 @@ 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.
*
* Reads the materialized `domain.canonical` flag, which is maintained at index time by the
* canonicality db helpers (Registry/Domain bidirectional pointers + cascading flips).
*/
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,9 +47,9 @@ 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 registry.canonicalDomainId.
// 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 materialized canonical edge, 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
Expand All @@ -64,34 +64,34 @@ 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 registry.canonical_domain_id. The bidirectional invariant guarantees the edge
-- is consistent without a separate edge-auth join.
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} r
ON r.id = d.registry_id
JOIN ${ensIndexerSchema.domain} parent
ON parent.id = rcd.domain_id AND parent.subregistry_id = d.registry_id
ON parent.id = r.canonical_domain_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 registry.canonical_domain_id, 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} pr
ON pr.id = pd.registry_id
JOIN ${ensIndexerSchema.domain} np
ON np.id = rcd.domain_id AND np.subregistry_id = pd.registry_id
ON np.id = pr.canonical_domain_id
WHERE upward_check.depth < ${pathLength}
AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth]
)
Expand Down
67 changes: 37 additions & 30 deletions apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
import config from "@/config";

import { Param, sql } from "drizzle-orm";
import { sql } from "drizzle-orm";
import type { CanonicalPath, DomainId, RegistryId } from "enssdk";

import { getRootRegistryIds } from "@ensnode/ensnode-sdk";

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

/**
* Maximum depth to walk before throwing. ENS names have no formal depth limit, but at the
* Omnigraph API boundary we cap traversal to fail loudly rather than risk an unbounded
* recursive CTE if the canonical-tree invariant is ever violated. The cap is detected via an
* extra row beyond `MAX_DEPTH`; if that row is produced we throw rather than silently truncate.
*/
const MAX_DEPTH = 16;

/**
* Provide the canonical parents for a Domain via reverse traversal of the namegraph.
*
* Traversal walks `domain → registry → canonical parent domain` via the
* {@link registryCanonicalDomain} table and terminates at any top-level Root Registry configured
* for the namespace (all concrete ENSv1Registries plus the ENSv2 Root when defined). Returns
* `null` when the resulting path does not terminate at a Root Registry (i.e. the Domain is not
* canonical).
* Walks `domain → registry → registry.canonicalDomainId` upward via the materialized canonical
* edge until the registry has no canonical parent (root). Returns `null` when the input Domain is
* not itself canonical (`domain.canonical = false`).
*/
export async function getCanonicalPath(domainId: DomainId): Promise<CanonicalPath | null> {
const rootRegistryIds = getRootRegistryIds(config.namespace);

// NOTE: using new Param to bind the array as a single text[] parameter, per
// https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070
const rootRegistryIdsArray = sql`${new Param(rootRegistryIds)}::text[]`;
// Short-circuit non-canonical Domains via the materialized flag.
const domain = await ensDb.query.domain.findFirst({
where: (t, { eq }) => eq(t.id, domainId),
columns: { canonical: true },
});
if (!domain) {
throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`);
}
if (!domain.canonical) return null;

const result = await ensDb.execute(sql`
WITH RECURSIVE upward AS (
Expand All @@ -37,21 +41,20 @@ export async function getCanonicalPath(domainId: DomainId): Promise<CanonicalPat

UNION ALL

-- Step upward: domain -> current registry's canonical domain (parent).
-- 1. Recursion stops as soon as we reach a Root Registry or there is no parent to traverse.
-- 2. MAX_DEPTH guards against corrupted state.
-- 3. The pd.subregistry_id = upward.registry_id clause performs edge authentication.
-- Step upward: domain current registry's canonical parent domain.
-- The bidirectional invariant guarantees consistency, so no edge-auth is needed.
-- We allow recursion to one row beyond MAX_DEPTH so we can detect (and throw on) a
-- legitimate path that exceeds the cap, rather than silently truncating it.
SELECT
pd.id AS domain_id,
pd.registry_id,
upward.depth + 1
FROM upward
JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd
ON rcd.registry_id = upward.registry_id
JOIN ${ensIndexerSchema.registry} r
ON r.id = upward.registry_id
JOIN ${ensIndexerSchema.domain} pd
Comment on lines 13 to 55
ON pd.id = rcd.domain_id AND pd.subregistry_id = upward.registry_id
WHERE upward.depth < ${MAX_DEPTH}
AND upward.registry_id <> ALL(${rootRegistryIdsArray})
ON pd.id = r.canonical_domain_id
WHERE upward.depth <= ${MAX_DEPTH}
)
SELECT *
FROM upward
Expand All @@ -60,15 +63,19 @@ export async function getCanonicalPath(domainId: DomainId): Promise<CanonicalPat

const rows = result.rows as { domain_id: DomainId; registry_id: RegistryId }[];

// Defense-in-depth: the existence + canonical check above guarantees the CTE base case yields
// at least one row, so this branch is unreachable under correct invariant maintenance.
if (rows.length === 0) {
throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`);
throw new Error(
`Invariant(getCanonicalPath): DomainId '${domainId}' is canonical but produced no upward path.`,
);
}

// Canonical iff the tip of the path terminates at any of the namespace's Root Registries.
const tld = rows[rows.length - 1];
const isCanonical = rootRegistryIds.includes(tld.registry_id);

if (!isCanonical) return null;
if (rows.length > MAX_DEPTH) {
throw new Error(
`Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_DEPTH}.`,
);
}

return rows.map((row) => row.domain_id);
Comment thread
shrugs marked this conversation as resolved.
}
Loading
Loading