Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
// small hack for testing since I cannot connect to production database :(
if (name === "offchaindemo.eth") {
return true;
}
Comment on lines +162 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// small hack for testing since I cannot connect to production database :(
if (name === "offchaindemo.eth") {
return true;
}

Hardcoded test data in production code causes incorrect resolver check for "offchaindemo.eth"

Fix on Vercel

Comment on lines +161 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the hardcoded resolver bypass.

Line 163 makes nameHasResolver() return true without consulting the chain, so Query.domain(by: { name: "offchaindemo.eth" }) can surface a VirtualDomain even when the real resolver lookup would fail in the active namespace/environment. If this is only needed for local testing, it should live behind a test seam, not in the shipped code.

Suggested change
 export async function nameHasResolver(name: InterpretedName): Promise<boolean> {
-  // small hack for testing since I cannot connect to production database :(
-  if (name === "offchaindemo.eth") {
-    return true;
-  }
   const {
     contracts: {
       UniversalResolver: { address, abi },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function nameHasResolver(name: InterpretedName): Promise<boolean> {
// small hack for testing since I cannot connect to production database :(
if (name === "offchaindemo.eth") {
return true;
}
export async function nameHasResolver(name: InterpretedName): Promise<boolean> {
const {
contracts: {
UniversalResolver: { address, abi },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts` around
lines 161 - 165, The function nameHasResolver contains a hardcoded test bypass
returning true for "offchaindemo.eth"; remove that conditional so the function
always performs the real resolver check (i.e., eliminate the special-case branch
in nameHasResolver and rely on the existing chain lookup logic), or if needed
for local tests move the bypass behind a proper test seam/flag checked only in
test code (e.g., use a test-only environment variable or dependency-injected
mock) so the production code path for nameHasResolver and Query.domain remains
accurate.

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);
}
8 changes: 6 additions & 2 deletions apps/ensapi/src/omnigraph-api/schema/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 66 additions & 1 deletion apps/ensapi/src/omnigraph-api/schema/domain.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Domain, "type"> & { type: "VirtualDomain" };

export function makeVirtualDomain(name: InterpretedName): Domain {
const firstLabel = interpretedNameToInterpretedLabels(name)[0];
const labelHash = labelhashInterpretedLabel(firstLabel);
const virtualDomain: VirtualDomain = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe type casting in makeVirtualDomain bypasses TypeScript type safety for ENSv1DomainId construction

Fix on Vercel

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,
Comment on lines +375 to +386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The synthetic registry is wrong for nested virtual names.

Line 385 always assigns getRootRegistryId(...), but this file defines Domain.registry as the registry under which the domain exists. For foo.bar.eth, the leaf label is foo (Line 376), so its registry is bar.eth’s subregistry, not the ENS root. As written, registry, parent, and any registry-scoped follow-up queries are only correct for depth-1 names.

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;
Comment on lines +404 to +405

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// TODO: implement this
return true;
// VirtualDomains are unindexed names without canonical metadata, so they are never canonical.
// All canonical metadata fields (canonicalDepth, canonicalPath, canonicalNode, etc.) are set
// to null for VirtualDomains, indicating they are not in the canonical nametree.
return false;

The isCanonicalName function incorrectly returns true for all VirtualDomains, marking unindexed names as canonical when they should never be canonical.

Fix on Vercel

Comment on lines +384 to +405

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not mark every virtual domain as canonical.

DomainInterfaceRef.canonical at Lines 132-137 becomes non-null whenever domain.canonical is truthy. Here isCanonicalName() always returns true, but Lines 392-395 leave the canonical path/node metadata null, so virtual domains claim canonicality without any canonical data to support it. Until canonicality can be derived, this should stay false/null.

Suggested change
   const virtualDomain: VirtualDomain = {
     type: "VirtualDomain",
@@
-    canonical: isCanonicalName(name),
+    canonical: false,
@@
-}
-
-function isCanonicalName(name: InterpretedName): boolean {
-  // TODO: implement this
-  return true;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensapi/src/omnigraph-api/schema/domain.ts` around lines 384 - 405, The
virtual domain code is incorrectly marking every virtual domain as canonical
because isCanonicalName(name) always returns true; change isCanonicalName to
return false until you can correctly derive canonicality (or implement the real
logic), so that DomainInterfaceRef.canonical (the canonical field set when
creating virtualDomain) remains false/null when canonical path/node metadata
(canonicalPath, canonicalNode, canonicalLabelHashPath, __canonicalNamePrefix)
are not populated; update the isCanonicalName function to return false by
default or explicitly check for the presence of canonical metadata before
returning true.

}

export const VirtualDomainRef = builder.objectRef<Domain>("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
//////////////////////////////
Expand Down
17 changes: 13 additions & 4 deletions apps/ensapi/src/omnigraph-api/schema/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Comment on lines +131 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Virtual domain IDs are not reloadable through this API.

This resolver can now return makeVirtualDomain(args.by.name), but Line 132 still treats by.id as an indexed DomainId and hands it to the normal DomainInterfaceRef.load path. That loader only hits ensDb.query.domain in apps/ensapi/src/omnigraph-api/schema/domain.ts Lines 73-80, so echoing back the id from a virtual domain will not reconstruct the same object. Right now the new VirtualDomain.id is not a stable API reference in practice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensapi/src/omnigraph-api/schema/query.ts` around lines 131 - 139, The
resolver currently returns makeVirtualDomain(args.by.name) for names without
indexed IDs but still treats args.by.id as a normal DomainId—causing virtual IDs
to not reload. Fix by detecting virtual domain IDs and materializing them
instead of passing them to the standard loader: either (A) in the resolve
function add a check for a virtual-id pattern (or use a small helper
isVirtualDomainId) and if true return
makeVirtualDomain(extractNameFromVirtualId(args.by.id)), otherwise continue to
use args.by.id; or (B) update DomainInterfaceRef.load to recognize the
virtual-id format and return makeVirtualDomain(...) when encountering it.
Reference functions/types: resolve, makeVirtualDomain,
getDomainIdByInterpretedName, nameHasResolver, DomainInterfaceRef.load, and
ensDb.query.domain so the change targets the correct code paths.

},
}),

Expand Down
58 changes: 58 additions & 0 deletions packages/enssdk/src/omnigraph/generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down
Loading