diff --git a/.changeset/eip165-no-retry-empty-response.md b/.changeset/eip165-no-retry-empty-response.md new file mode 100644 index 0000000000..fb8744167a --- /dev/null +++ b/.changeset/eip165-no-retry-empty-response.md @@ -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`). diff --git a/.changeset/enssdk-resolvable-name.md b/.changeset/enssdk-resolvable-name.md new file mode 100644 index 0000000000..a2448f112e --- /dev/null +++ b/.changeset/enssdk-resolvable-name.md @@ -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`. diff --git a/.changeset/resolver-is-extended.md b/.changeset/resolver-is-extended.md new file mode 100644 index 0000000000..5fe8483471 --- /dev/null +++ b/.changeset/resolver-is-extended.md @@ -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. diff --git a/.changeset/unindexed-domain-resolution.md b/.changeset/unindexed-domain-resolution.md new file mode 100644 index 0000000000..bdcb5b27d5 --- /dev/null +++ b/.changeset/unindexed-domain-resolution.md @@ -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. diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index ed9f2b0cf1..e79a2ed22b 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -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 = @@ -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` diff --git a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts index c9f1308c33..9e5f0a21e2 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts @@ -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 => - row.address !== null && row.chainId !== null; +): row is RequiredAndNotNull => + 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. */ export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) { if (path.length === 0) return []; @@ -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 @@ -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 @@ -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", 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; `), diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index d5f12cec33..155ba42a4f 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -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"; @@ -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 { @@ -115,12 +115,14 @@ export async function resolveForward selection: ForwardResolutionArgs["selection"], options: Omit[2], "registry">, ): Promise> { - // 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), }); @@ -131,10 +133,26 @@ export async function resolveForward * `registry`. */ async function _resolveForward( - name: InterpretedName, + name: ResolvableName, selection: ForwardResolutionArgs["selection"], options: { registry: AccountId; accelerate: boolean; canAccelerate: boolean }, ): Promise> { + ////////////////////////////////////////////////// + // Validate Input + ////////////////////////////////////////////////// + + // Invariant: name must conform to ResolvableName + if (!isResolvableName(name)) { + throw new Error(`'${name}' must 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 { registry: { chainId }, accelerate = false, @@ -144,6 +162,8 @@ async function _resolveForward( // `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, @@ -161,39 +181,15 @@ async function _resolveForward( 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(operations); - const publicClient = di.context.rootChainPublicClient; - //////////////////////////////////////////////////////////////// /// 0 Non-Accelerated Resolution: delegate to UniversalResolver //////////////////////////////////////////////////////////////// diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index 3b18b585d8..652c2e3f81 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -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"; @@ -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, diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index 979d0b7a14..f945782079 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -1,6 +1,5 @@ import { trace } from "@opentelemetry/api"; import { - type DomainId, ENS_ROOT_NAME, type InterpretedName, interpretedLabelsToLabelHashPath, @@ -22,7 +21,8 @@ import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { forwardWalkDisjointNamegraph, - hasResolver, + type WalkResultRow, + walkResultRowHasResolver, } from "@/lib/protocol-acceleration/forward-walk-disjoint-namegraph"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; @@ -35,24 +35,39 @@ const tracer = trace.getTracer("get-domain-by-interpreted-name"); const MAX_HOP_DEPTH = 3; /** - * Domain lookup by Interpreted Name by traversing the namegraph. + * The result of walking the namegraph for a Name: the terminal disjoint namegraph's path (after all + * Bridged Resolver / ENSv1Resolver / ENSv2Resolver hops resolve), plus whether the leaf was an + * exact match. * - * Walks resolution from the primary Root Registry (ENSv2 Root when defined, otherwise the ENSv1 - * concrete Root), following Bridged Resolvers as necessary, returning the leaf Domain upon an exact - * match. We only operate over indexed data with acceleration implicitly enabled; if the traversal - * of the namegraph cannot be accelerated, this function won't be able to identify the Domain - * indicated by `name`. + * Callers interpret this: + * - `exact` → the Name maps to an indexed leaf Domain (`rows[0].domainId`). + * - not `exact`, but the deepest Resolver in `rows` is an ENSIP-10 wildcard (`extended`) → the Name + * is resolvable-but-unindexed (an UnindexedDomain). + * - otherwise → the Name is not resolvable (the deepest Resolver, if any, does not support ENSIP-10). + */ +export interface NamegraphWalkResult { + /** The terminal disjoint namegraph's path rows, ordered by depth DESC (deepest first). */ + rows: WalkResultRow[]; + /** Whether the deepest row exactly matches the full `path` (i.e. the leaf Domain is indexed). */ + exact: boolean; +} + +/** + * Walks the namegraph for an Interpreted Name and returns the {@link NamegraphWalkResult}. + * + * Walks resolution from the primary Root Registry (ENSv2 Root when defined, otherwise the ENSv1 Root), + * following Bridged Resolvers as necessary. We only operate over indexed data which is fully available + * for the ENS Root Chain. * - * Unlike Forward Resolution, this function does not check registration expiry, so callers can - * address Domains regardless of expiry status. This means that a Domain identified by this function - * may not be accessible by Forward Resolution: an expired Domain in a PermissionedRegistry does not - * exist in the context of Forward Resolution. + * Unlike Forward Resolution, this walk does not check registration expiry, so callers can address + * Domains regardless of expiry status. This means that a Domain identified by this walk may not be + * accessible by Forward Resolution: an expired Domain in a PermissionedRegistry does not exist in + * the context of Forward Resolution. The Domains returned by this function are Addressable but + * not necessarily Resolvable. * * @dev depends on the Protocol Acceleration plugin which is a hard requirement for the Omnigraph API usage. */ -export async function getDomainIdByInterpretedName( - name: InterpretedName, -): Promise { +export async function forwardWalkNamegraph(name: InterpretedName): Promise { if (name === ENS_ROOT_NAME) { throw new Error(`Invariant: the ENS Root Name ('') is not addressable.`); } @@ -66,8 +81,8 @@ export async function getDomainIdByInterpretedName( throw new Error(`Invariant: Name '${name}' exceeds maximum depth ${MAX_SUPPORTED_NAME_DEPTH}.`); } - return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - forwardWalkNamegraph(getRootRegistryId(di.context.namespace), path), + return withActiveSpanAsync(tracer, "forwardWalkNamegraph", { name }, () => + walkNamegraphFromRegistry(getRootRegistryId(di.context.namespace), path), ); } @@ -86,18 +101,18 @@ export async function getDomainIdByInterpretedName( * the ENS Root Chain's linea.eth instead of the Linea Chain's shadowed linea.eth (which, formally, * doesn't exist in the eyes of Resolution). */ -async function forwardWalkNamegraph( +async function walkNamegraphFromRegistry( registryId: RegistryId, path: LabelHashPath, depth = 0, -): Promise { +): Promise { if (depth > MAX_HOP_DEPTH) { - throw new Error(`Invariant(forwardWalkNamegraph): Hop depth exceeded: ${depth}`); + throw new Error(`Invariant(walkNamegraphFromRegistry): Hop depth exceeded: ${depth}`); } // walk the disjoint namegraph by indicated by `registryId` through `path` const rows = await forwardWalkDisjointNamegraph(registryId, path); - if (rows.length === 0) return null; + if (rows.length === 0) return { rows, exact: false }; // rows are ORDER BY depth DESC, so deepest element is rows[0] const deepest = rows[0]; @@ -107,10 +122,10 @@ async function forwardWalkNamegraph( // if the exact match has a Resolver set, we can return it outright // NOTE: this also encodes the "prefer linea.eth on the ENS Root Chain" behavior - if (exact && hasResolver(deepest)) return deepest.domainId; + if (exact && walkResultRowHasResolver(deepest)) return { rows, exact: true }; // otherwise, identify the deepest element with a Resolver - const deepestResolver = rows.find(hasResolver); + const deepestResolver = rows.find(walkResultRowHasResolver); if (deepestResolver) { const resolverEq = makeContractMatcher(di.context.namespace, deepestResolver); // Bridged Resolvers @@ -123,7 +138,7 @@ async function forwardWalkNamegraph( // NOTE: we blindly return after bridging, which correctly implements the Forward Resolution // behavior in that the origin Domain, even if there is one, is invisible to resolution // (due to the ancestor Bridged Resolver) and therefore not addressable - return forwardWalkNamegraph( + return walkNamegraphFromRegistry( bridged.targetRegistryId, path.slice(deepestResolver.depth), depth + 1, @@ -134,16 +149,25 @@ async function forwardWalkNamegraph( // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 if (resolverEq(DatasourceNames.ENSv2Root, "ENSv1Resolver")) { // to implement the ENSv1Resolver, walk the ENSv1 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv1RootRegistryId(di.context.namespace), path, depth + 1); + return walkNamegraphFromRegistry( + getENSv1RootRegistryId(di.context.namespace), + path, + depth + 1, + ); } // ENSv2Resolver (ENSv2 Fallback) if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv2RootRegistryId(di.context.namespace), path, depth + 1); + return walkNamegraphFromRegistry( + getENSv2RootRegistryId(di.context.namespace), + path, + depth + 1, + ); } } - // finally, return the exact match if it was the leaf - return exact ? deepest.domainId : null; + // finally, return the terminal path; the caller interprets `exact` (indexed leaf) vs a deepest + // wildcard Resolver (resolvable-but-unindexed) vs neither (not resolvable) + return { rows, exact }; } diff --git a/apps/ensapi/src/omnigraph-api/lib/unindexed-domain.ts b/apps/ensapi/src/omnigraph-api/lib/unindexed-domain.ts new file mode 100644 index 0000000000..ee64ed4d08 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/unindexed-domain.ts @@ -0,0 +1,144 @@ +import { + type DomainId, + getNameHierarchy, + type InterpretedName, + interpretedNameToInterpretedLabels, + isResolvableName, + labelhashInterpretedLabel, + makeResolverId, + makeUnindexedDomainId, + type Node, + namehashInterpretedName, + type RegistryId, + type ResolverId, + type UnindexedDomainId, +} from "enssdk"; + +import type di from "@/di"; +import { + type WalkResultRow, + walkResultRowHasResolver, +} from "@/lib/protocol-acceleration/forward-walk-disjoint-namegraph"; + +/** + * A resolvable-but-unindexed Domain: the indexer has no row for it, but it is resolvable because the + * deepest Resolver along its namegraph path is an ENSIP-10 wildcard (`extended`) Resolver — e.g. + * off-chain / CCIP-Read names, unindexed 3DNS names, and wildcard subnames. Minted by + * {@link makeUnindexedDomain}; never loaded from the index. + * + * It is a member of the Domain interface's Shape alongside the indexed Domain row, so it mirrors + * exactly the indexed-Domain fields that the Domain interface's (and DomainCanonical's) field + * resolvers read, and the `UnindexedDomain` GraphQL type has no concrete-type fields of its own. It + * IS Canonical (it is named, via the queried Name), so its canonical metadata is populated; + * `DomainCanonical.path` is built lazily by {@link computeUnindexedDomainCanonicalPath} (its + * leaf/intermediate nodes are virtual and cannot be loaded by id). + */ +export interface UnindexedDomain { + type: "UnindexedDomain"; + id: UnindexedDomainId; + /** The Registry that manages the ancestor Domain bearing the wildcard Resolver. */ + registryId: RegistryId; + + // mirrors of the indexed-Domain fields read by the Domain interface / DomainCanonical resolvers + label: typeof di.context.ensIndexerSchema.label.$inferSelect; + ownerId: null; + subregistryId: null; + canonical: true; + canonicalName: InterpretedName; + canonicalDepth: number; + canonicalNode: Node; + + /** + * This Domain's effective Resolver — the wildcard (`extended`) Resolver borne by the deepest + * ancestor in its namegraph path, which is what makes it resolvable. A virtual Domain has no + * Resolver assigned directly to it (so `DomainResolver.assigned` is null), but it always has this + * effective Resolver (so `DomainResolver.effective` is non-null). + */ + effectiveResolverId: ResolverId; + + /** + * The namegraph walk `rows` for {@link canonicalName} that minted this Domain, retained so + * {@link computeUnindexedDomainCanonicalPath} can build the Canonical Path without re-walking. + */ + rows: WalkResultRow[]; +} + +export const isUnindexedDomain = (domain: { type: string }): domain is UnindexedDomain => + domain.type === "UnindexedDomain"; + +/** + * Constructs the {@link UnindexedDomain} for `name` from the namegraph walk `rows` for that name, or + * returns null when `name` does not name one: it is indexed (an exact match), it has no ancestor + * ENSIP-10 wildcard Resolver, or it is not a {@link isResolvableName | ResolvableName}. + * + * Takes the walk `rows` (rather than walking itself) so the caller's single + * {@link forwardWalkNamegraph} can be reused — including across all suffixes of a name, which share + * the same indexed-ancestor `rows` (see {@link computeUnindexedDomainCanonicalPath}). + */ +export function makeUnindexedDomain( + name: InterpretedName, + rows: WalkResultRow[], +): UnindexedDomain | null { + // a name with an Encoded LabelHash or an over-long label cannot be DNS-encoded / resolved + if (!isResolvableName(name)) return null; + + const labels = interpretedNameToInterpretedLabels(name); + + // an exact match (deepest row is the leaf) is an indexed Domain, not an UnindexedDomain + if (rows[0]?.depth === labels.length) return null; + + // resolvable-but-unindexed iff the deepest (ancestor) Resolver is an ENSIP-10 wildcard Resolver + // (mirrors UniversalResolver's _checkResolver: a static ancestor Resolver cannot resolve a + // descendant, so the name is nonexistent) + const effective = rows.find(walkResultRowHasResolver); + if (!effective?.extended) return null; + + const node = namehashInterpretedName(name); + + return { + type: "UnindexedDomain", + id: makeUnindexedDomainId(effective.registryId, node), + registryId: effective.registryId, + label: { labelHash: labelhashInterpretedLabel(labels[0]), interpreted: labels[0] }, + ownerId: null, + subregistryId: null, + canonical: true, + canonicalName: name, + canonicalDepth: labels.length, + canonicalNode: node, + effectiveResolverId: makeResolverId({ chainId: effective.chainId, address: effective.address }), + rows, + } satisfies UnindexedDomain; +} + +/** + * Builds the Canonical Path (root→leaf inclusive) of a resolvable-but-unindexed Domain: the deepest + * indexed ancestor's materialized canonical path (loaded by id), followed by an + * {@link UnindexedDomain} for each label below it down to the leaf (passed through as resolved + * values, since virtual nodes cannot be loaded by id). Used by `DomainCanonical.path`. + * + * @dev all suffixes of `domain.canonicalName` below the deepest indexed ancestor share its walk + * `rows`, so the Domain's own `rows` mint every virtual node — no additional namegraph walk. + */ +export function computeUnindexedDomainCanonicalPath( + domain: UnindexedDomain, +): (DomainId | UnindexedDomain)[] { + const { canonicalName: name, rows } = domain; + + // the deepest indexed ancestor (rows are ordered depth DESC) anchors the indexed canonical prefix. + // Its materialized canonicalPath is root→leaf inclusive, so its length is that ancestor's canonical + // depth — the count of leading labels already covered by indexed Domains. We slice by this length + // (NOT the walk-frame `depth`, which is relative to the post-bridge sub-path and undercounts for + // bridged names, over-minting and duplicating the indexed ancestors). + const indexedPrefix = rows[0]?.canonicalPath ?? []; + + // mint an UnindexedDomain for each label below the indexed prefix, in root→leaf order: + // getNameHierarchy is leaf-first, so reverse to root→leaf and drop the indexed-ancestor labels + const virtualNodes = getNameHierarchy(name) + .toReversed() + .slice(indexedPrefix.length) + .map((suffix) => makeUnindexedDomain(suffix, rows)) + .filter((node) => node !== null); + + return [...indexedPrefix, ...virtualNodes]; +} diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index 4362d64067..295bf49d92 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -664,3 +664,31 @@ describe("Account.primaryName and Account.primaryNames", () => { }); }); }); + +describe("Query.account (unindexed)", () => { + const AccountByAddress = gql` + query AccountByAddress($address: Address!) { + account(by: { address: $address }) { + id + address + domains { edges { node { id } } } + } + } + `; + + it("returns a virtualized Account for an unindexed Address", async () => { + // an Address the indexer has never seen — Reverse Resolution is keyed by address and works + // regardless of whether the Account is indexed, so Query.account must not null-propagate. + const address = "0x00000000000000000000000000000000deadbeef"; + + const result = await request<{ + account: { id: string; address: string; domains: GraphQLConnection<{ id: string }> } | null; + }>(AccountByAddress, { address }); + + expect(result.account).not.toBeNull(); + expect(result.account?.id).toBe(address); + expect(result.account?.address).toBe(address); + // a synthesized Account owns no indexed Domains + expect(flattenConnection(result.account!.domains)).toHaveLength(0); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index e6020042bd..cb1a9c4e06 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,13 +1,12 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address } from "enssdk"; +import type { NormalizedAddress } from "enssdk"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; -import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { buildAccountPrimaryNamesSelection } from "@/omnigraph-api/lib/resolution/account-primary-names-selection"; import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; @@ -32,19 +31,15 @@ import { ReverseResolveRef, } from "@/omnigraph-api/schema/reverse-resolve"; -export const AccountRef = builder.loadableObjectRef("Account", { - load: (ids: Address[]) => { - const { ensDb } = di.context; - return ensDb.query.account.findMany({ - where: (t, { inArray }) => inArray(t.id, ids), - }); - }, - toKey: getModelId, - cacheResolved: true, - sort: true, -}); - -export type Account = Exclude; +/** + * An Account is modeled purely by its {@link NormalizedAddress} — the `account` table holds only an + * id, so there is nothing to load. Resolving `Query.account` to an address directly (rather than via + * a dataloader) means resolvable-but-unindexed Accounts (e.g. those with only an off-chain primary + * name) are supported automatically: Reverse Resolution (`Account.resolve`) is keyed by address and + * works independent of indexing, while indexed-only relations (`domains`, `events`, `permissions`) + * naturally return empty for an unindexed address. + */ +export const AccountRef = builder.objectRef("Account"); /////////// // Account @@ -59,7 +54,7 @@ AccountRef.implement({ description: "A unique reference to this Account.", type: "Address", nullable: false, - resolve: (parent) => parent.id, + resolve: (parent) => parent, }), /////////////////// @@ -69,7 +64,7 @@ AccountRef.implement({ description: "An EVM Address that uniquely identifies this Account on-chain.", type: "Address", nullable: false, - resolve: (parent) => parent.id, + resolve: (parent) => parent, }), ////////////////// @@ -97,7 +92,7 @@ AccountRef.implement({ // null-propagate the entire Account. if (coinTypes === null) { return { - address: account.id, + address: account, coinTypes: [], accelerate, canAccelerate, @@ -106,12 +101,12 @@ AccountRef.implement({ }; } - const { trace, records } = await resolvePrimaryNameRecords(account.id, coinTypes, { + const { trace, records } = await resolvePrimaryNameRecords(account, coinTypes, { accelerate, canAccelerate, }); - return { address: account.id, coinTypes, accelerate, canAccelerate, trace, records }; + return { address: account, coinTypes, accelerate, canAccelerate, trace, records }; }, }), @@ -127,7 +122,7 @@ AccountRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }) => resolveFindDomains({ - where: { ...where, ownerId: parent.id }, + where: { ...where, ownerId: parent }, order, ...connectionArgs, }), @@ -146,7 +141,7 @@ AccountRef.implement({ resolve: (parent, args) => resolveFindEvents({ ...args, - where: { ...args.where, sender: { eq: parent.id } }, + where: { ...args.where, sender: { eq: parent } }, }), }), @@ -165,7 +160,7 @@ AccountRef.implement({ const { ensDb, ensIndexerSchema } = di.context; const scope = and( // this user's permissions - eq(ensIndexerSchema.permissionsUser.user, parent.id), + eq(ensIndexerSchema.permissionsUser.user, parent), // optionally filtered by contract contract ? and( @@ -200,7 +195,7 @@ AccountRef.implement({ type: RegistryPermissionsUserRef, resolve: (parent, args) => { const { ensDb, ensIndexerSchema } = di.context; - const scope = eq(ensIndexerSchema.permissionsUser.user, parent.id); + const scope = eq(ensIndexerSchema.permissionsUser.user, parent); const join = and( eq(ensIndexerSchema.permissionsUser.chainId, ensIndexerSchema.registry.chainId), eq(ensIndexerSchema.permissionsUser.address, ensIndexerSchema.registry.address), @@ -238,7 +233,7 @@ AccountRef.implement({ type: ResolverPermissionsUserRef, resolve: (parent, args) => { const { ensDb, ensIndexerSchema } = di.context; - const scope = eq(ensIndexerSchema.permissionsUser.user, parent.id); + const scope = eq(ensIndexerSchema.permissionsUser.user, parent); const join = and( eq(ensIndexerSchema.permissionsUser.chainId, ensIndexerSchema.resolver.chainId), eq(ensIndexerSchema.permissionsUser.address, ensIndexerSchema.resolver.address), diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index bdd01024dc..201f1bd60b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -1,4 +1,8 @@ import { builder } from "@/omnigraph-api/builder"; +import { + computeUnindexedDomainCanonicalPath, + isUnindexedDomain, +} from "@/omnigraph-api/lib/unindexed-domain"; import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -46,6 +50,10 @@ DomainCanonicalRef.implement({ type: [DomainInterfaceRef], nullable: false, resolve: (domain) => { + // an UnindexedDomain's path leaf/intermediates are virtual (unloadable by id), so build it + // from resolved values rather than ids + if (isUnindexedDomain(domain)) return computeUnindexedDomainCanonicalPath(domain); + if (!domain.canonicalPath) { throw new Error( `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' is missing canonicalPath.`, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts index 79322fa0bc..3a566dd8a5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts @@ -1,16 +1,16 @@ -import type { DomainId } from "enssdk"; - import { builder } from "@/omnigraph-api/builder"; import { getDomainAssignedResolver, getDomainEffectiveResolver, } from "@/omnigraph-api/lib/get-domain-resolver"; +import { isUnindexedDomain } from "@/omnigraph-api/lib/unindexed-domain"; +import type { Domain } from "@/omnigraph-api/schema/domain"; import { ResolverRef } from "@/omnigraph-api/schema/resolver"; //////////////////////////////// // DomainResolver //////////////////////////////// -export const DomainResolverRef = builder.objectRef("DomainResolver"); +export const DomainResolverRef = builder.objectRef("DomainResolver"); DomainResolverRef.implement({ description: "Metadata describing this Domain's relationship to its Resolver(s).", @@ -23,7 +23,9 @@ DomainResolverRef.implement({ "The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.", type: ResolverRef, nullable: true, - resolve: (domainId) => getDomainAssignedResolver(domainId), + // a virtual UnindexedDomain has no Resolver assigned directly to it + resolve: (domain) => + isUnindexedDomain(domain) ? null : getDomainAssignedResolver(domain.id), }), //////////////////////////// @@ -34,7 +36,12 @@ DomainResolverRef.implement({ "The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree.", type: ResolverRef, nullable: true, - resolve: (domainId) => getDomainEffectiveResolver(domainId), + // an UnindexedDomain is resolvable precisely because an ancestor bears a wildcard Resolver; + // that ancestor Resolver (identified by the namegraph walk that minted it) is its effective one + resolve: (domain) => + isUnindexedDomain(domain) + ? domain.effectiveResolverId + : getDomainEffectiveResolver(domain.id), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index beec7d262b..6294bcd63c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,7 +1,7 @@ import { trace } from "@opentelemetry/api"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns, inArray, sql } from "drizzle-orm"; -import { type DomainId, isNormalizedName } from "enssdk"; +import { type DomainId, isResolvableName } from "enssdk"; import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; @@ -28,6 +28,7 @@ import { buildRecordsSelectionFromResolveContainerInfo, mergeRecordsSelections, } from "@/omnigraph-api/lib/resolution/records-selection"; +import { isUnindexedDomain, type UnindexedDomain } from "@/omnigraph-api/lib/unindexed-domain"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS, @@ -60,31 +61,38 @@ const tracer = trace.getTracer("schema/Domain"); // Loadable Interface (Domain) /////////////////////////////// -export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { - load: (ids: DomainId[]) => - withSpanAsync(tracer, "Domain.load", { count: ids.length }, () => { - const { ensDb } = di.context; - return ensDb.query.domain.findMany({ - where: (t, { inArray }) => inArray(t.id, ids), - with: { label: true }, - }); +const loadDomains = (ids: DomainId[]) => + withSpanAsync(tracer, "Domain.load", { count: ids.length }, () => + di.context.ensDb.query.domain.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + with: { label: true }, }), + ); + +/** The shape of an indexed Domain row (with its Label joined), as loaded for the Domain interface. */ +export type IndexedDomain = Awaited>[number]; + +export const DomainInterfaceRef = builder.loadableInterfaceRef< + IndexedDomain | UnindexedDomain, + DomainId +>("Domain", { + load: loadDomains, toKey: getModelId, cacheResolved: true, sort: true, }); -export type Domain = Exclude; -export type DomainInterface = Omit; -export type ENSv1Domain = RequiredAndNotNull & - RequiredAndNull & { type: "ENSv1Domain" }; -export type ENSv2Domain = RequiredAndNotNull & - RequiredAndNull & { type: "ENSv2Domain" }; +export type Domain = IndexedDomain | UnindexedDomain; +export type DomainInterface = Omit; +export type ENSv1Domain = RequiredAndNotNull & + RequiredAndNull & { type: "ENSv1Domain" }; +export type ENSv2Domain = RequiredAndNotNull & + RequiredAndNull & { type: "ENSv2Domain" }; -export const isENSv1Domain = (domain: DomainInterface): domain is ENSv1Domain => +export const isENSv1Domain = (domain: { type: string }): domain is ENSv1Domain => domain.type === "ENSv1Domain"; -export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => +export const isENSv2Domain = (domain: { type: string }): domain is ENSv2Domain => domain.type === "ENSv2Domain"; export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); @@ -133,10 +141,10 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain.", + "The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.", type: DomainInterfaceRef, nullable: true, - resolve: async (domain, _args, context) => + resolve: (domain, _args, context) => context.loaders.registryParentDomain.load(domain.registryId), }), @@ -155,7 +163,8 @@ DomainInterfaceRef.implement({ // Domain.registry /////////////////// registry: t.field({ - description: "The Registry under which this Domain exists.", + description: + "The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).", type: RegistryInterfaceRef, nullable: false, resolve: (parent) => parent.registryId, @@ -178,7 +187,7 @@ DomainInterfaceRef.implement({ description: "Resolver relationship metadata for this Domain.", type: DomainResolverRef, nullable: false, - resolve: (parent) => parent.id, + resolve: (parent) => parent, }), ////////////////// @@ -201,7 +210,7 @@ DomainInterfaceRef.implement({ const { canAccelerate } = context; const name = domain.canonicalName; - if (!name || !isNormalizedName(name)) { + if (!name || !isResolvableName(name)) { return { accelerate, canAccelerate, trace: null, result: null }; } @@ -431,3 +440,16 @@ ENSv2DomainRef.implement({ }), }), }); + +//////////////////////////////////// +// UnindexedDomain Implementation +//////////////////////////////////// + +export const UnindexedDomainRef = builder.objectRef("UnindexedDomain"); + +UnindexedDomainRef.implement({ + description: + "A resolvable-but-unindexed Domain: not present in the index, but resolvable because an ancestor in its namegraph path has an ENSIP-10 wildcard Resolver (e.g. off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames).", + interfaces: [DomainInterfaceRef], + isTypeOf: (domain) => isUnindexedDomain(domain as { type: string }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts index 1e42ef3556..092817110b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -1,4 +1,4 @@ -import { type Address, type CoinType, type InterpretedName, isNormalizedName } from "enssdk"; +import { type Address, type CoinType, type InterpretedName, isResolvableName } from "enssdk"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; @@ -62,7 +62,7 @@ PrimaryNameRecordRef.implement({ const { name, accelerate } = parent; const { canAccelerate } = context; - if (!name || !isNormalizedName(name)) { + if (!name || !isResolvableName(name)) { return { accelerate, canAccelerate, trace: null, result: null }; } diff --git a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts index 40d92f36ad..65902de0a0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; import { getDatasourceContract, getENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { effectiveResolverFallback } from "@ensnode/integration-test-env/devnet"; import { DEVNET_NAMES } from "@/test/integration/devnet-names"; import { @@ -291,6 +292,130 @@ describe("Query.domain", () => { }); }); +describe("Query.domain (UnindexedDomain)", () => { + const DomainDetail = gql` + query DomainDetail($name: InterpretedName!) { + domain(by: { name: $name }) { + __typename + id + label { interpreted } + registry { __typename id } + canonical { + name { interpreted } + depth + path { __typename id } + } + } + } + `; + + type DomainDetailResult = { + domain: { + __typename: string; + id: DomainId; + label: { interpreted: InterpretedLabel }; + registry: { __typename: string; id: string } | null; + canonical: { + name: { interpreted: Name }; + depth: number; + path: { __typename: string; id: DomainId }[]; + } | null; + } | null; + }; + + it("returns a Canonical UnindexedDomain for an unregistered wildcard subname under an extended Resolver", async () => { + // parent.eth carries an ENSIP-10 wildcard (extended) Resolver, so an unregistered subname under + // it is resolvable-but-unindexed. + const name = `unregistered-wildcard.${effectiveResolverFallback.parentName}`; + const result = await request(DomainDetail, { name }); + + expect(result.domain).not.toBeNull(); + expect(result.domain?.__typename).toBe("UnindexedDomain"); + // its leaf Label is derived from the queried Name + expect(result.domain?.label.interpreted).toBe("unregistered-wildcard"); + // it is virtualized onto the Registry that manages the wildcard Resolver's Domain + expect(result.domain?.registry).not.toBeNull(); + // it IS Canonical (it is named): its canonical metadata is populated + expect(result.domain?.canonical?.name.interpreted).toBe(name); + expect(result.domain?.canonical?.depth).toBe(3); + // its canonical path is root→leaf: indexed `eth` + indexed `parent.eth` + the virtual leaf + const path = result.domain?.canonical?.path ?? []; + expect(path).toHaveLength(3); + expect(path[2]).toMatchObject({ __typename: "UnindexedDomain", id: result.domain?.id }); + }); + + it("builds a label-by-label canonical path with an UnindexedDomain per unindexed ancestor", async () => { + // two labels below the wildcard-Resolver Domain: both `b.parent.eth` and `a.b.parent.eth` are + // unindexed, so the path is [eth, parent.eth, b.parent.eth(UD), a.b.parent.eth(UD)] + const name = `a.b.${effectiveResolverFallback.parentName}`; + const result = await request(DomainDetail, { name }); + + expect(result.domain?.__typename).toBe("UnindexedDomain"); + expect(result.domain?.canonical?.depth).toBe(4); + const path = result.domain?.canonical?.path ?? []; + expect(path).toHaveLength(4); + // the two leaf-ward path nodes are virtual UnindexedDomains + expect(path[2]?.__typename).toBe("UnindexedDomain"); + expect(path[3]).toMatchObject({ __typename: "UnindexedDomain", id: result.domain?.id }); + }); + + it("exposes the wildcard ancestor's Resolver as the effective Resolver (assigned is null)", async () => { + const UnindexedDomainResolver = gql` + query UnindexedDomainResolver($name: InterpretedName!) { + domain(by: { name: $name }) { + __typename + resolver { + assigned { contract { chainId address } } + effective { contract { chainId address } } + } + } + } + `; + + type ResolverContract = { contract: { chainId: number; address: string } } | null; + type Result = { + domain: { + __typename: string; + resolver: { assigned: ResolverContract; effective: ResolverContract }; + } | null; + }; + + const name = `unregistered-wildcard.${effectiveResolverFallback.parentName}`; + const result = await request(UnindexedDomainResolver, { name }); + + expect(result.domain?.__typename).toBe("UnindexedDomain"); + // a virtual Domain has no Resolver assigned directly to it + expect(result.domain?.resolver.assigned).toBeNull(); + // but its effective Resolver is the wildcard ancestor's (parent.eth's) Resolver + const { domain: parent } = await request(UnindexedDomainResolver, { + name: effectiveResolverFallback.parentName, + }); + expect(parent?.resolver.assigned?.contract.address).toBeTruthy(); + expect(result.domain?.resolver.effective?.contract).toEqual( + parent?.resolver.assigned?.contract, + ); + }); + + it("returns null for an unregistered subname under a Resolver-less name (no wildcard Resolver)", async () => { + await expect( + request(DomainDetail, { + name: "leaf.this-name-definitely-does-not-exist-xyz123.eth", + }), + ).resolves.toMatchObject({ domain: null }); + }); + + it("returns null for an unresolvable name (Encoded LabelHash leaf) even under a wildcard Resolver", async () => { + // an Encoded LabelHash has no known literal label, so the name is not a ResolvableName and is + // not virtualized as an UnindexedDomain even though parent.eth has a wildcard Resolver + const encodedLabelHash = `[${"0".repeat(64)}]`; + await expect( + request(DomainDetail, { + name: `${encodedLabelHash}.${effectiveResolverFallback.parentName}`, + }), + ).resolves.toMatchObject({ domain: null }); + }); +}); + describe("Query.domains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ domains: PaginatedGraphQLConnection }>( diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 445bbed274..eb0656ae64 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -7,9 +7,10 @@ import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; -import { getDomainIdByInterpretedName } from "@/omnigraph-api/lib/get-domain-by-interpreted-name"; +import { forwardWalkNamegraph } from "@/omnigraph-api/lib/get-domain-by-interpreted-name"; import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { makeUnindexedDomain } from "@/omnigraph-api/lib/unindexed-domain"; import { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -125,9 +126,18 @@ builder.queryType({ type: DomainInterfaceRef, args: { by: t.arg({ type: DomainIdInput, required: true }) }, nullable: true, - resolve: (parent, args, ctx, info) => { + resolve: async (parent, args, ctx, info) => { if (args.by.id !== undefined) return args.by.id; - return getDomainIdByInterpretedName(args.by.name); + const name = args.by.name; + + const { rows, exact } = await forwardWalkNamegraph(name); + if (rows.length === 0) return null; + + // if exact, the leaf (rows[0]) is the indexed Domain + if (exact) return rows[0].domainId; + + // otherwise the name may be resolvable-but-unindexed (an UnindexedDomain), or null + return makeUnindexedDomain(name, rows); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 6166608cfa..df542c3edf 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -75,6 +75,17 @@ ResolverRef.implement({ resolve: ({ chainId, address }) => ({ chainId, address }), }), + ///////////////////// + // Resolver.extended + ///////////////////// + extended: t.field({ + description: + "Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed.", + type: "Boolean", + nullable: false, + resolve: (parent) => parent.isExtended, + }), + //////////////////// // Resolver.records //////////////////// diff --git a/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts index 416238e73c..3d68ec1ff5 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts @@ -2,6 +2,7 @@ import type { AccountId, Address, DomainId } from "enssdk"; import { isAddressEqual, zeroAddress } from "viem"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; +import { upsertResolver } from "@/lib/protocol-acceleration/resolver-db-helpers"; /** * Ensures that the Domain-Resolver Relationship for the provided `domainId` in `registry` is set @@ -17,6 +18,9 @@ export async function ensureDomainResolverRelation( if (isAddressEqual(zeroAddress, resolver)) { await context.ensDb.delete(ensIndexerSchema.domainResolverRelation, { ...registry, domainId }); } else { + // ensures a resolver entity exists for all observed Resolver contracts + await upsertResolver(context, { chainId: registry.chainId, address: resolver }); + await context.ensDb .insert(ensIndexerSchema.domainResolverRelation) .values({ ...registry, domainId, resolver }) diff --git a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts index 75264a9e3a..4b4fcd40ed 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts @@ -1,4 +1,5 @@ import { + type AccountId, type Address, type CoinType, type Hex, @@ -16,6 +17,7 @@ import { interpretPubkeyValue, interpretTextRecordKey, interpretTextRecordValue, + isExtendedResolver, } from "@ensnode/ensnode-sdk/internal"; import { getThisAccountId } from "@/lib/get-this-account-id"; @@ -30,6 +32,31 @@ type ResolverRecordsCompositeKey = Pick< "chainId" | "address" | "node" >; +/** + * Ensures a Resolver entity exists for `resolver`, capturing additional metadata. + * + * @dev performs a single `supportsInterface` RPC (via Ponder's cached `context.client`) to determine + * `isExtended` support. + */ +export async function upsertResolver( + context: IndexingEngineContext, + resolver: AccountId, +): Promise { + const id = makeResolverId(resolver); + + const existing = await context.ensDb.find(ensIndexerSchema.resolver, { id }); + if (existing) return existing; + + const isExtended = await isExtendedResolver({ + publicClient: context.client, + address: resolver.address, + }); + + const row = { id, ...resolver, isExtended }; + await context.ensDb.insert(ensIndexerSchema.resolver).values(row).onConflictDoNothing(); + return row; +} + /** * Ensures the Resolver + ResolverRecords entities exist for the given Resolver event, and returns * the ResolverRecords key for further per-record updates. @@ -41,10 +68,7 @@ export async function ensureResolverAndRecords( const resolver = getThisAccountId(context, event); const key: ResolverRecordsCompositeKey = { ...resolver, node: event.args.node }; - await context.ensDb - .insert(ensIndexerSchema.resolver) - .values({ id: makeResolverId(resolver), ...resolver }) - .onConflictDoNothing(); + await upsertResolver(context, resolver); await context.ensDb .insert(ensIndexerSchema.resolverRecords) diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx index b80778ea87..8a41858a8d 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx @@ -177,7 +177,7 @@ The [Omnigraph API](/docs/integrate/omnigraph) will support automatic avatar url The Subgraph (and any data-level-compatible indexer) contains a large volume of unhealed names — names whose labels are only known as labelhashes (e.g. `[abcd…].eth`). These complicate both the developer experience and the UX of every app built on top, which must decide how to display and handle them. ::: -**What the heck is a `[428…b0b]`?** These are encoded labelhashes used to represent an unknown label in an ENS name. Without name healing, millions of names in the ENS manager app (and other ENS apps) don't appear properly. See the problem for yourself: [Example 1](https://app.ens.domains/0xfFD1Ac3e8818AdCbe5C597ea076E8D3210B45df5) and [Example 2](). +**What the heck is a `[428…b0b]`?** These are encoded labelhashes used to represent an unknown label in an ENS name. Without name healing, millions of names in the ENS manager app (and other ENS apps) don't appear properly. See the problem for yourself: [Example 1](https://app.ens.domains/0xfFD1Ac3e8818AdCbe5C597ea076E8D3210B45df5) and [Example 2](https://app.ens.domains/%5B4283f2583432677d3dac6d2c021cdd7ef6855349ea584813ad5811c0e497eb0b%5D.makoto.eth). ![An ENS profile listing names where an unknown label is shown as an encoded labelhash instead of a readable name](@assets/ens-profile-unhealed.svg) diff --git a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx index bf3e2569db..a9f7a322ff 100644 --- a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx @@ -108,13 +108,14 @@ It is keyed by `(chain_id, address, domain_id)` to match the on-chain data model #### resolvers -Represents an individual `IResolver` contract that has emitted at least one event. Note that Resolver contracts can exist on-chain but not emit any events and still function properly, so checks against a Resolver's existence and metadata must be done at runtime. - -| Column | Type | Nullable | Description | -| ---------- | -------- | -------- | -------------------------------------------- | -| `id` | `text` | no | Keyed by `(chain_id, address)`. Primary key. | -| `chain_id` | `bigint` | no | Chain the resolver contract is deployed on. | -| `address` | `text` | no | Address of the resolver contract. | +Represents an individual `IResolver` contract observed by the indexer — either by emitting at least one event, or by being assigned as a Domain's Resolver (via a `domain_resolver_relations` row). Note that Resolver contracts can exist on-chain but not emit any events and still function properly, so checks against a Resolver's existence and metadata must be done at runtime. + +| Column | Type | Nullable | Description | +| ------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `text` | no | Keyed by `(chain_id, address)`. Primary key. | +| `chain_id` | `bigint` | no | Chain the resolver contract is deployed on. | +| `address` | `text` | no | Address of the resolver contract. | +| `is_extended` | `boolean` | no | Whether the Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed. Defaults to `false`. | **Indexes:** unique on `(chain_id, address)`. diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts index 976278f38c..919216fab6 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts @@ -93,6 +93,13 @@ export const resolver = onchainTable( chainId: t.int8({ mode: "number" }).notNull().$type(), address: t.hex().notNull().$type
(), + + /** + * Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, + * interfaceId `0x9061b923`), determined via a single `supportsInterface` RPC the first + * time the Resolver is observed (see `upsertResolver`). + */ + isExtended: t.boolean().notNull().default(false), }), (t) => ({ byId: uniqueIndex().on(t.chainId, t.address), diff --git a/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts b/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts index 0c6442cc79..1178b1cc88 100644 --- a/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts +++ b/packages/enskit/src/react/omnigraph/_lib/by-id-lookup-resolvers.ts @@ -28,6 +28,9 @@ export const byIdLookupResolvers: Record> = { const v2Key = cache.keyOfEntity({ __typename: "ENSv2Domain", id: by.id }); if (v2Key && cache.resolve(v2Key, "id")) return v2Key; + + const unindexedKey = cache.keyOfEntity({ __typename: "UnindexedDomain", id: by.id }); + if (unindexedKey && cache.resolve(unindexedKey, "id")) return unindexedKey; } return passthrough(args, cache, info); diff --git a/packages/ensnode-sdk/src/rpc/eip-165.ts b/packages/ensnode-sdk/src/rpc/eip-165.ts index 8a34724b94..84cacba3cb 100644 --- a/packages/ensnode-sdk/src/rpc/eip-165.ts +++ b/packages/ensnode-sdk/src/rpc/eip-165.ts @@ -38,6 +38,12 @@ async function supportsInterface< functionName: "supportsInterface"; address: Address; args: readonly [InterfaceId]; + // A `0x` ("returned no data") response to an eip-165 probe means "interface not + // supported" — it is never transient. Ponder's `context.client` otherwise retries + // empty-data responses 9× with exponential backoff (~64s each), which makes + // index-time resolver classification pathologically slow. Opt out so it fails fast. + // Plain viem clients ignore this field. + retryEmptyResponse?: boolean; }) => Promise; }, >({ @@ -55,6 +61,7 @@ async function supportsInterface< functionName: "supportsInterface", address, args: [selector], + retryEmptyResponse: false, }); } catch { // this call reverted for whatever reason — this contract does not support the interface diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index f5c748f704..bd27bd6e79 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -17,11 +17,13 @@ import type { PermissionsResourceId, PermissionsUserId, RegistrationId, + RegistryId, RenewalId, ResolverId, ResolverRecordsId, StorageId, TokenId, + UnindexedDomainId, } from "./types"; /** @@ -71,6 +73,17 @@ export const makeENSv1DomainId = (accountId: AccountId, node: Node) => export const makeENSv2DomainId = (registry: AccountId, storageId: StorageId) => [_stringifyAccountId(registry), storageId.toString()].join("-") as ENSv2DomainId; +/** + * Stringifies the id of a resolvable-but-unindexed Domain from the {@link RegistryId} of the + * Registry that manages the ancestor Domain bearing the wildcard Resolver, and the `node` (namehash) + * of the unindexed name. See {@link UnindexedDomainId}. + * + * @dev Prefixed with `unindexed-` to unambiguously disambiguate from an {@link ENSv1DomainId}, which + * shares the same `${registryId}-${node}` tail shape. + */ +export const makeUnindexedDomainId = (registryId: RegistryId, node: Node) => + ["unindexed", registryId, node].join("-") as UnindexedDomainId; + /** * Computes a Label's {@link StorageId} given its TokenId or LabelHash. */ diff --git a/packages/enssdk/src/lib/index.ts b/packages/enssdk/src/lib/index.ts index f48994631e..d3f11a4dc0 100644 --- a/packages/enssdk/src/lib/index.ts +++ b/packages/enssdk/src/lib/index.ts @@ -16,5 +16,6 @@ export * from "./normalization"; export * from "./parse-labelhash"; export * from "./parse-reverse-name"; export * from "./reinterpretation"; +export * from "./resolvable-name"; export * from "./reverse-name"; export * from "./types"; diff --git a/packages/enssdk/src/lib/resolvable-name.test.ts b/packages/enssdk/src/lib/resolvable-name.test.ts new file mode 100644 index 0000000000..47ead5aae8 --- /dev/null +++ b/packages/enssdk/src/lib/resolvable-name.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { asInterpretedName } from "./interpreted-names-and-labels"; +import { asResolvableName, isResolvableName } from "./resolvable-name"; + +describe("isResolvableName", () => { + it("accepts normal normalized names", () => { + expect(isResolvableName(asInterpretedName("vitalik.eth"))).toBe(true); + expect(isResolvableName(asInterpretedName("sub.parent.eth"))).toBe(true); + expect(isResolvableName(asInterpretedName("eth"))).toBe(true); + }); + + it("accepts a multi-byte label under the byte-length cap", () => { + expect(isResolvableName(asInterpretedName("🦊.eth"))).toBe(true); + }); + + it("rejects names containing an Encoded LabelHash segment", () => { + const encodedLabelHash = `[${"0".repeat(64)}]`; + expect(isResolvableName(asInterpretedName(`${encodedLabelHash}.eth`))).toBe(false); + expect(isResolvableName(asInterpretedName(`sub.${encodedLabelHash}.eth`))).toBe(false); + }); + + it("rejects names with a label of 256+ bytes", () => { + expect(isResolvableName(asInterpretedName(`${"a".repeat(255)}.eth`))).toBe(true); + expect(isResolvableName(asInterpretedName(`${"a".repeat(256)}.eth`))).toBe(false); + }); +}); + +describe("asResolvableName", () => { + it("returns the name when resolvable", () => { + const name = asInterpretedName("vitalik.eth"); + expect(asResolvableName(name)).toBe(name); + }); + + it("throws when not resolvable", () => { + expect(() => asResolvableName(asInterpretedName(`[${"0".repeat(64)}].eth`))).toThrow( + "Not a valid ResolvableName", + ); + }); +}); diff --git a/packages/enssdk/src/lib/resolvable-name.ts b/packages/enssdk/src/lib/resolvable-name.ts new file mode 100644 index 0000000000..204535daef --- /dev/null +++ b/packages/enssdk/src/lib/resolvable-name.ts @@ -0,0 +1,51 @@ +import { stringToBytes } from "viem"; + +import { interpretedNameToInterpretedLabels } from "./interpreted-names-and-labels"; +import { isEncodedLabelHash } from "./labelhash"; +import type { InterpretedName } from "./types"; + +/** + * The exclusive upper bound (in bytes) on a single resolvable label. ENS DNS-encoding prefixes each + * label with a single length octet, so a label must encode to at most 255 bytes; a label of 256+ + * bytes cannot be DNS-encoded and therefore cannot be resolved. + */ +const MAX_RESOLVABLE_LABEL_BYTE_LENGTH = 256; + +/** + * A {@link ResolvableName} is an {@link InterpretedName} that can actually be resolved via the ENS + * protocol — i.e. it can be DNS-encoded and passed to a Resolver. It requires that every label: + * - is normalized, + * - is a known literal label (NOT an Encoded LabelHash — an unknown label cannot be DNS-encoded), and + * - DNS-encodes to fewer than {@link MAX_RESOLVABLE_LABEL_BYTE_LENGTH} bytes. + * + * @dev technically names with unnormalized labels are resolvable by the UniversalResolver (which + * does not check normalization), but to reduce edge cases and avoid footguns, we intentionally + * include this additional constraint. This also enforces that a ResolvableName within ENSNode is + * a strict subset of InterpretedName and InterpretedName-based operations (like {@link namehashInterpretedName}) + * function identically on ResolvableNames. + */ +export type ResolvableName = InterpretedName & { __brand: "ResolvableName" }; + +/** + * Determines whether `name` is a {@link ResolvableName}. + */ +export function isResolvableName(name: InterpretedName): name is ResolvableName { + for (const label of interpretedNameToInterpretedLabels(name)) { + // an Encoded LabelHash has no known literal label, so the name cannot be DNS-encoded + if (isEncodedLabelHash(label)) return false; + + // a label must DNS-encode within a single length octet (< 256 bytes) + if (stringToBytes(label).length >= MAX_RESOLVABLE_LABEL_BYTE_LENGTH) return false; + } + + return true; +} + +/** + * Asserts that `name` is a {@link ResolvableName}, returning it, or throws. + */ +export function asResolvableName(name: InterpretedName): ResolvableName { + if (isResolvableName(name)) return name; + + throw new Error(`Not a valid ResolvableName: '${name}'`); +} diff --git a/packages/enssdk/src/lib/types/ensv2.ts b/packages/enssdk/src/lib/types/ensv2.ts index 7bac38f638..47164c365b 100644 --- a/packages/enssdk/src/lib/types/ensv2.ts +++ b/packages/enssdk/src/lib/types/ensv2.ts @@ -48,9 +48,26 @@ export type ENSv1DomainId = string & { __brand: "ENSv1DomainId" }; export type ENSv2DomainId = string & { __brand: "ENSv2DomainId" }; /** - * A DomainId is one of ENSv1DomainId or ENSv2DomainId. + * An ID that uniquely identifies a resolvable-but-unindexed Domain — one that the indexer has no + * row for, but which is nonetheless resolvable because an ancestor in its namegraph path has an + * ENSIP-10 wildcard (`IExtendedResolver`) Resolver (e.g. off-chain / CCIP-Read names, unindexed + * 3DNS names, wildcard subnames). + * + * Keyed by (registryId, node) — the Registry that manages the ancestor Domain bearing the wildcard + * Resolver, and the namehash of the (unindexed) name. This is globally unique by construction: were + * an indexed Domain to exist for this (registry, node), the namegraph walk would have matched it + * exactly and never minted an UnindexedDomainId. + * + * @dev formatted with an `unindexed-` prefix (see `makeUnindexedDomainId`) to unambiguously + * disambiguate from an {@link ENSv1DomainId}, which shares the same `${registryId}-${node}` tail. + * @dev see packages/enssdk/src/lib/ids.ts for context + */ +export type UnindexedDomainId = string & { __brand: "UnindexedDomainId" }; + +/** + * A DomainId is one of ENSv1DomainId, ENSv2DomainId, or UnindexedDomainId. */ -export type DomainId = ENSv1DomainId | ENSv2DomainId; +export type DomainId = ENSv1DomainId | ENSv2DomainId | UnindexedDomainId; /** * An ID that uniquely identifies a Permissions entity. diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 62ce6ea0c9..668fb67323 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1457,6 +1457,10 @@ const introspection = { { "kind": "OBJECT", "name": "ENSv2Domain" + }, + { + "kind": "OBJECT", + "name": "UnindexedDomain" } ] }, @@ -6632,6 +6636,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "extended", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "id", "type": { @@ -7317,6 +7333,267 @@ const introspection = { } ] }, + { + "kind": "OBJECT", + "name": "UnindexedDomain", + "fields": [ + { + "name": "canonical", + "type": { + "kind": "OBJECT", + "name": "DomainCanonical" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "events", + "type": { + "kind": "OBJECT", + "name": "DomainEventsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "EventsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "DomainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "label", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Label" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "owner", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "parent", + "type": { + "kind": "INTERFACE", + "name": "Domain" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "registration", + "type": { + "kind": "INTERFACE", + "name": "Registration" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "registrations", + "type": { + "kind": "OBJECT", + "name": "DomainRegistrationsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "registry", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Registry" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ForwardResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, + { + "name": "resolver", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolver" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "subdomains", + "type": { + "kind": "OBJECT", + "name": "DomainSubdomainsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "SubdomainsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "subregistry", + "type": { + "kind": "INTERFACE", + "name": "Registry" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Domain" + } + ] + }, { "kind": "OBJECT", "name": "WrappedBaseRegistrarRegistration", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index addda6238f..1d35a305f2 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -319,7 +319,7 @@ interface Domain { owner: Account """ - The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`. """ parent: Domain @@ -329,7 +329,9 @@ interface Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection - """The Registry under which this Domain exists.""" + """ + The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`). + """ registry: Registry! """Resolve protocol-level data for this Domain.""" @@ -573,7 +575,7 @@ type ENSv1Domain implements Domain { owner: Account """ - The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`. """ parent: Domain @@ -583,7 +585,9 @@ type ENSv1Domain implements Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection - """The Registry under which this Domain exists.""" + """ + The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`). + """ registry: Registry! """Resolve protocol-level data for this Domain.""" @@ -693,7 +697,7 @@ type ENSv2Domain implements Domain { owner: Account """ - The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`. """ parent: Domain @@ -708,7 +712,9 @@ type ENSv2Domain implements Domain { """All Registrations for a Domain, including the latest Registration.""" registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection - """The Registry under which this Domain exists.""" + """ + The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`). + """ registry: Registry! """Resolve protocol-level data for this Domain.""" @@ -1728,6 +1734,11 @@ type Resolver { """All Events associated with this Resolver.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): ResolverEventsConnection + """ + Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed. + """ + extended: Boolean! + """A unique reference to this Resolver.""" id: ResolverId! @@ -1906,6 +1917,66 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } +""" +A resolvable-but-unindexed Domain: not present in the index, but resolvable because an ancestor in its namegraph path has an ENSIP-10 wildcard Resolver (e.g. off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames). +""" +type UnindexedDomain implements Domain { + """ + Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree. + """ + canonical: DomainCanonical + + """All Events associated with this Domain.""" + events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection + + """A unique and stable reference to this Domain.""" + id: DomainId! + + """The Label associated with this Domain in the ENS Namegraph.""" + label: Label! + + """ + If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). + """ + owner: Account + + """ + The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`. + """ + parent: Domain + + """The latest Registration for this Domain, if exists.""" + registration: Registration + + """All Registrations for a Domain, including the latest Registration.""" + registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection + + """ + The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`). + """ + registry: Registry! + + """Resolve protocol-level data for this Domain.""" + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + + """Resolver relationship metadata for this Domain.""" + resolver: DomainResolver! + + """ + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. + """ + subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection + + """The Registry this Domain declares as its Subregistry, if exists.""" + subregistry: Registry +} + """ Additional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name) """ diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md index 8db98d4acf..881a926893 100644 --- a/packages/ensskills/skills/omnigraph/SKILL.md +++ b/packages/ensskills/skills/omnigraph/SKILL.md @@ -91,10 +91,10 @@ _Represents a Domain, i.e. an individual Label within the ENS namegraph. It may - id: DomainId! — A unique and stable reference to this Domain. - label: Label! — The Label associated with this Domain in the ENS Namegraph. - owner: Account — If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). -- parent: Domain — The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. +- parent: Domain — The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`. - registration: Registration — The latest Registration for this Domain, if exists. - registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection — All Registrations for a Domain, including the latest Registration. -- registry: Registry! — The Registry under which this Domain exists. +- registry: Registry! — The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`). - resolve(accelerate: Boolean): ForwardResolve! — Resolve protocol-level data for this Domain. - resolver: DomainResolver! — Resolver relationship metadata for this Domain. - subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection — All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. @@ -129,6 +129,7 @@ _A Resolver represents a Resolver contract on-chain._ - bridged: Registry — If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution. - contract: AccountId! — Contract metadata for this Resolver. - events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): ResolverEventsConnection — All Events associated with this Resolver. +- extended: Boolean! — Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed. - id: ResolverId! — A unique reference to this Resolver. - permissions: Permissions — Permissions granted by this Resolver. - records(after: String, before: String, first: Int, last: Int): ResolverRecordsConnection — ResolverRecords issued by this Resolver. @@ -139,7 +140,7 @@ _A Resolver represents a Resolver contract on-chain._ _Metadata describing this Domain's relationship to its Resolver(s)._ - assigned: Resolver — The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. -- effective: Resolver — The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree. +- effective: Resolver — The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree. #### Registry @@ -207,7 +208,7 @@ _An ENSIP-19 primary name for an Account on a specific coin type._ Run `npx enscli ensnode omnigraph schema ` for fields of: -`AccelerationStatus`, `AccountId`, `BaseRegistrarRegistration`, `CanonicalName`, `DomainProfile`, `ENSv1Domain`, `ENSv1Registry`, `ENSv1VirtualRegistry`, `ENSv2Domain`, `ENSv2Registry`, `ENSv2RegistryRegistration`, `ENSv2RegistryReservation`, `Event`, `Label`, `NameWrapperRegistration`, `PageInfo`, `PermissionsResource`, `PermissionsUser`, `ProfileAddresses`, `ProfileAvatar`, `ProfileHeader`, `ProfileSocialAccount`, `ProfileSocials`, `ProfileWebsite`, `RegistryPermissionsUser`, `Renewal`, `ResolvedAbiRecord`, `ResolvedAddressRecord`, `ResolvedInterfaceRecord`, `ResolvedPubkeyRecord`, `ResolvedRawTextRecord`, `ResolverPermissionsUser`, `ResolverRecords`, `ThreeDNSRegistration`, `WrappedBaseRegistrarRegistration` +`AccelerationStatus`, `AccountId`, `BaseRegistrarRegistration`, `CanonicalName`, `DomainProfile`, `ENSv1Domain`, `ENSv1Registry`, `ENSv1VirtualRegistry`, `ENSv2Domain`, `ENSv2Registry`, `ENSv2RegistryRegistration`, `ENSv2RegistryReservation`, `Event`, `Label`, `NameWrapperRegistration`, `PageInfo`, `PermissionsResource`, `PermissionsUser`, `ProfileAddresses`, `ProfileAvatar`, `ProfileHeader`, `ProfileSocialAccount`, `ProfileSocials`, `ProfileWebsite`, `RegistryPermissionsUser`, `Renewal`, `ResolvedAbiRecord`, `ResolvedAddressRecord`, `ResolvedInterfaceRecord`, `ResolvedPubkeyRecord`, `ResolvedRawTextRecord`, `ResolverPermissionsUser`, `ResolverRecords`, `ThreeDNSRegistration`, `UnindexedDomain`, `WrappedBaseRegistrarRegistration`