Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/eip165-no-retry-empty-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ensnode/ensnode-sdk": patch
"ensindexer": patch
---

The EIP-165 `supportsInterface` probe (used to classify a Resolver's ENSIP-10 `extended` support at index time) now opts out of Ponder's empty-response retry. A `0x` ("returned no data") response from a pre-EIP-165 Resolver is a definitive "not supported", never transient — but Ponder's `context.client` previously retried it 9× with exponential backoff (~64s each), making a full index pathologically slow. The probe now fails fast (still resolving to `extended = false`).
5 changes: 5 additions & 0 deletions .changeset/enssdk-resolvable-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"enssdk": patch
---

Adds the `ResolvableName` branded type with `isResolvableName`/`asResolvableName` guards — an `InterpretedName` that can be DNS-encoded and resolved (no Encoded LabelHash segments, every label under 256 bytes). Also adds the `UnindexedDomainId` type and `makeUnindexedDomainId`; `DomainId` now includes `UnindexedDomainId`.
7 changes: 7 additions & 0 deletions .changeset/resolver-is-extended.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ensnode/ensdb-sdk": patch
"ensindexer": patch
"ensapi": patch
---

The `resolvers` table gains an `is_extended` column — whether the Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`) — populated at index time via a single cached `supportsInterface` RPC. The Omnigraph API exposes it as a new `Resolver.extended: Boolean!` field.
5 changes: 5 additions & 0 deletions .changeset/unindexed-domain-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": patch
---

**Omnigraph API** — Resolvable-but-unindexed Domains & Accounts (off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames) are now resolvable via `Query.domain(by: { name })` and `Query.account(by: { address })`, instead of returning `null`. This is supported by an additional concept, the `UnindexedDomain`, which expands the possible concrete types of the `Domain` interface.
4 changes: 2 additions & 2 deletions apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import di from "@/di";
import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span";
import {
forwardWalkDisjointNamegraph,
hasResolver,
walkResultRowHasResolver,
} from "@/lib/protocol-acceleration/forward-walk-disjoint-namegraph";

type FindResolverResult =
Expand Down Expand Up @@ -309,7 +309,7 @@ async function findResolverWithIndexENSv2(
const rows = await forwardWalkDisjointNamegraph(makeConcreteRegistryId(registry), path);

// the deepest Domain with an assigned Resolver is the active Resolver (ENSIP-10)
const active = rows.find(hasResolver);
const active = rows.find(walkResultRowHasResolver);
if (!active) return NULL_RESULT;

// map `active.depth` back to its name: getNameHierarchy is ordered leaf-first, while `depth`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,51 @@ const tracer = trace.getTracer("forward-walk-disjoint-namegraph");
export interface WalkResultRow {
domainId: DomainId;
depth: number;

/**
* The Registry this Domain lives in (i.e. its parent's Subregistry).
* */
registryId: RegistryId;

/**
* This Domain's assigned Resolver address (via DRR), or NULL if it has no Resolver.
*/
address: Address | null;

/**
* This Domain's assigned Resolver's chainId (via DRR), or NULL if it has no Resolver.
*/
chainId: ChainId | null;

/**
* Whether this Domain's assigned Resolver is an ENSIP-10 wildcard (`IExtendedResolver`). NULL when
* the Domain has no Resolver (mirrors `address`/`chainId`); a Resolver row always carries it.
*/
extended: boolean | null;

/**
* This Domain's materialized Canonical Path (root→leaf inclusive), or NULL when it is not in the
* Canonical Nametree. Used to build the canonical path of a resolvable-but-unindexed descendant.
*/
canonicalPath: DomainId[] | null;
}

/**
* Determines whether the WalkResultRow has a resolver set.
* Determines whether the WalkResultRow has a resolver set. When it does, `address`, `chainId`, and
* `extended` are all non-null together (every indexed Resolver referenced by a Domain-Resolver
* Relation has a Resolver row carrying `extended`).
*/
export const hasResolver = (
export const walkResultRowHasResolver = (
row: WalkResultRow,
): row is RequiredAndNotNull<WalkResultRow, "address" | "chainId"> =>
row.address !== null && row.chainId !== null;
): row is RequiredAndNotNull<WalkResultRow, "address" | "chainId" | "extended"> =>
row.address !== null && row.chainId !== null && row.extended !== null;

/**
* Walks a disjoint namegraph from `registryId` through `path` to identify each ancestor Domain,
* then LEFT JOINs each Domain to its Resolver via DRR and returns the full path ordered by depth
* DESC (deepest first). Resolver-less Domains are kept in the result with `resolver`/`chainId` set
* to NULL. Recursion terminates when the path is exhausted.
* then LEFT JOINs each Domain to its Resolver (via DRR, joined onward to the Resolver entity for
* its `extended` flag) and returns the full path ordered by depth DESC (deepest first).
* Resolver-less Domains are kept in the result with `address`/`chainId`/`extended` NULL.
* Recursion terminates when the path is exhausted.
Comment thread
shrugs marked this conversation as resolved.
Comment thread
shrugs marked this conversation as resolved.
*/
export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) {
if (path.length === 0) return [];
Expand All @@ -55,7 +83,9 @@ export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path:
WITH RECURSIVE path AS (
SELECT
${registryId}::text AS next_registry_id,
NULL::text AS registry_id,
NULL::text AS "domainId",
NULL::text[] AS canonical_path,
0 AS depth

UNION ALL
Expand All @@ -64,7 +94,10 @@ export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path:
-- NOTE: this walk specifically addresses non-canonical Domains as well, so it follows the
-- raw on-chain forward pointer domain.subregistry_id directly, without canonical edge authentication
d.subregistry_id AS next_registry_id,
-- the Registry this Domain lives in is the Registry we walked into to find it
path.next_registry_id AS registry_id,
d.id AS "domainId",
d.canonical_path AS canonical_path,
path.depth + 1
FROM path
JOIN ${ensIndexerSchema.domain} d
Expand All @@ -75,12 +108,17 @@ export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path:
)
SELECT
path."domainId",
drr.resolver AS "address",
drr.chain_id AS "chainId",
path.registry_id AS "registryId",
drr.resolver AS "address",
drr.chain_id AS "chainId",
r.is_extended AS "extended",
path.canonical_path AS "canonicalPath",
Comment thread
shrugs marked this conversation as resolved.
path.depth
FROM path
LEFT JOIN ${ensIndexerSchema.domainResolverRelation} drr
ON drr.domain_id = path."domainId"
LEFT JOIN ${ensIndexerSchema.resolver} r
ON r.chain_id = drr.chain_id AND r.address = drr.resolver
WHERE path."domainId" IS NOT NULL
ORDER BY path.depth DESC;
`),
Expand Down
64 changes: 30 additions & 34 deletions apps/ensapi/src/lib/resolution/forward-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { trace } from "@opentelemetry/api";
import {
type AccountId,
asInterpretedName,
asResolvableName,
ENS_ROOT_NAME,
type InterpretedName,
isNormalizedName,
type Node,
isResolvableName,
namehashInterpretedName,
type ResolvableName,
} from "enssdk";
import type { PublicClient } from "viem";

