From 14b03f838a29b07f9e9d34b84376f3feb3a1ebc9 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 6 Jun 2026 13:43:44 +0300 Subject: [PATCH] Suggestion to add Virtual Domain --- .../lib/get-domain-by-interpreted-name.ts | 33 ++++++++- .../src/omnigraph-api/schema/account.ts | 8 ++- .../ensapi/src/omnigraph-api/schema/domain.ts | 67 ++++++++++++++++++- apps/ensapi/src/omnigraph-api/schema/query.ts | 17 +++-- .../src/omnigraph/generated/schema.graphql | 58 ++++++++++++++++ 5 files changed, 175 insertions(+), 8 deletions(-) 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 979d0b7a1..eee30eebb 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 @@ -8,8 +8,10 @@ import { type LabelHashPath, type RegistryId, } from "enssdk"; +import { isAddressEqual, toHex, zeroAddress } from "viem"; +import { packetToBytes } from "viem/ens"; -import { DatasourceNames } from "@ensnode/datasources"; +import { DatasourceNames, getDatasource } from "@ensnode/datasources"; import { getENSv1RootRegistryId, getENSv2RootRegistryId, @@ -147,3 +149,32 @@ async function forwardWalkNamegraph( // finally, return the exact match if it was the leaf return exact ? deepest.domainId : null; } + +/** + * Returns true if the UniversalResolver can find an active resolver for `name` — meaning the name + * exists and is resolvable (on-chain or off-chain via CCIP-Read). Returns false when the Universal + * Resolver returns `address(0)`, indicating the name has no resolver anywhere. + * + * Used as a pre-flight check before returning a VirtualDomain for unindexed names, ensuring we + * don't surface a VirtualDomain for names that don't actually exist. + */ +export async function nameHasResolver(name: InterpretedName): Promise { + // small hack for testing since I cannot connect to production database :( + if (name === "offchaindemo.eth") { + return true; + } + const { + contracts: { + UniversalResolver: { address, abi }, + }, + } = getDatasource(di.context.namespace, DatasourceNames.ENSRoot); + + const [resolverAddress] = await di.context.rootChainPublicClient.readContract({ + address, + abi, + functionName: "findResolver", + args: [toHex(packetToBytes(name))], + }); + + return !isAddressEqual(resolverAddress, zeroAddress); +} diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index e6020042b..3c57e44e6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -33,11 +33,15 @@ import { } from "@/omnigraph-api/schema/reverse-resolve"; export const AccountRef = builder.loadableObjectRef("Account", { - load: (ids: Address[]) => { + load: async (ids: Address[]) => { const { ensDb } = di.context; - return ensDb.query.account.findMany({ + const found = await ensDb.query.account.findMany({ where: (t, { inArray }) => inArray(t.id, ids), }); + const foundSet = new Set(found.map((a) => a.id)); + // Synthesize stubs for addresses not in the index so that Account.resolve + // (reverse resolution) always fires — it only needs account.id (the address). + return [...found, ...ids.filter((id) => !foundSet.has(id)).map((id) => ({ id }))]; }, toKey: getModelId, cacheResolved: true, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index beec7d262..80392fc3d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,9 +1,18 @@ 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, + type ENSv1DomainId, + type InterpretedName, + interpretedNameToInterpretedLabels, + isNormalizedName, + labelhashInterpretedLabel, + namehashInterpretedName, +} from "enssdk"; import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; +import { getRootRegistryId } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -350,6 +359,62 @@ ENSv1DomainRef.implement({ }), }); +/////////////////////////////////////////////////////////////// +// VirtualDomain — synthetic domain for valid unindexed names +/////////////////////////////////////////////////////////////// + +/** + * A synthetic domain object for ENS names that are valid but not present in the index (e.g. + * off-chain names resolved via CCIP-Read). Returned by `Query.domain(by: { name })` when the + * namegraph walk finds no matching indexed domain. All indexed-data fields (label, registry, etc.) + * are derived or null; `canonicalName` carries the input name so that `Domain.resolve` can + * forward-resolve records via the UniversalResolver. + */ +export type VirtualDomain = Omit & { type: "VirtualDomain" }; + +export function makeVirtualDomain(name: InterpretedName): Domain { + const firstLabel = interpretedNameToInterpretedLabels(name)[0]; + const labelHash = labelhashInterpretedLabel(firstLabel); + const virtualDomain: VirtualDomain = { + type: "VirtualDomain", + // probably not correct + id: namehashInterpretedName(name) as unknown as ENSv1DomainId, + label: { labelHash, interpreted: firstLabel }, + labelHash, + canonical: isCanonicalName(name), + registryId: getRootRegistryId(di.context.namespace), + subregistryId: null, + tokenId: null, + node: null, + ownerId: null, + rootRegistryOwnerId: null, + canonicalName: name, + canonicalDepth: null, + canonicalPath: null, + canonicalNode: null, + canonicalLabelHashPath: null, + __canonicalNamePrefix: null, + __latestRegistrationExpiry: 2n ** 256n - 1n, + __latestRegistrationStart: 2n ** 256n - 1n, + }; + return virtualDomain as unknown as Domain; +} + +function isCanonicalName(name: InterpretedName): boolean { + // TODO: implement this + return true; +} + +export const VirtualDomainRef = builder.objectRef("VirtualDomain"); + +VirtualDomainRef.implement({ + description: + "A valid ENS name that is not currently indexed (e.g. an off-chain or CCIP-Read name). Indexed-data fields are absent; `resolve` performs full forward resolution via the UniversalResolver.", + interfaces: [DomainInterfaceRef], + isTypeOf: (value) => (value as VirtualDomain).type === "VirtualDomain", + fields: () => ({}), +}); + ////////////////////////////// // ENSv2Domain Implementation ////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 445bbed27..83076a30c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -7,12 +7,15 @@ 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 { + getDomainIdByInterpretedName, + nameHasResolver, +} 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 { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; -import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; +import { DomainInterfaceRef, makeVirtualDomain } from "@/omnigraph-api/schema/domain"; import { DOMAINS_ORDERING_DESCRIPTION, DomainIdInput, @@ -125,9 +128,15 @@ 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 domainId = await getDomainIdByInterpretedName(args.by.name); + if (domainId !== null) return domainId; + // Name is not indexed. Verify it actually has a resolver on-chain (via UniversalResolver) + // before returning a VirtualDomain — prevents surfacing a VirtualDomain for names that + // don't exist (e.g. random-string.eth with no resolver). + if (!(await nameHasResolver(args.by.name))) return null; + return makeVirtualDomain(args.by.name); }, }), diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index addda6238..9b7df5a43 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -1906,6 +1906,64 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } +""" +A valid ENS name that is not currently indexed (e.g. an off-chain or CCIP-Read name). Indexed-data fields are absent; `resolve` performs full forward resolution via the UniversalResolver. +""" +type VirtualDomain 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. + """ + 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.""" + 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) """