diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index b75da6d21..f446afd05 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -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"; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index b9ee706b8..7989b1e5c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -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, @@ -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 }>; + 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 }) { + addresses { coinType address } + texts { key value } + } + } + } + `; + + it("resolves ETH address for test.eth", async () => { + const result = await request(DomainRecords, { + name: "test.eth", + addresses: [60], + texts: [], + }); + + expect(result.domain.records?.addresses).toEqual([{ coinType: 60, address: DEVNET_OWNER }]); + expect(result.domain.records?.texts).toEqual([]); + }); + + it("resolves address and text records for example.eth", async () => { + const result = await request(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" }]); + }); + + +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 31d98b1c7..b777c8f26 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -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, @@ -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"); @@ -101,6 +106,37 @@ export type ENSv1Domain = Exclude; export type Domain = Exclude; +/** + * 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, +): Promise | 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 ////////////////////////////////// @@ -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), }), /////////////// @@ -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.", + 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 }, + ), + ); + + return result as ResolverRecordsResponseBase; + }, + }), + /////////////////////// // Domain.registration /////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts new file mode 100644 index 000000000..9843c8e1c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -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.).", + required: false, + }), + 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<{ + name: string | null | undefined; + texts: Record | undefined; + addresses: Record | 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, + })) + : [], + }), + }), + }); diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 82c298c64..85dc08c9d 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -224,6 +224,14 @@ interface Domain { """ path: [Domain!] + """ + Resolve ENS records for this Domain via the ENS protocol. Only canonical domains can be resolved. Returns null if the domain is not canonical. + """ + records( + """Which records to resolve.""" + selection: ResolveSelectionInput! + ): ResolvedRecords + """The latest Registration for this Domain, if exists.""" registration: Registration @@ -343,6 +351,14 @@ type ENSv1Domain implements Domain { """ path: [Domain!] + """ + Resolve ENS records for this Domain via the ENS protocol. Only canonical domains can be resolved. Returns null if the domain is not canonical. + """ + records( + """Which records to resolve.""" + selection: ResolveSelectionInput! + ): ResolvedRecords + """The latest Registration for this Domain, if exists.""" registration: Registration @@ -394,6 +410,14 @@ type ENSv2Domain implements Domain { """ permissions(after: String, before: String, first: Int, last: Int, where: DomainPermissionsWhereInput): ENSv2DomainPermissionsConnection + """ + Resolve ENS records for this Domain via the ENS protocol. Only canonical domains can be resolved. Returns null if the domain is not canonical. + """ + records( + """Which records to resolve.""" + selection: ResolveSelectionInput! + ): ResolvedRecords + """The latest Registration for this Domain, if exists.""" registration: Registration @@ -1021,6 +1045,56 @@ type Renewal { """RenewalId represents a enssdk#RenewalId.""" scalar RenewalId +""" +Specifies which ENS records to resolve. At least one field must be set to receive any records. +""" +input ResolveSelectionInput { + """Coin types to resolve address records for (e.g. `60` for ETH).""" + addresses: [CoinType!] + + """ + Whether to resolve the `name` record (used in Reverse Resolution, ENSIP-19). + """ + reverseName: Boolean + + """Text record keys to resolve (e.g. `avatar`, `description`, `com.).""" + texts: [String!] +} + +"""A resolved address record for an ENS name.""" +type ResolvedAddressRecord { + """The address value, or null if not set.""" + address: String + + """The coin type for this address record.""" + coinType: CoinType! +} + +""" +Records resolved for a specific ENS name via the ENS protocol. Only selected records are populated. +""" +type ResolvedRecords { + """Resolved address records for selected coin types.""" + addresses: [ResolvedAddressRecord!]! + + """ + The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set or not selected. + """ + reverseName: String + + """Resolved text records for selected keys.""" + texts: [ResolvedTextRecord!]! +} + +"""A resolved text record for an ENS name.""" +type ResolvedTextRecord { + """The text record key.""" + key: String! + + """The text record value, or null if not set.""" + value: String +} + """A Resolver represents a Resolver contract on-chain.""" type Resolver { """Whether Resolver is a BridgedResolver."""