Expand Down Expand Up @@ -56,7 +56,7 @@ const tracer = trace.getTracer("forward-resolution");
* is the protocol-faithful path whenever ENSApi is not accelerating from indexed data.
*/
async function resolveViaUniversalResolver(
name: InterpretedName,
name: ResolvableName,
operations: Operation[],
publicClient: PublicClient,
): Promise<Operation[]> {
Expand Down Expand Up @@ -115,12 +115,14 @@ export async function resolveForward<SELECTION extends ResolverRecordsSelection>
selection: ForwardResolutionArgs<SELECTION>["selection"],
options: Omit<Parameters<typeof _resolveForward>[2], "registry">,
): Promise<ForwardResolutionResult<SELECTION>> {
// Invariant: Name must be an InterpretedName
const interpretedName = asInterpretedName(name);
// we want users to be able to provide unbranded arguments, so we need to enforce our branded
// invariants here
// Invariant: the input name must be Resolvable (and therefore Interpreted) to be resolved
const resolvableName = asResolvableName(asInterpretedName(name));

// NOTE: `resolveForward` is just `_resolveForward` with the enforcement that `registry` must
// initially be ENS Root Registry: see `_resolveForward` for additional context.
return _resolveForward(interpretedName, selection, {
return _resolveForward(resolvableName, selection, {
...options,
registry: getENSv1RootRegistry(di.context.namespace),
});
Expand All @@ -131,10 +133,26 @@ export async function resolveForward<SELECTION extends ResolverRecordsSelection>
* `registry`.
*/
async function _resolveForward<SELECTION extends ResolverRecordsSelection>(
name: InterpretedName,
name: ResolvableName,
selection: ForwardResolutionArgs<SELECTION>["selection"],
options: { registry: AccountId; accelerate: boolean; canAccelerate: boolean },
): Promise<ForwardResolutionResult<SELECTION>> {
//////////////////////////////////////////////////
// Validate Input
//////////////////////////////////////////////////

// Invariant: name must conform to ResolvableName
if (!isResolvableName(name)) {
throw new Error(`'${name}' must be resolvable.`);
}
Comment thread
shrugs marked this conversation as resolved.

// TODO: technically we could support resolving records for the root node, but because there
// are so many edge cases, this is something we should explicitly declare support for
// after we have test cases
if (name === ENS_ROOT_NAME) {
throw new Error(`Resolving records for the ENS Root Node ('') is not currently supported.`);
}

const {
registry: { chainId },
accelerate = false,
Expand All @@ -144,6 +162,8 @@ async function _resolveForward<SELECTION extends ResolverRecordsSelection>(
// `selection` may contain bigints (e.g. `abi: ContentType`); stringify safely for tracing.
const selectionString = toJson(selection);

const publicClient = di.context.rootChainPublicClient;

// trace for external consumers
return withEnsProtocolStep(
TraceableENSProtocol.ForwardResolution,
Expand All @@ -161,39 +181,15 @@ async function _resolveForward<SELECTION extends ResolverRecordsSelection>(
accelerate,
},
async (span) => {
//////////////////////////////////////////////////
// Validate Input
//////////////////////////////////////////////////

// TODO: technically InterpretedNames are not resolvable, since ENS contracts are not
// encoded-labelhash-aware; so we add a temporary additional constraint on name that it
// must be fully normalized (and therefore not contain encoded labelhash segments)
// (this will be improved in a future pr https://github.com/namehash/ensnode/issues/1920)
if (!isNormalizedName(name)) {
throw new Error(`'${name}' must be normalized to be resolvable.`);
}

// TODO: technically we could support resolving records for the root node, but because there
// are so many edge cases, this is something we should explicitly declare support for
// after we have test cases
if (name === ENS_ROOT_NAME) {
throw new Error(
`Resolving records for the ENS Root Node ('') is not currently supported.`,
);
}

const node: Node = namehashInterpretedName(name);
span.setAttribute("node", node);

// construct the set of resolve() operations indicated by node/selection
const node = namehashInterpretedName(name);
let operations = makeOperations(node, selection);
span.setAttribute("node", node);
span.setAttribute("operations", toJson(operations));

// if no operations were generated, this was an empty selection; give them what they asked for
if (operations.length === 0) return makeRecordsResponse<SELECTION>(operations);

const publicClient = di.context.rootChainPublicClient;

////////////////////////////////////////////////////////////////
/// 0 Non-Accelerated Resolution: delegate to UniversalResolver
////////////////////////////////////////////////////////////////
Expand Down
15 changes: 13 additions & 2 deletions apps/ensapi/src/lib/resolution/reverse-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import {
type Address,
asInterpretedName,
asResolvableName,
type CoinType,
coinTypeReverseLabel,
type DefaultableChainId,
evmChainIdToCoinType,
isInterpretedName,
isResolvableName,
reverseName,
} from "enssdk";
import { isAddress, isAddressEqual } from "viem";
Expand Down Expand Up @@ -68,14 +72,21 @@ export async function resolveReverse(
/////////////////////////////////////////////////////////

// Steps 1-3 — Resolve coinType-specific name record
const _reverseName = reverseName(address, coinType);
const { name } = await withProtocolStep(
const _reverseName = asResolvableName(asInterpretedName(reverseName(address, coinType)));
const { name: nameRecord } = await withProtocolStep(
TraceableENSProtocol.ReverseResolution,
ReverseResolutionProtocolStep.ResolveReverseName,
{ name: _reverseName },
() => resolveForward(_reverseName, REVERSE_RESOLUTION_SELECTION, options),
);

// Invariant: the name record must be Interpreted and Resolvable
// TODO: additional error types for this possibility
const name =
nameRecord && isInterpretedName(nameRecord) && isResolvableName(nameRecord)
? nameRecord
: null;

// Step 4 — Determine if name record exists
addProtocolStepEvent(
protocolTracingSpan,
Expand Down
Loading
Loading