Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/ensapi/src/omnigraph-api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import "./schema/permissions";
import "./schema/query";
import "./schema/registry";
import "./schema/renewal";
import "./schema/resolution";
import "./schema/resolver-records";
import "./schema/scalars";

Expand Down
48 changes: 48 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { InterpretedLabel, InterpretedName } from "enssdk";
import { beforeAll, describe, expect, it } from "vitest";

import { DEVNET_OWNER } from "@ensnode/ensnode-sdk/internal";

import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names";
import {
DomainSubdomainsPaginated,
Expand Down Expand Up @@ -252,3 +254,49 @@ describe("Domain.events filtering (EventsWhereInput)", () => {
expect(events.length).toBe(0);
});
});

describe("Domain.records", () => {
type DomainRecordsResult = {
domain: {
records: {
addresses: Array<{ coinType: number; address: string | null }>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: you can use the enssdk#CoinType type here

texts: Array<{ key: string; value: string | null }>;
} | null;
};
};

const DomainRecords = gql`
query DomainRecords($name: InterpretedName!, $addresses: [CoinType!], $texts: [String!]) {
domain(by: { name: $name }) {
records(selection: { addresses: $addresses, texts: $texts }) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in the future i want to move the addresses/texts selections into the fields themselves (which will require some gql query introspection, which we do elsewhere) but good call to not do that immediately

addresses { coinType address }
texts { key value }
}
}
}
`;

it("resolves ETH address for test.eth", async () => {
const result = await request<DomainRecordsResult>(DomainRecords, {
name: "test.eth",
addresses: [60],
texts: [],
});

expect(result.domain.records?.addresses).toEqual([{ coinType: 60, address: DEVNET_OWNER }]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit:

await expect().resolves.toMatchObject({
  domain: { records: { texts: [], addresses: [{coinType: 60, address: DEVNET_OWNER }] } }
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto below, consolidate into a single object match and use the await expect(...).resolves syntax. we should add that preference to our AGENT.md.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Actually, remove this case in favor of the below case which tests both at the same time.

expect(result.domain.records?.texts).toEqual([]);
});

it("resolves address and text records for example.eth", async () => {
const result = await request<DomainRecordsResult>(DomainRecords, {
name: "example.eth",
addresses: [60],
texts: ["description"],
});

expect(result.domain.records?.addresses).toEqual([{ coinType: 60, address: DEVNET_OWNER }]);
expect(result.domain.records?.texts).toEqual([{ key: "description", value: "example.eth" }]);
});


});
97 changes: 72 additions & 25 deletions apps/ensapi/src/omnigraph-api/schema/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
import { withSpanAsync } from "@/lib/instrumentation/auto-span";
import { builder } from "@/omnigraph-api/builder";
import type { context as graphqlContext } from "@/omnigraph-api/context";
import {
orderPaginationBy,
paginateBy,
Expand Down Expand Up @@ -40,8 +41,12 @@ import { EventRef, EventsWhereInput } from "@/omnigraph-api/schema/event";
import { LabelRef } from "@/omnigraph-api/schema/label";
import { OrderDirection } from "@/omnigraph-api/schema/order-direction";
import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions";
import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk";
import { resolveForward } from "@/lib/resolution/forward-resolution";
import { runWithTrace } from "@/lib/tracing/tracing-api";
import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration";
import { RegistryRef } from "@/omnigraph-api/schema/registry";
import { ResolvedRecordsRef, ResolveSelectionInput } from "@/omnigraph-api/schema/resolution";
import { ResolverRef } from "@/omnigraph-api/schema/resolver";

const tracer = trace.getTracer("schema/Domain");
Expand Down Expand Up @@ -101,6 +106,37 @@ export type ENSv1Domain = Exclude<typeof ENSv1DomainRef.$inferType, ENSv1DomainI
export type ENSv2Domain = Exclude<typeof ENSv2DomainRef.$inferType, ENSv2DomainId>;
export type Domain = Exclude<typeof DomainInterfaceRef.$inferType, DomainId>;

/**
* Returns the canonical interpreted name for a domain, or null if the domain is not canonical.
* Reuses the canonical path DataLoaders so repeated calls within a request are batched/cached.
*/
async function getDomainInterpretedName(
domain: Domain,
context: ReturnType<typeof graphqlContext>,
): Promise<ReturnType<typeof interpretedLabelsToInterpretedName> | null> {
const canonicalPath = isENSv1Domain(domain)
? await context.loaders.v1CanonicalPath.load(domain.id)
: await context.loaders.v2CanonicalPath.load(domain.id);

if (!canonicalPath) return null;

const domains = await rejectAnyErrors(
DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath),
);

const labels = canonicalPath.map((domainId: DomainId) => {
const found = domains.find((d) => d.id === domainId);
if (!found) {
throw new Error(
`Invariant(getDomainInterpretedName): Domain in CanonicalPath not found:\nPath: ${JSON.stringify(canonicalPath)}\nDomainId: ${domainId}`,
);
}
return found.label.interpreted;
});

return interpretedLabelsToInterpretedName(labels);
}

//////////////////////////////////
// DomainInterface Implementation
//////////////////////////////////
Expand Down Expand Up @@ -137,31 +173,7 @@ DomainInterfaceRef.implement({
tracing: true,
type: "InterpretedName",
nullable: true,
resolve: async (domain, args, context) => {
const canonicalPath = isENSv1Domain(domain)
? await context.loaders.v1CanonicalPath.load(domain.id)
: await context.loaders.v2CanonicalPath.load(domain.id);
if (!canonicalPath) return null;

// TODO: this could be more efficient if the get*CanonicalPath helpers included the label
// join for us.
const domains = await rejectAnyErrors(
DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath),
);

const labels = canonicalPath.map((domainId) => {
const found = domains.find((d) => d.id === domainId);
if (!found) {
throw new Error(
`Invariant(Domain.name): Domain in CanonicalPath not found:\nPath: ${JSON.stringify(canonicalPath)}\nDomainId: ${domainId}`,
);
}

return found.label.interpreted;
});

return interpretedLabelsToInterpretedName(labels);
},
resolve: (domain, args, context) => getDomainInterpretedName(domain, context),
}),

///////////////
Expand Down Expand Up @@ -206,6 +218,41 @@ DomainInterfaceRef.implement({
resolve: (parent) => getDomainResolver(parent.id),
}),

///////////////////
// Domain.records
///////////////////
records: t.field({
description:
"Resolve ENS records for this Domain via the ENS protocol. Only canonical domains can be resolved. Returns null if the domain is not canonical.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

additional: only canonical, normalized names can be resolved; so names with unknown labels can't be resolved actually (which is crazy to me but that's what the contracts say)

type: ResolvedRecordsRef,
nullable: true,
args: {
selection: t.arg({
type: ResolveSelectionInput,
required: true,
description: "Which records to resolve.",
}),
},
resolve: async (domain, { selection }, context) => {
const name = await getDomainInterpretedName(domain, context);
if (!name) return null;

const { result } = await runWithTrace(() =>
resolveForward(
name,
{
name: selection.reverseName ?? undefined,
texts: selection.texts ?? undefined,
addresses: selection.addresses ?? undefined,
},
{ accelerate: false, canAccelerate: false },
),
Comment on lines +224 to +249
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify context acceleration wiring and Domain.records schema args.
rg -nP -C3 '\bcanAccelerate\b|records:\s*t\.field|accelerate:\s*t\.arg' --iglob '*.ts'

Repository: namehash/ensnode

Length of output: 17937


🏁 Script executed:

# Check omnigraph context configuration and middleware wiring
cd apps/ensapi

# Find omnigraph context type definition
rg -nP 'type.*Context|interface.*Context' --iglob '*omnigraph*.ts' -B2 -A5

# Check if omnigraph routes/schema uses canAccelerateMiddleware
rg -nP 'canAccelerateMiddleware|omnigraph-api' --iglob '*.ts' -B2 -A2

Repository: namehash/ensnode

Length of output: 33798


🏁 Script executed:

# Check omnigraph context definition and yoga setup
cat -n apps/ensapi/src/omnigraph-api/context.ts

# Check omnigraph yoga handler
cat -n apps/ensapi/src/omnigraph-api/yoga.ts

Repository: namehash/ensnode

Length of output: 3243


🏁 Script executed:

# Check omnigraph router mounting and middleware setup
rg -nP 'omnigraph|canAccelerateMiddleware' apps/ensapi/src/handlers/api/router.ts -B3 -A3

# Check app.ts for middleware chain
rg -nP 'app\.use|middleware|canAccelerateMiddleware' apps/ensapi/src/app.ts -B2 -A2

Repository: namehash/ensnode

Length of output: 971


🏁 Script executed:

# Check omnigraph-api handler definition
cat -n apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts

Repository: namehash/ensnode

Length of output: 1068


🏁 Script executed:

# Check if there's any middleware applied globally before omnigraph routes
cat -n apps/ensapi/src/app.ts | head -50

# Check if omnigraph has any acceleration-related code
rg -nP 'accelerate|canAccelerate' apps/ensapi/src/omnigraph-api/ --iglob '*.ts'

Repository: namehash/ensnode

Length of output: 1990


🏁 Script executed:

# Check how graphql-yoga context is supposed to work with Hono
# Look at yoga creation and how context parameter is passed
rg -nP 'createYoga|context' apps/ensapi/src/omnigraph-api/yoga.ts -B5 -A5

# Check if there are other GraphQL resolvers that access Hono context variables
rg -nP 'context\.' apps/ensapi/src/omnigraph-api/schema/ --iglob '*.ts' | head -20

Repository: namehash/ensnode

Length of output: 1604


🏁 Script executed:

# Check graphql-yoga documentation or types for context parameter
# Look for how yoga.fetch uses the second parameter (c.var)
rg -nP 'fetch.*c\.var|context.*function' apps/ensapi/ --iglob '*.ts' -B2 -A2

# Check if there are examples of graphql-yoga receiving context from request
rg -nP 'createYoga.*context|yoga.*context' apps/ensapi/ --iglob '*.ts' -B5 -A5

Repository: namehash/ensnode

Length of output: 2707


🏁 Script executed:

# Verify graphql-yoga context function signature and how it receives request context
# Check if context can be a function that receives request info
cat -n apps/ensapi/src/omnigraph-api/yoga.ts | grep -A20 "createYoga"

# Look at subgraph-api to see if it shows context wiring pattern
cat -n apps/ensapi/src/handlers/subgraph/subgraph-api.ts | grep -B5 -A10 "context"

Repository: namehash/ensnode

Length of output: 1883


🏁 Script executed:

# Check graphql-yoga version and context handling
# Look at package.json for graphql-yoga version
cd apps/ensapi && cat package.json | grep -A2 -B2 "graphql-yoga"

# Look at how subgraph or other yoga instances handle context from serverContext
rg -nP 'context.*serverContext|createYoga.*context' apps/ensapi/ --iglob '*.ts' -B3 -A10

Repository: namehash/ensnode

Length of output: 267


🏁 Script executed:

# Check graphql-yoga version and context patterns
grep -A2 -B2 "graphql-yoga" apps/ensapi/package.json

# Look for context function patterns with parameters in the codebase
rg -nP 'context\s*=.*\(\s*\w+\s*\)' apps/ensapi/src/omnigraph-api/ --iglob '*.ts' -B2 -A5

Repository: namehash/ensnode

Length of output: 197


🌐 Web query:

graphql-yoga 5.16 context function serverContext Hono variables fetch

💡 Result:

In GraphQL Yoga 5.16 (released around early 2026 per changelog), the context function and serverContext work with Hono via the WHATWG Fetch API compatibility, as Hono exposes c.req.raw (a standard Request) and app.fetch. Default context includes: request (Fetch Request), params, query (DocumentNode), operationName, variables (query variables object), extensions. For Hono integration (no official docs, but community examples from 2023+ still valid for v5): import { Hono } from 'hono' import { createYoga, createSchema } from 'graphql-yoga' import { serve } from '@hono/node-server' const schema = createSchema({...}) const yoga = createYoga({ schema }) const app = new Hono app.all('/graphql*', async (c) => yoga.fetch(c.req.raw, { /* serverContext if needed */ })) serve({ fetch: app.fetch, port: 3000 }) Or app.use('/graphql', async (c) => yoga.handle({ request: c.req.raw }, {}) ) // older handle In resolvers, access context.variables (object from client query), context.request (for headers/body), context.fetch (server fetch utility). To add Hono-specific serverContext (e.g., Hono c object), pass as second arg to handle/fetch: yoga.fetch(request, { honoC: c }) Then in user context factory: context: async (initialContext) => { const { honoC } = initialContext; return { honoVars: honoC.env?.VAR || c.req.param('id') } } For Cloudflare Workers/Deno (Hono common): serverContext auto-includes FetchEvent with request, waitUntil, etc. No explicit serverContext needed usually. Examples access context.request.headers.get('auth'), context.variables.userId in resolvers. Use context.fetch(url) for internal server calls.

Citations:


🏁 Script executed:

# Check if canAccelerateMiddleware could be applied to omnigraph
# Look at isRealtimeMiddleware to see if it's applied to omnigraph
rg -nP 'isRealtimeMiddleware|makeIsRealtimeMiddleware' apps/ensapi/src/ --iglob '*.ts' -B2 -A2

# Check omnigraph-api router to see all middlewares applied
cat -n apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts

Repository: namehash/ensnode

Length of output: 5546


🏁 Script executed:

# Final verification: check if context function in yoga can be updated to receive serverContext
# Look at graphql-yoga documentation patterns in the code
rg -nP 'context.*async|context.*=>|serverContext' apps/ensapi/src/omnigraph-api/ --iglob '*.ts' -B3 -A3

Repository: namehash/ensnode

Length of output: 10109


🏁 Script executed:

# Check if omnigraph is expected to support acceleration or if it's REST-only
rg -nP 'omnigraph.*accelerat|accelerat.*omnigraph' apps/ensapi/src/ --iglob '*.ts'

# Check if there are any tests or documentation about Domain.records with accelerate
rg -nP 'Domain.*records|records.*accelerate' apps/ensapi/src/omnigraph-api/ --iglob '*.ts' -B2 -A2

Repository: namehash/ensnode

Length of output: 836


Expose and honor the accelerate argument.

Domain.records currently has no accelerate GraphQL arg and always passes { accelerate: false, canAccelerate: false }, so callers cannot use the default accelerated path required by the API contract.

To implement this, wire the acceleration middleware to the Omnigraph API router and update the context factory to receive canAccelerate from the middleware:

  1. Apply canAccelerateMiddleware and its prerequisite makeIsRealtimeMiddleware + indexingStatusMiddleware to the omnigraph-api router (similar to resolution-api)
  2. Update omnigraph-api/context.ts to accept an initial context parameter containing canAccelerate from the middleware
  3. Add the accelerate GraphQL arg to Domain.records and pass both accelerate and context.canAccelerate to resolveForward()
Proposed fix
     records: t.field({
       description:
         "Resolve ENS records for this Domain via the ENS protocol. Only canonical domains can be resolved. Returns null if the domain is not canonical.",
       type: ResolvedRecordsRef,
       nullable: true,
       args: {
+        accelerate: t.arg.boolean({
+          required: false,
+          defaultValue: true,
+          description: "Whether to use accelerated resolution when available.",
+        }),
         selection: t.arg({
           type: ResolveSelectionInput,
           required: true,
           description: "Which records to resolve.",
         }),
       },
-      resolve: async (domain, { selection }, context) => {
+      resolve: async (domain, { accelerate, selection }, context) => {
         const name = await getDomainInterpretedName(domain, context);
         if (!name) return null;
 
         const { result } = await runWithTrace(() =>
           resolveForward(
             name,
             {
               texts: selection.texts ?? undefined,
               addresses: selection.addresses ?? undefined,
             },
-            { accelerate: false, canAccelerate: false },
+            { accelerate: accelerate ?? true, canAccelerate: context.canAccelerate ?? false },
           ),
         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/omnigraph-api/schema/domain.ts` around lines 224 - 249,
Domain.records currently lacks an accelerate arg and always calls resolveForward
with accelerate: false; add a GraphQL arg "accelerate" to the Domain.records
field (the field defined at records: t.field) and forward its value along with
context.canAccelerate into resolveForward (i.e., call resolveForward(name,
{...}, { accelerate: args.accelerate, canAccelerate: context.canAccelerate })).
Also wire up the acceleration middleware chain to the omnigraph-api router by
applying makeIsRealtimeMiddleware, indexingStatusMiddleware and
canAccelerateMiddleware (same order used in resolution-api) and update the
omnigraph-api context factory to accept the initial context param that contains
canAccelerate from the middleware so context.canAccelerate is available in the
resolver; keep getDomainInterpretedName usage unchanged.

);

return result as ResolverRecordsResponseBase;
},
Comment on lines +236 to +253
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.

P1 Unhandled throw for canonical names with encoded labels

getDomainInterpretedName can return an InterpretedName that contains encoded labelhash segments (e.g. [abc123].eth). When that name is passed to resolveForward, it immediately throws "'[abc123].eth' must be normalized to be resolvable." (see forward-resolution.ts line 149). This error propagates as a GraphQL field error rather than returning null, violating the stated API contract: "Returns null if the domain is not canonical." Canonical domains with unknown labels would surface an unexpected 500-level error to the client instead of a clean null.

A simple guard before calling resolveForward would fix this:

import { isNormalizedName } from "enssdk";

resolve: async (domain, { selection }, context) => {
  const name = await getDomainInterpretedName(domain, context);
  if (!name) return null;
  if (!isNormalizedName(name)) return null;  // add this guard

  const { result } = await runWithTrace(() =>
    resolveForward(...)
  );
  ...
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

hmmmm, make sense

}),

///////////////////////
// Domain.registration
///////////////////////
Expand Down
107 changes: 107 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { CoinType } from "enssdk";

import { builder } from "@/omnigraph-api/builder";

///////////////////////
// ResolveSelectionInput
///////////////////////
export const ResolveSelectionInput = builder.inputType("ResolveSelectionInput", {
description:
"Specifies which ENS records to resolve. At least one field must be set to receive any records.",
fields: (t) => ({
reverseName: t.boolean({
description: "Whether to resolve the `name` record (used in Reverse Resolution, ENSIP-19).",
required: false,
}),
texts: t.stringList({
description: "Text record keys to resolve (e.g. `avatar`, `description`, `com.).",
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.

P2 Unclosed backtick in description string

The description for the texts field has an unclosed backtick before com., which will render incorrectly in GraphQL API documentation.

Suggested change
description: "Text record keys to resolve (e.g. `avatar`, `description`, `com.).",
description: "Text record keys to resolve (e.g. `avatar`, `description`, `com.`).",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

oops, should be com.github

required: false,
Comment thread
sevenzing marked this conversation as resolved.
}),
addresses: t.field({
description: "Coin types to resolve address records for (e.g. `60` for ETH).",
type: ["CoinType"],
required: false,
}),
}),
});

///////////////////////
// ResolvedTextRecord
///////////////////////
export const ResolvedTextRecordRef = builder
.objectRef<{ key: string; value: string | null }>("ResolvedTextRecord")
.implement({
description: "A resolved text record for an ENS name.",
fields: (t) => ({
key: t.exposeString("key", {
description: "The text record key.",
nullable: false,
}),
value: t.exposeString("value", {
description: "The text record value, or null if not set.",
nullable: true,
}),
}),
});

///////////////////////////
// ResolvedAddressRecord
///////////////////////////
export const ResolvedAddressRecordRef = builder
.objectRef<{ coinType: CoinType; address: string | null }>("ResolvedAddressRecord")
.implement({
description: "A resolved address record for an ENS name.",
fields: (t) => ({
coinType: t.field({
description: "The coin type for this address record.",
type: "CoinType",
nullable: false,
resolve: (r) => r.coinType,
}),
address: t.exposeString("address", {
description: "The address value, or null if not set.",
nullable: true,
}),
}),
});

////////////////////
// ResolvedRecords
////////////////////
export const ResolvedRecordsRef = builder
.objectRef<{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the types used here can likely be inferred (or already exist) within the resolution module likely using ResolverRecordsResponse or ResolverRecordsResponseBase, make sure to use that type instead of re-defining

name: string | null | undefined;
texts: Record<string, string | null> | undefined;
addresses: Record<CoinType, string | null> | undefined;
}>("ResolvedRecords")
.implement({
description:
"Records resolved for a specific ENS name via the ENS protocol. Only selected records are populated.",
fields: (t) => ({
reverseName: t.string({
description:
"The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set or not selected.",
nullable: true,
resolve: (r) => r.name ?? null,
}),
texts: t.field({
description: "Resolved text records for selected keys.",
type: [ResolvedTextRecordRef],
nullable: false,
resolve: (r) =>
r.texts ? Object.entries(r.texts).map(([key, value]) => ({ key, value })) : [],
}),
addresses: t.field({
description: "Resolved address records for selected coin types.",
type: [ResolvedAddressRecordRef],
nullable: false,
resolve: (r) =>
r.addresses
? Object.entries(r.addresses).map(([coinType, address]) => ({
coinType: Number(coinType) as CoinType,
address,
}))
: [],
}),
}),
});
Loading
Loading