From eae25f67785dd1f303cbef170df44fa02e6e901e Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 21 Apr 2026 20:55:50 +0400 Subject: [PATCH 01/30] add records resolution --- apps/ensapi/src/omnigraph-api/schema.ts | 1 + .../schema/domain.integration.test.ts | 48 ++++++++ .../ensapi/src/omnigraph-api/schema/domain.ts | 97 ++++++++++++---- .../src/omnigraph-api/schema/resolution.ts | 107 ++++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 74 ++++++++++++ 5 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/resolution.ts diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index b75da6d213..f446afd05d 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 b9ee706b89..7989b1e5c9 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 31d98b1c70..b777c8f265 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 0000000000..9843c8e1c2 --- /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 82c298c64c..85dc08c9d3 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.""" From e8653a400b8f821f08f0f347673237ffd5fb8280 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 16:27:28 +0300 Subject: [PATCH 02/30] small docker fixes --- docker/envs/.env.docker.devnet | 2 -- package.json | 1 + packages/integration-test-env/package.json | 1 + packages/integration-test-env/src/seed-cli.ts | 17 +++++++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 packages/integration-test-env/src/seed-cli.ts diff --git a/docker/envs/.env.docker.devnet b/docker/envs/.env.docker.devnet index 9f3fc99897..09c4a40614 100644 --- a/docker/envs/.env.docker.devnet +++ b/docker/envs/.env.docker.devnet @@ -5,8 +5,6 @@ PLUGINS=subgraph,unigraph,protocol-acceleration # ENSIndexer and ENSApi ENSINDEXER_SCHEMA_NAME=docker_devnet_v1 -# ENSIndexer and ENSApi -RPC_URL_1=http://devnet:8545 # ENSIndexer and ENSRainbow LABEL_SET_VERSION=0 # ENSIndexer and ENSRainbow diff --git a/package.json b/package.json index 535dffb977..c95df1cd04 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "release:postversion": "pnpm docker:version:sync && pnpm generate:openapi", "packages:prepublish": "pnpm -r prepublish", "devnet": "docker compose -f docker/services/devnet.yml up", + "seed:devnet": "pnpm -F @ensnode/integration-test-env seed:devnet", "docker:build:ensnode": "pnpm run -w --parallel \"/^docker:build:.*/\"", "docker:version:sync": "node ./scripts/sync-docker-services-tags.mjs \"$(pnpm -F ensapi -s version:current)\"", "docker:build:ensindexer": "docker build -f apps/ensindexer/Dockerfile -t ghcr.io/namehash/ensnode/ensindexer:latest .", diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index da8e05d9e8..0ce968d039 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -6,6 +6,7 @@ "type": "module", "description": "Integration test environment orchestration for ENSNode", "scripts": { + "seed:devnet": "tsx src/seed-cli.ts", "start": "CI=1 tsx src/orchestrator.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/integration-test-env/src/seed-cli.ts b/packages/integration-test-env/src/seed-cli.ts new file mode 100644 index 0000000000..339d9aedcf --- /dev/null +++ b/packages/integration-test-env/src/seed-cli.ts @@ -0,0 +1,17 @@ +import { ensTestEnvChain } from "@ensnode/datasources"; + +import { seedDevnet } from "./seed/index"; + +const defaultRpcUrl = ensTestEnvChain.rpcUrls.default.http[0]; +const rpcUrl = process.env.DEVNET_RPC_URL ?? defaultRpcUrl; + +async function main() { + console.log(`[seed:devnet] Seeding devnet at ${rpcUrl}...`); + await seedDevnet(rpcUrl); + console.log("[seed:devnet] Done"); +} + +main().catch((error: unknown) => { + console.error("[seed:devnet] Failed:", error); + process.exit(1); +}); From 6ff2930c15dee13e2cf3eb3f18ff8e623f71327c Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 16:28:04 +0300 Subject: [PATCH 03/30] add graphql styled records selection --- apps/ensapi/src/omnigraph-api/builder.ts | 2 + .../lib/build-records-selection.test.ts | 167 +++++++++ .../lib/build-records-selection.ts | 81 ++++ .../lib/records-selection-config.ts | 95 +++++ .../schema/domain.integration.test.ts | 107 +++++- .../ensapi/src/omnigraph-api/schema/domain.ts | 26 +- .../src/omnigraph-api/schema/resolution.ts | 178 +++++++-- .../src/omnigraph-api/schema/scalars.ts | 21 ++ .../src/omnigraph/generated/introspection.ts | 354 ++++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 102 +++-- packages/enssdk/src/omnigraph/graphql.ts | 2 + 11 files changed, 1028 insertions(+), 107 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 8f50658849..7a3862681a 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -10,6 +10,7 @@ import type { CoinType, DomainId, Hex, + InterfaceId, InterpretedLabel, InterpretedName, Node, @@ -63,6 +64,7 @@ export type BuilderScalars = { Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; CoinType: { Input: CoinType; Output: CoinType }; + InterfaceId: { Input: InterfaceId; Output: InterfaceId }; Node: { Input: Node; Output: Node }; InterpretedName: { Input: InterpretedName; Output: InterpretedName }; InterpretedLabel: { Input: InterpretedLabel; Output: InterpretedLabel }; diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts new file mode 100644 index 0000000000..8b780e0284 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts @@ -0,0 +1,167 @@ +import { + type GraphQLFieldConfigMap, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLScalarType, + GraphQLString, + Kind, + parse, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { + buildRecordsSelectionFromResolveInfo, + EMPTY_RECORDS_SELECTION_MESSAGE, +} from "@/omnigraph-api/lib/build-records-selection"; +import { + RECORDS_SELECTION_PARAMETRIC_FIELDS, + RECORDS_SELECTION_SIMPLE_FIELDS, +} from "@/omnigraph-api/lib/records-selection-config"; + +const stringListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))); +const intListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLInt))); + +const mockBigIntArg = new GraphQLNonNull( + new GraphQLScalarType({ + name: "BigInt", + serialize: String, + parseValue: (value) => BigInt(value as string | number | bigint), + parseLiteral(ast) { + if (ast.kind === Kind.STRING || ast.kind === Kind.INT) return BigInt(ast.value); + throw new Error("BigInt literal must be a string or integer"); + }, + }), +); + +function buildMockResolvedRecordsType() { + const fields: Record }> = {}; + + for (const { graphqlField } of RECORDS_SELECTION_SIMPLE_FIELDS) { + fields[graphqlField] = { type: GraphQLString }; + } + + for (const { graphqlField, argName } of RECORDS_SELECTION_PARAMETRIC_FIELDS) { + const argType = + argName === "coinTypes" + ? intListArg + : argName === "contentTypeMask" + ? mockBigIntArg + : stringListArg; + fields[graphqlField] = { type: GraphQLString, args: { [argName]: { type: argType } } }; + } + + return new GraphQLObjectType({ + name: "ResolvedRecords", + fields: fields as GraphQLFieldConfigMap, + }); +} + +const ResolvedRecordsType = buildMockResolvedRecordsType(); + +function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { + const document = parse(`{ records { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const recordsField = operation.selectionSet.selections[0]; + if (recordsField.kind !== "Field") throw new Error("expected field"); + + return { + fieldNodes: [recordsField], + fragments: {}, + returnType: ResolvedRecordsType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +describe("buildRecordsSelectionFromResolveInfo", () => { + it.each(RECORDS_SELECTION_SIMPLE_FIELDS)( + "selects $graphqlField as $selectionKey", + ({ graphqlField, selectionKey }) => { + const info = resolveInfoForRecordsSubselection(graphqlField); + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ [selectionKey]: true }); + }, + ); + + it.each([ + { + subselection: 'texts(keys: ["description"])', + expected: { texts: ["description"] }, + }, + { + subselection: "addresses(coinTypes: [60])", + expected: { addresses: [60] }, + }, + { + subselection: 'abi(contentTypeMask: "1")', + expected: { abi: 1n }, + }, + { + subselection: 'interfaces(ids: ["0x01020304"])', + expected: { interfaces: ["0x01020304"] }, + }, + ])("parses parametric field: $subselection", ({ subselection, expected }) => { + const info = resolveInfoForRecordsSubselection(subselection); + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual(expected); + }); + + it("builds combined selection across simple and parametric fields", () => { + const info = resolveInfoForRecordsSubselection(` + reverseName + contenthash + texts(keys: ["avatar", "description"]) + addresses(coinTypes: [60]) + abi(contentTypeMask: "1") + `); + + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ + name: true, + contenthash: true, + texts: ["avatar", "description"], + addresses: [60], + abi: 1n, + }); + }); + + it("ignores __typename", () => { + const info = resolveInfoForRecordsSubselection("__typename reverseName"); + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ name: true }); + }); + + it("throws when selection is empty", () => { + const info = resolveInfoForRecordsSubselection("__typename"); + expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( + EMPTY_RECORDS_SELECTION_MESSAGE, + ); + }); + + it("throws when only unknown fields are selected", () => { + const info = { + fieldNodes: [ + { + kind: "Field", + name: { kind: "Name", value: "records" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "unknownField" }, + }, + ], + }, + }, + ], + fragments: {}, + returnType: ResolvedRecordsType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; + + expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( + EMPTY_RECORDS_SELECTION_MESSAGE, + ); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts new file mode 100644 index 0000000000..7054e272da --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts @@ -0,0 +1,81 @@ +import { + type FieldNode, + GraphQLError, + type GraphQLResolveInfo, + getArgumentValues, + getNamedType, + isObjectType, + type SelectionSetNode, +} from "graphql"; + +import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { + getParametricRecordsSelectionField, + getSimpleRecordsSelectionField, +} from "@/omnigraph-api/lib/records-selection-config"; + +export const EMPTY_RECORDS_SELECTION_MESSAGE = "Records selection cannot be empty."; + +function collectFieldNodes(selectionSet: SelectionSetNode, info: GraphQLResolveInfo): FieldNode[] { + const fields: FieldNode[] = []; + + for (const selection of selectionSet.selections) { + if (selection.kind === "Field") { + if (selection.name.value === "__typename") continue; + fields.push(selection); + } else if (selection.kind === "InlineFragment") { + fields.push(...collectFieldNodes(selection.selectionSet, info)); + } else if (selection.kind === "FragmentSpread") { + const fragment = info.fragments[selection.name.value]; + if (fragment) fields.push(...collectFieldNodes(fragment.selectionSet, info)); + } + } + + return fields; +} + +/** + * Builds a {@link ResolverRecordsSelection} from the GraphQL selection set and field + * arguments on `Domain.records` → `ResolvedRecords`. + */ +export function buildRecordsSelectionFromResolveInfo( + info: GraphQLResolveInfo, +): ResolverRecordsSelection { + const fieldNode = info.fieldNodes[0]; + if (!fieldNode?.selectionSet) { + throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + } + + const returnType = getNamedType(info.returnType); + if (!isObjectType(returnType)) { + throw new GraphQLError("Return type must be an object type."); + } + + const selection: ResolverRecordsSelection = {}; + + for (const childField of collectFieldNodes(fieldNode.selectionSet, info)) { + const graphqlField = childField.name.value; + + const simple = getSimpleRecordsSelectionField(graphqlField); + if (simple) { + selection[simple.selectionKey] = true; + continue; + } + + const parametric = getParametricRecordsSelectionField(graphqlField); + if (!parametric) continue; + + const fieldDef = returnType.getFields()[graphqlField]; + if (!fieldDef) continue; + + const args = getArgumentValues(fieldDef, childField, info.variableValues); + parametric.applySelection(selection, args); + } + + if (isSelectionEmpty(selection)) { + throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + } + + return selection; +} diff --git a/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts b/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts new file mode 100644 index 0000000000..250cf8d994 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts @@ -0,0 +1,95 @@ +import type { CoinType, ContentType, InterfaceId } from "enssdk"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +export type RecordsSelectionSimpleKey = Extract< + keyof ResolverRecordsSelection, + "name" | "contenthash" | "pubkey" | "dnszonehash" | "version" +>; + +export type RecordsSelectionParametricKey = Extract< + keyof ResolverRecordsSelection, + "texts" | "addresses" | "abi" | "interfaces" +>; + +export type RecordsSelectionSimpleField = { + graphqlField: string; + selectionKey: RecordsSelectionSimpleKey; +}; + +export type RecordsSelectionParametricField = { + graphqlField: string; + argName: string; + selectionKey: RecordsSelectionParametricKey; + applySelection: (selection: ResolverRecordsSelection, args: Record) => void; +}; + +/** + * GraphQL fields on `ResolvedRecords` that map to boolean flags in {@link ResolverRecordsSelection}. + * Querying the field (no args) selects that record for resolution. + */ +export const RECORDS_SELECTION_SIMPLE_FIELDS = [ + { graphqlField: "reverseName", selectionKey: "name" }, + { graphqlField: "contenthash", selectionKey: "contenthash" }, + { graphqlField: "pubkey", selectionKey: "pubkey" }, + { graphqlField: "dnszonehash", selectionKey: "dnszonehash" }, + { graphqlField: "version", selectionKey: "version" }, +] as const satisfies readonly RecordsSelectionSimpleField[]; + +/** + * GraphQL fields on `ResolvedRecords` that require arguments specifying which records to resolve. + */ +export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ + { + graphqlField: "texts", + argName: "keys", + selectionKey: "texts", + applySelection: (selection, args) => { + selection.texts = args.keys as string[]; + }, + }, + { + graphqlField: "addresses", + argName: "coinTypes", + selectionKey: "addresses", + applySelection: (selection, args) => { + selection.addresses = args.coinTypes as CoinType[]; + }, + }, + { + graphqlField: "abi", + argName: "contentTypeMask", + selectionKey: "abi", + applySelection: (selection, args) => { + selection.abi = args.contentTypeMask as ContentType; + }, + }, + { + graphqlField: "interfaces", + argName: "ids", + selectionKey: "interfaces", + applySelection: (selection, args) => { + selection.interfaces = args.ids as InterfaceId[]; + }, + }, +] as const satisfies readonly RecordsSelectionParametricField[]; + +const simpleFieldByGraphqlName = new Map( + RECORDS_SELECTION_SIMPLE_FIELDS.map((f) => [f.graphqlField, f]), +); + +const parametricFieldByGraphqlName = new Map( + RECORDS_SELECTION_PARAMETRIC_FIELDS.map((f) => [f.graphqlField, f]), +); + +export function getSimpleRecordsSelectionField( + graphqlField: string, +): RecordsSelectionSimpleField | undefined { + return simpleFieldByGraphqlName.get(graphqlField); +} + +export function getParametricRecordsSelectionField( + graphqlField: string, +): RecordsSelectionParametricField | undefined { + return parametricFieldByGraphqlName.get(graphqlField); +} 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 9c81b6efec..94d9c365a2 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,7 @@ import { ADDR_REVERSE_NODE, asInterpretedLabel, + type CoinType, type DomainId, ETH_NODE, type InterpretedLabel, @@ -15,7 +16,7 @@ import { import { beforeAll, describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; @@ -490,35 +491,69 @@ describe("Domain.records", () => { type DomainRecordsResult = { domain: { records: { - addresses: Array<{ coinType: number; address: string | null }>; + addresses: Array<{ coinType: CoinType; address: string | null }>; + texts: Array<{ key: string; value: string | null }>; + } | null; + }; + }; + + type DomainAllRecordsResult = { + domain: { + records: { + reverseName: string | null; + contenthash: string | null; + pubkey: { x: string; y: string } | null; + dnszonehash: string | null; + version: string | null; + abi: { contentType: string; data: string } | null; + interfaces: Array<{ interfaceId: string; implementer: string | null }>; + addresses: Array<{ coinType: CoinType; address: string | null }>; texts: Array<{ key: string; value: string | null }>; } | null; }; }; const DomainRecords = gql` - query DomainRecords($name: InterpretedName!, $addresses: [CoinType!], $texts: [String!]) { + query DomainRecords($name: InterpretedName!, $addresses: [CoinType!]!, $texts: [String!]!) { domain(by: { name: $name }) { - records(selection: { addresses: $addresses, texts: $texts }) { - addresses { coinType address } - texts { key value } + records { + addresses(coinTypes: $addresses) { coinType address } + texts(keys: $texts) { key value } } } } `; - it("resolves ETH address for test.eth", async () => { - const result = await request(DomainRecords, { - name: "test.eth", - addresses: [60], - texts: [], - }); + const DomainRecordsAll = gql` + query DomainRecordsAll( + $name: InterpretedName! + $addresses: [CoinType!]! + $texts: [String!]! + $contentTypeMask: BigInt! + $interfaceIds: [InterfaceId!]! + ) { + domain(by: { name: $name }) { + records { + reverseName + contenthash + pubkey { x y } + dnszonehash + version + abi(contentTypeMask: $contentTypeMask) { contentType data } + interfaces(ids: $interfaceIds) { interfaceId implementer } + addresses(coinTypes: $addresses) { coinType address } + texts(keys: $texts) { key value } + } + } + } + `; - expect(result.domain.records?.addresses).toEqual([ - { coinType: 60, address: accounts.owner.address }, - ]); - expect(result.domain.records?.texts).toEqual([]); - }); + const textRecordsByKey = (texts: Array<{ key: string; value: string | null }>) => + Object.fromEntries(texts.map(({ key, value }) => [key, value])); + + const addressRecordsByCoinType = ( + addresses: Array<{ coinType: CoinType; address: string | null }>, + ) => Object.fromEntries(addresses.map(({ coinType, address }) => [coinType, address])); it("resolves address and text records for example.eth", async () => { const result = await request(DomainRecords, { @@ -532,4 +567,42 @@ describe("Domain.records", () => { ]); expect(result.domain.records?.texts).toEqual([{ key: "description", value: "example.eth" }]); }); + + it("resolves every supported record type for test.eth", async () => { + const result = await request(DomainRecordsAll, { + name: "test.eth", + addresses: [60, 0, 2], + texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], + contentTypeMask: "1", + interfaceIds: [fixtures.fourBytesInterface], + }); + + const records = result.domain.records; + expect(records).toBeDefined(); + + expect(records?.contenthash).toBe(fixtures.contenthash); + expect(records?.pubkey).toEqual({ x: fixtures.publicKeyX, y: fixtures.publicKeyY }); + expect(records?.dnszonehash).toBeNull(); + expect(records?.version).toEqual(expect.any(String)); + expect(records?.abi).toEqual({ + contentType: "1", + data: fixtures.abiBytes, + }); + expect(records?.interfaces).toEqual([ + { interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }, + ]); + expect(addressRecordsByCoinType(records?.addresses ?? [])).toEqual({ + 60: accounts.owner.address, + 0: fixtures.bitcoinAddress, + 2: fixtures.litecoinAddress, + }); + expect(textRecordsByKey(records?.texts ?? [])).toEqual({ + avatar: "https://example.com/avatar.png", + description: "test.eth", + url: "https://ens.domains", + email: "test@ens.domains", + "com.twitter": "ensdomains", + "com.github": "ensdomains", + }); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index e1a43d0687..71a0825ae0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -14,6 +14,7 @@ import { withSpanAsync } from "@/lib/instrumentation/auto-span"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; +import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/build-records-selection"; import { orderPaginationBy, paginateBy, @@ -50,7 +51,7 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; -import { ResolvedRecordsRef, ResolveSelectionInput } from "@/omnigraph-api/schema/resolution"; +import { ResolvedRecordsRef } from "@/omnigraph-api/schema/resolution"; const tracer = trace.getTracer("schema/Domain"); @@ -183,30 +184,17 @@ DomainInterfaceRef.implement({ /////////////////// 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.", + "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names 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) => { + resolve: async (domain, _args, _context, info) => { const name = domain.canonicalName; if (!name) return null; + const selection = buildRecordsSelectionFromResolveInfo(info); + const { result } = await runWithTrace(() => - resolveForward( - name, - { - name: selection.reverseName ?? undefined, - texts: selection.texts ?? undefined, - addresses: selection.addresses ?? undefined, - }, - { accelerate: false, canAccelerate: false }, - ), + resolveForward(name, selection, { accelerate: false, canAccelerate: false }), ); return result as ResolverRecordsResponseBase; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 9843c8e1c2..1098e6a877 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,29 +1,8 @@ -import type { CoinType } from "enssdk"; +import type { CoinType, Hex, InterfaceId, NormalizedAddress } from "enssdk"; -import { builder } from "@/omnigraph-api/builder"; +import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; -/////////////////////// -// 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, - }), - }), -}); +import { builder } from "@/omnigraph-api/builder"; /////////////////////// // ResolvedTextRecord @@ -65,36 +44,167 @@ export const ResolvedAddressRecordRef = builder }), }); +//////////////////////// +// ResolvedPubkeyRecord +//////////////////////// +export const ResolvedPubkeyRecordRef = builder + .objectRef<{ x: Hex; y: Hex }>("ResolvedPubkeyRecord") + .implement({ + description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", + fields: (t) => ({ + x: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.x, + }), + y: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.y, + }), + }), + }); + +/////////////////////// +// ResolvedAbiRecord +/////////////////////// +export const ResolvedAbiRecordRef = builder + .objectRef<{ contentType: bigint; data: Hex }>("ResolvedAbiRecord") + .implement({ + description: "A resolved ABI record for an ENS name.", + fields: (t) => ({ + contentType: t.field({ + type: "BigInt", + nullable: false, + resolve: (r) => r.contentType, + }), + data: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.data, + }), + }), + }); + +//////////////////////////// +// ResolvedInterfaceRecord +//////////////////////////// +export const ResolvedInterfaceRecordRef = builder + .objectRef<{ interfaceId: InterfaceId; implementer: NormalizedAddress | null }>( + "ResolvedInterfaceRecord", + ) + .implement({ + description: "A resolved ERC-165 interface implementer record for an ENS name.", + fields: (t) => ({ + interfaceId: t.field({ + type: "InterfaceId", + nullable: false, + resolve: (r) => r.interfaceId, + }), + implementer: t.field({ + type: "Address", + nullable: true, + resolve: (r) => r.implementer, + }), + }), + }); + //////////////////// // ResolvedRecords //////////////////// export const ResolvedRecordsRef = builder - .objectRef<{ - name: string | null | undefined; - texts: Record | undefined; - addresses: Record | undefined; - }>("ResolvedRecords") + .objectRef>("ResolvedRecords") .implement({ - description: - "Records resolved for a specific ENS name via the ENS protocol. Only selected records are populated.", + description: "Records resolved for a specific ENS name via the ENS protocol.", fields: (t) => ({ reverseName: t.string({ description: - "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set or not selected.", + "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set.", nullable: true, resolve: (r) => r.name ?? null, }), + contenthash: t.field({ + description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.contenthash ?? null, + }), + pubkey: t.field({ + description: "The PubkeyResolver (x, y) pair, or null if not set.", + type: ResolvedPubkeyRecordRef, + nullable: true, + resolve: (r) => r.pubkey ?? null, + }), + dnszonehash: t.field({ + description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.dnszonehash ?? null, + }), + version: t.field({ + description: "The IVersionableResolver version, or null if not set or unavailable.", + type: "BigInt", + nullable: true, + resolve: (r) => r.version ?? null, + }), + abi: t.field({ + description: + "The first stored ABI matching the requested content-type bitmask, or null if not set.", + type: ResolvedAbiRecordRef, + nullable: true, + args: { + contentTypeMask: t.arg({ + type: "BigInt", + required: true, + description: + "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", + }), + }, + resolve: (r) => r.abi ?? null, + }), + interfaces: t.field({ + description: "Resolved ERC-165 interface implementer records for the requested ids.", + type: [ResolvedInterfaceRecordRef], + nullable: false, + args: { + ids: t.arg({ + type: ["InterfaceId"], + required: true, + description: "ERC-165 interface ids to resolve (4-byte hex selectors).", + }), + }, + resolve: (r) => + r.interfaces + ? Object.entries(r.interfaces).map(([interfaceId, implementer]) => ({ + interfaceId: interfaceId as InterfaceId, + implementer, + })) + : [], + }), texts: t.field({ - description: "Resolved text records for selected keys.", + description: "Resolved text records for the requested keys.", type: [ResolvedTextRecordRef], nullable: false, + args: { + keys: t.arg.stringList({ + required: true, + description: "Text record keys to resolve (e.g. `avatar`, `description`).", + }), + }, resolve: (r) => r.texts ? Object.entries(r.texts).map(([key, value]) => ({ key, value })) : [], }), addresses: t.field({ - description: "Resolved address records for selected coin types.", + description: "Resolved address records for the requested coin types.", type: [ResolvedAddressRecordRef], nullable: false, + args: { + coinTypes: t.arg({ + type: ["CoinType"], + required: true, + description: "Coin types to resolve (e.g. `60` for ETH).", + }), + }, resolve: (r) => r.addresses ? Object.entries(r.addresses).map(([coinType, address]) => ({ diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 9ccbbf235a..77113fe54a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -5,6 +5,8 @@ import { type CoinType, type DomainId, type Hex, + type InterfaceId, + isInterfaceId, isInterpretedLabel, isInterpretedName, type Name, @@ -73,6 +75,25 @@ builder.scalarType("CoinType", { parseValue: (value) => makeCoinTypeSchema("CoinType").parse(value), }); +builder.scalarType("InterfaceId", { + description: "InterfaceId represents a ERC-165 interface id (4-byte hex selector).", + serialize: (value: InterfaceId) => value, + parseValue: (value) => + z.coerce + .string() + .check((ctx) => { + if (!isInterfaceId(ctx.value)) { + ctx.issues.push({ + code: "custom", + message: "Must be a 4-byte hex (0x + 8 hex chars)", + input: ctx.value, + }); + } + }) + .transform((val) => val.toLowerCase() as InterfaceId) + .parse(value), +}); + builder.scalarType("Node", { description: "Node represents a enssdk#Node.", serialize: (value: Node) => value, diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index e73acc6fdb..6ad4bf6566 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1165,6 +1165,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, { "name": "registration", "type": { @@ -1924,6 +1933,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, { "name": "registration", "type": { @@ -2524,6 +2542,15 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, { "name": "registration", "type": { @@ -3560,6 +3587,10 @@ const introspection = { "kind": "SCALAR", "name": "Int" }, + { + "kind": "SCALAR", + "name": "InterfaceId" + }, { "kind": "SCALAR", "name": "InterpretedLabel" @@ -5904,6 +5935,329 @@ const introspection = { "kind": "SCALAR", "name": "RenewalId" }, + { + "kind": "OBJECT", + "name": "ResolvedAbiRecord", + "fields": [ + { + "name": "contentType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "data", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedAddressRecord", + "fields": [ + { + "name": "address", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "coinType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedInterfaceRecord", + "fields": [ + { + "name": "implementer", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "interfaceId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterfaceId" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedPubkeyRecord", + "fields": [ + { + "name": "x", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "y", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedRecords", + "fields": [ + { + "name": "abi", + "type": { + "kind": "OBJECT", + "name": "ResolvedAbiRecord" + }, + "args": [ + { + "name": "contentTypeMask", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "addresses", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ResolvedAddressRecord" + } + } + } + }, + "args": [ + { + "name": "coinTypes", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } + } + } + ], + "isDeprecated": false + }, + { + "name": "contenthash", + "type": { + "kind": "SCALAR", + "name": "Hex" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "dnszonehash", + "type": { + "kind": "SCALAR", + "name": "Hex" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "interfaces", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ResolvedInterfaceRecord" + } + } + } + }, + "args": [ + { + "name": "ids", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterfaceId" + } + } + } + } + } + ], + "isDeprecated": false + }, + { + "name": "pubkey", + "type": { + "kind": "OBJECT", + "name": "ResolvedPubkeyRecord" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "reverseName", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "texts", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ResolvedTextRecord" + } + } + } + }, + "args": [ + { + "name": "keys", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + } + } + ], + "isDeprecated": false + }, + { + "name": "version", + "type": { + "kind": "SCALAR", + "name": "BigInt" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedTextRecord", + "fields": [ + { + "name": "key", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "value", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "Resolver", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index eae266124c..32950df8e9 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -252,12 +252,9 @@ interface Domain { parent: 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. + Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records( - """Which records to resolve.""" - selection: ResolveSelectionInput! - ): ResolvedRecords + records: ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -455,12 +452,9 @@ type ENSv1Domain implements Domain { parent: 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. + Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records( - """Which records to resolve.""" - selection: ResolveSelectionInput! - ): ResolvedRecords + records: ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -575,12 +569,9 @@ 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. + Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records( - """Which records to resolve.""" - selection: ResolveSelectionInput! - ): ResolvedRecords + records: ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -865,6 +856,9 @@ input EventsWhereInput { """Hex represents viem#Hex.""" scalar Hex +"""InterfaceId represents a ERC-165 interface id (4-byte hex selector).""" +scalar InterfaceId + """InterpretedLabel represents a enssdk#InterpretedLabel.""" scalar InterpretedLabel @@ -1322,20 +1316,10 @@ 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 ABI record for an ENS name.""" +type ResolvedAbiRecord { + contentType: BigInt! + data: Hex! } """A resolved address record for an ENS name.""" @@ -1347,20 +1331,64 @@ type ResolvedAddressRecord { coinType: CoinType! } -""" -Records resolved for a specific ENS name via the ENS protocol. Only selected records are populated. -""" +"""A resolved ERC-165 interface implementer record for an ENS name.""" +type ResolvedInterfaceRecord { + implementer: Address + interfaceId: InterfaceId! +} + +"""A resolved PubkeyResolver (x, y) pair for an ENS name.""" +type ResolvedPubkeyRecord { + x: Hex! + y: Hex! +} + +"""Records resolved for a specific ENS name via the ENS protocol.""" type ResolvedRecords { - """Resolved address records for selected coin types.""" - addresses: [ResolvedAddressRecord!]! + """ + The first stored ABI matching the requested content-type bitmask, or null if not set. + """ + abi( + """ + Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first). + """ + contentTypeMask: BigInt! + ): ResolvedAbiRecord + + """Resolved address records for the requested coin types.""" + addresses( + """Coin types to resolve (e.g. `60` for ETH).""" + coinTypes: [CoinType!]! + ): [ResolvedAddressRecord!]! + + """The ENSIP-7 contenthash record raw bytes, or null if not set.""" + contenthash: Hex + + """The IDNSZoneResolver zonehash raw bytes, or null if not set.""" + dnszonehash: Hex + + """Resolved ERC-165 interface implementer records for the requested ids.""" + interfaces( + """ERC-165 interface ids to resolve (4-byte hex selectors).""" + ids: [InterfaceId!]! + ): [ResolvedInterfaceRecord!]! + + """The PubkeyResolver (x, y) pair, or null if not set.""" + pubkey: ResolvedPubkeyRecord """ - The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set or not selected. + The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. """ reverseName: String - """Resolved text records for selected keys.""" - texts: [ResolvedTextRecord!]! + """Resolved text records for the requested keys.""" + texts( + """Text record keys to resolve (e.g. `avatar`, `description`).""" + keys: [String!]! + ): [ResolvedTextRecord!]! + + """The IVersionableResolver version, or null if not set or unavailable.""" + version: BigInt } """A resolved text record for an ENS name.""" diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index f7b6e99178..dc93d42699 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -5,6 +5,7 @@ import type { CoinType, DomainId, Hex, + InterfaceId, InterpretedLabel, InterpretedName, Node, @@ -40,6 +41,7 @@ export type OmnigraphScalars = { Hex: Hex; ChainId: ChainId; CoinType: CoinType; + InterfaceId: InterfaceId; InterpretedName: InterpretedName; InterpretedLabel: InterpretedLabel; Node: Node; From 713f1eeec7dc2989ce36b463849200980288040f Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 16:49:26 +0300 Subject: [PATCH 04/30] add primary names field in omnigraph --- apps/ensapi/src/omnigraph-api/builder.ts | 2 + .../validate-primary-names-chain-ids.test.ts | 30 +++++++++ .../lib/validate-primary-names-chain-ids.ts | 26 +++++++ .../schema/account.integration.test.ts | 67 +++++++++++++++++++ .../src/omnigraph-api/schema/account.ts | 39 ++++++++++- .../src/omnigraph-api/schema/resolution.ts | 34 +++++++++- .../src/omnigraph-api/schema/scalars.ts | 9 +++ .../src/omnigraph-api/example-queries.ts | 50 ++++++++++++++ .../src/omnigraph/generated/introspection.ts | 60 +++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 28 ++++++++ packages/enssdk/src/omnigraph/graphql.ts | 2 + 11 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 7a3862681a..4c314ac1fb 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -8,6 +8,7 @@ import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-open import type { ChainId, CoinType, + DefaultableChainId, DomainId, Hex, InterfaceId, @@ -63,6 +64,7 @@ export type BuilderScalars = { Address: { Input: NormalizedAddress; Output: NormalizedAddress }; Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; + DefaultableChainId: { Input: DefaultableChainId; Output: DefaultableChainId }; CoinType: { Input: CoinType; Output: CoinType }; InterfaceId: { Input: InterfaceId; Output: InterfaceId }; Node: { Input: Node; Output: Node }; diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts new file mode 100644 index 0000000000..a2411c1c8c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts @@ -0,0 +1,30 @@ +import { DEFAULT_EVM_CHAIN_ID } from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE, + EMPTY_CHAIN_IDS_MESSAGE, + validatePrimaryNamesChainIds, +} from "@/omnigraph-api/lib/validate-primary-names-chain-ids"; + +describe("validatePrimaryNamesChainIds", () => { + it.each([ + { chainIds: undefined, label: "omitted" }, + { chainIds: null, label: "null" }, + { chainIds: [1], label: "single chain" }, + { chainIds: [1, 10], label: "multiple chains" }, + { chainIds: [DEFAULT_EVM_CHAIN_ID], label: "default chain only" }, + ])("allows $label", ({ chainIds }) => { + expect(() => validatePrimaryNamesChainIds(chainIds)).not.toThrow(); + }); + + it("rejects empty chainIds", () => { + expect(() => validatePrimaryNamesChainIds([])).toThrow(EMPTY_CHAIN_IDS_MESSAGE); + }); + + it("rejects default chain id combined with other chain ids", () => { + expect(() => validatePrimaryNamesChainIds([DEFAULT_EVM_CHAIN_ID, 1])).toThrow( + DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE, + ); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts new file mode 100644 index 0000000000..de9e3bfac4 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts @@ -0,0 +1,26 @@ +import { DEFAULT_EVM_CHAIN_ID, type DefaultableChainId } from "enssdk"; +import { GraphQLError } from "graphql"; + +export const EMPTY_CHAIN_IDS_MESSAGE = "chainIds cannot be empty."; +export const DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE = `Must not include the default EVM chain id (${DEFAULT_EVM_CHAIN_ID}) with other chain ids.`; + +/** + * Validates `chainIds` for primary name resolution. + * + * - `undefined` (omitted) is allowed — resolves all ENSIP-19 supported chains. + * - Empty array is rejected. + * - `DEFAULT_EVM_CHAIN_ID` (0) is allowed only when it is the sole chain id. + */ +export function validatePrimaryNamesChainIds( + chainIds: DefaultableChainId[] | null | undefined, +): void { + if (chainIds === null || chainIds === undefined) return; + + if (chainIds.length === 0) { + throw new GraphQLError(EMPTY_CHAIN_IDS_MESSAGE); + } + + if (chainIds.includes(DEFAULT_EVM_CHAIN_ID) && chainIds.length > 1) { + throw new GraphQLError(DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE); + } +} 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 a914a09675..3c23fba5d8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -312,3 +312,70 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { expect(events.length).toBe(0); }); }); + +describe("Account.primaryNames", () => { + type AccountPrimaryNamesResult = { + account: { + primaryNames: Array<{ chainId: number; name: string | null }>; + }; + }; + + const AccountPrimaryNames = gql` + query AccountPrimaryNames($address: Address!, $chainIds: [DefaultableChainId!]) { + account(by: { address: $address }) { + primaryNames(chainIds: $chainIds) { + chainId + name + } + } + } + `; + + const AccountPrimaryNamesAllChains = gql` + query AccountPrimaryNamesAllChains($address: Address!) { + account(by: { address: $address }) { + primaryNames { + chainId + name + } + } + } + `; + + it("resolves primary name for owner on chain 1", async () => { + const result = await request(AccountPrimaryNames, { + address: accounts.owner.address, + chainIds: [1], + }); + + expect(result.account.primaryNames).toEqual([{ chainId: 1, name: "test.eth" }]); + }); + + it("returns null for user without a primary name", async () => { + const result = await request(AccountPrimaryNames, { + address: accounts.user.address, + chainIds: [1], + }); + + expect(result.account.primaryNames).toEqual([{ chainId: 1, name: null }]); + }); + + it("resolves all ENSIP-19 supported chains when chainIds is omitted from the query", async () => { + const result = await request(AccountPrimaryNamesAllChains, { + address: accounts.owner.address, + }); + + expect(result.account.primaryNames).toEqual( + expect.arrayContaining([{ chainId: 1, name: "test.eth" }]), + ); + }); + + it("rejects default chain id combined with other chain ids", async () => { + await expect( + request(AccountPrimaryNames, { + address: accounts.owner.address, + chainIds: [0, 1], + }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 7953e60928..5450b09cdb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,8 +1,10 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address } from "enssdk"; +import type { Address, DefaultableChainId, InterpretedName } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; +import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; 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"; @@ -17,6 +19,7 @@ import { 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 { validatePrimaryNamesChainIds } from "@/omnigraph-api/lib/validate-primary-names-chain-ids"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -25,6 +28,7 @@ import { EventRef } from "@/omnigraph-api/schema/event"; import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; +import { PrimaryNameByChainRef } from "@/omnigraph-api/schema/resolution"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; export const AccountRef = builder.loadableObjectRef("Account", { @@ -65,6 +69,39 @@ AccountRef.implement({ resolve: (parent) => parent.id, }), + //////////////////////// + // Account.primaryNames + //////////////////////// + primaryNames: t.field({ + description: + "ENSIP-19 primary names for this Account. Omit chainIds to resolve all ENSIP-19 supported chains.", + type: [PrimaryNameByChainRef], + nullable: false, + args: { + chainIds: t.arg({ + type: ["DefaultableChainId"], + required: false, + description: + "Chain ids to resolve primary names for. Use 0 for the default EVM chain per ENSIP-19. Omit to resolve all ENSIP-19 supported chains.", + }), + }, + resolve: async (account, { chainIds }) => { + validatePrimaryNamesChainIds(chainIds); + + const { result } = await runWithTrace(() => + resolvePrimaryNames(account.id, chainIds ?? undefined, { + accelerate: false, + canAccelerate: false, + }), + ); + + return Object.entries(result).map(([chainId, name]) => ({ + chainId: Number(chainId) as DefaultableChainId, + name: name as InterpretedName | null, + })); + }, + }), + //////////////////// // Account.domains //////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 1098e6a877..fc7249712e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,9 +1,41 @@ -import type { CoinType, Hex, InterfaceId, NormalizedAddress } from "enssdk"; +import type { + CoinType, + DefaultableChainId, + Hex, + InterfaceId, + InterpretedName, + NormalizedAddress, +} from "enssdk"; import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; import { builder } from "@/omnigraph-api/builder"; +////////////////////// +// PrimaryNameByChain +////////////////////// +export const PrimaryNameByChainRef = builder + .objectRef<{ chainId: DefaultableChainId; name: InterpretedName | null }>("PrimaryNameByChain") + .implement({ + description: "An ENSIP-19 primary name for an Account on a specific chain.", + fields: (t) => ({ + chainId: t.field({ + description: + "The chain on which the primary name was resolved. 0 denotes the default EVM chain per ENSIP-19.", + type: "DefaultableChainId", + nullable: false, + resolve: (r) => r.chainId, + }), + name: t.field({ + description: + "The validated primary name for this Account on this chain, or null if none is set.", + type: "InterpretedName", + nullable: true, + resolve: (r) => r.name, + }), + }), + }); + /////////////////////// // ResolvedTextRecord /////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 77113fe54a..a60f6e0dd1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -3,6 +3,7 @@ import { asInterpretedName, type ChainId, type CoinType, + type DefaultableChainId, type DomainId, type Hex, type InterfaceId, @@ -27,6 +28,7 @@ import { z } from "zod/v4"; import { makeChainIdSchema, makeCoinTypeSchema, + makeDefaultableChainIdSchema, makeNormalizedAddressSchema, } from "@ensnode/ensnode-sdk/internal"; @@ -69,6 +71,13 @@ builder.scalarType("ChainId", { parseValue: (value) => makeChainIdSchema("ChainId").parse(value), }); +builder.scalarType("DefaultableChainId", { + description: + "DefaultableChainId represents a enssdk#DefaultableChainId. Use 0 for the default EVM chain per ENSIP-19.", + serialize: (value: DefaultableChainId) => value, + parseValue: (value) => makeDefaultableChainIdSchema("DefaultableChainId").parse(value), +}); + builder.scalarType("CoinType", { description: "CoinType represents a enssdk#CoinType.", serialize: (value: CoinType) => value, diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index c34e3f17ef..c127ed8a1c 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -145,6 +145,34 @@ query DomainByName($name: InterpretedName!) { }, }, + //////////////////// + // Domain Records + //////////////////// + { + id: "domain-records", + query: ` +query DomainRecords( + $name: InterpretedName! +) { + domain(by: { name: $name }) { + canonical { name { interpreted } } + records { + addresses(coinTypes: [60]) { coinType address } + texts(keys: ["description"]) { key value } + } + } +}`, + variables: { + default: { name: "vitalik.eth" }, + [ENSNamespaceIds.EnsTestEnv]: { + name: DEVNET_NAME_WITH_OWNED_RESOLVER, + }, + [ENSNamespaceIds.SepoliaV2]: { + name: SEPOLIA_V2_NAME_WITH_OWNED_RESOLVER, + }, + }, + }, + ////////////////////// // Domain Subdomains ////////////////////// @@ -222,6 +250,28 @@ query AccountDomains( }, }, + ///////////////////////// + // Account Primary Names + ///////////////////////// + { + id: "account-primary-names", + query: ` +query AccountPrimaryNames($address: Address!) { + account(by: { address: $address }) { + address + primaryNames { + chainId + name + } + } +}`, + variables: { + default: { address: VITALIK_ADDRESS }, + [ENSNamespaceIds.EnsTestEnv]: { address: accounts.owner.address }, + [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ADDRESS_WITH_LOT_OF_NAMES }, + }, + }, + //////////////////// // Account Events //////////////////// diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 6ad4bf6566..b55dfca8f1 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -200,6 +200,38 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "primaryNames", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameByChain" + } + } + } + }, + "args": [ + { + "name": "chainIds", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + } + } + } + ], + "isDeprecated": false + }, { "name": "registryPermissions", "type": { @@ -4572,6 +4604,34 @@ const introspection = { "kind": "SCALAR", "name": "PermissionsUserId" }, + { + "kind": "OBJECT", + "name": "PrimaryNameByChain", + "fields": [ + { + "name": "chainId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "name", + "type": { + "kind": "SCALAR", + "name": "InterpretedName" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "Query", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 32950df8e9..1fdae9b5e5 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -19,6 +19,16 @@ type Account { """ permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection + """ + ENSIP-19 primary names for this Account. Omit chainIds to resolve all ENSIP-19 supported chains. + """ + primaryNames( + """ + Chain ids to resolve primary names for. Use 0 for the default EVM chain per ENSIP-19. Omit to resolve all ENSIP-19 supported chains. + """ + chainIds: [DefaultableChainId!] + ): [PrimaryNameByChain!]! + """The Permissions on Registries granted to this Account.""" registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection @@ -223,6 +233,11 @@ scalar ChainId """CoinType represents a enssdk#CoinType.""" scalar CoinType +""" +DefaultableChainId represents a enssdk#DefaultableChainId. Use 0 for the default EVM chain per ENSIP-19. +""" +scalar DefaultableChainId + """ Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ @@ -1071,6 +1086,19 @@ type PermissionsUserEventsConnectionEdge { """PermissionsUserId represents a enssdk#PermissionsUserId.""" scalar PermissionsUserId +"""An ENSIP-19 primary name for an Account on a specific chain.""" +type PrimaryNameByChain { + """ + The chain on which the primary name was resolved. 0 denotes the default EVM chain per ENSIP-19. + """ + chainId: DefaultableChainId! + + """ + The validated primary name for this Account on this chain, or null if none is set. + """ + name: InterpretedName +} + type Query { """Identify an Account by ID or Address.""" account(by: AccountByInput!): Account diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index dc93d42699..dfeb8dd4a3 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -3,6 +3,7 @@ import { initGraphQLTada } from "gql.tada"; import type { ChainId, CoinType, + DefaultableChainId, DomainId, Hex, InterfaceId, @@ -40,6 +41,7 @@ export type OmnigraphScalars = { Address: NormalizedAddress; Hex: Hex; ChainId: ChainId; + DefaultableChainId: DefaultableChainId; CoinType: CoinType; InterfaceId: InterfaceId; InterpretedName: InterpretedName; From 319a99dadd981805bb3b5fc4b3d591277150256d Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 17:00:55 +0300 Subject: [PATCH 05/30] forgot introspection --- packages/enssdk/src/omnigraph/generated/introspection.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b55dfca8f1..40c908898a 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -224,7 +224,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "ChainId" + "name": "DefaultableChainId" } } } @@ -1097,6 +1097,10 @@ const introspection = { "kind": "SCALAR", "name": "CoinType" }, + { + "kind": "SCALAR", + "name": "DefaultableChainId" + }, { "kind": "INTERFACE", "name": "Domain", @@ -4614,7 +4618,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "ChainId" + "name": "DefaultableChainId" } }, "args": [], From 32f53c85fe25317a3c56cd0cd592e70fa13191c0 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 20:22:48 +0300 Subject: [PATCH 06/30] remove default chain id and add disableAcceleration --- .../handlers/api/omnigraph/omnigraph-api.ts | 14 +++++- apps/ensapi/src/omnigraph-api/builder.ts | 6 +-- apps/ensapi/src/omnigraph-api/context.ts | 9 +++- .../lib/find-domains/find-domains-resolver.ts | 4 +- .../validate-primary-names-chain-ids.test.ts | 9 ---- .../lib/validate-primary-names-chain-ids.ts | 12 +---- .../schema/account.integration.test.ts | 4 +- .../src/omnigraph-api/schema/account.ts | 20 +++++--- .../ensapi/src/omnigraph-api/schema/domain.ts | 15 +++++- .../src/omnigraph-api/schema/resolution.ts | 9 ++-- .../src/omnigraph-api/schema/scalars.ts | 9 ---- apps/ensapi/src/omnigraph-api/yoga.ts | 10 ++-- .../src/omnigraph/generated/introspection.ts | 49 +++++++++++++++---- .../src/omnigraph/generated/schema.graphql | 41 ++++++++++------ packages/enssdk/src/omnigraph/graphql.ts | 2 - 15 files changed, 133 insertions(+), 80 deletions(-) diff --git a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index ec2d40362c..ae0f506bbe 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -1,14 +1,26 @@ import config from "@/config"; +import type { Duration } from "enssdk"; + import { hasOmnigraphApiConfigSupport, hasOmnigraphApiIndexingStatusSupport, } from "@ensnode/ensnode-sdk"; import { createApp } from "@/lib/hono-factory"; +import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; +import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; + +const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; -const app = createApp({ middlewares: [indexingStatusMiddleware] }); +const app = createApp({ + middlewares: [ + indexingStatusMiddleware, + makeIsRealtimeMiddleware("omnigraph-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE), + canAccelerateMiddleware, + ], +}); app.use(async (c, next) => { const configPrerequisite = hasOmnigraphApiConfigSupport(config.ensIndexerPublicConfig); diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 4c314ac1fb..424a912dad 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -8,7 +8,6 @@ import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-open import type { ChainId, CoinType, - DefaultableChainId, DomainId, Hex, InterfaceId, @@ -28,7 +27,7 @@ import type { import { getNamedType } from "graphql"; import superjson from "superjson"; -import type { context } from "@/omnigraph-api/context"; +import type { OmnigraphContext } from "@/omnigraph-api/context"; const tracer = trace.getTracer("graphql"); const createSpan = createOpenTelemetryWrapper(tracer, { @@ -64,7 +63,6 @@ export type BuilderScalars = { Address: { Input: NormalizedAddress; Output: NormalizedAddress }; Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; - DefaultableChainId: { Input: DefaultableChainId; Output: DefaultableChainId }; CoinType: { Input: CoinType; Output: CoinType }; InterfaceId: { Input: InterfaceId; Output: InterfaceId }; Node: { Input: Node; Output: Node }; @@ -82,7 +80,7 @@ export type BuilderScalars = { }; export const builder = new SchemaBuilder<{ - Context: ReturnType; + Context: OmnigraphContext; Scalars: BuilderScalars; // the following ensures via typechecker that every t.connection returns a totalCount field diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index f6ac40df64..e294f2f725 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -4,6 +4,10 @@ import { inArray } from "drizzle-orm"; import type { DomainId, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; +import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; + +/** Server context passed from Hono into GraphQL Yoga via `yoga.fetch(request, serverContext)`. */ +export type OmnigraphYogaServerContext = CanAccelerateMiddlewareVariables; const createRegistryParentDomainLoader = () => new DataLoader(async (registryIds) => { @@ -23,9 +27,12 @@ const createRegistryParentDomainLoader = () => * * @dev make sure that anything that is per-request (like dataloaders) are newly created in this fn */ -export const context = () => ({ +export const createOmnigraphContext = (serverContext: OmnigraphYogaServerContext) => ({ now: BigInt(getUnixTime(new Date())), loaders: { registryParentDomain: createRegistryParentDomainLoader(), }, + canAccelerate: serverContext.canAccelerate, }); + +export type OmnigraphContext = ReturnType; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts index 7ea15ef8f0..923944c5b0 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts @@ -5,7 +5,7 @@ import { and, count } from "drizzle-orm"; import { ensDb } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; -import type { context as createContext } from "@/omnigraph-api/context"; +import type { OmnigraphContext } from "@/omnigraph-api/context"; import type { DomainsWithOrderingMetadata, DomainsWithOrderingMetadataResult, @@ -68,7 +68,7 @@ function getOrderValueFromResult( * @param args - The domains CTE, optional ordering, and relay connection args */ export function resolveFindDomains( - context: ReturnType, + context: OmnigraphContext, { domains, order, diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts index a2411c1c8c..1e54184557 100644 --- a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts @@ -1,8 +1,6 @@ -import { DEFAULT_EVM_CHAIN_ID } from "enssdk"; import { describe, expect, it } from "vitest"; import { - DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE, EMPTY_CHAIN_IDS_MESSAGE, validatePrimaryNamesChainIds, } from "@/omnigraph-api/lib/validate-primary-names-chain-ids"; @@ -13,7 +11,6 @@ describe("validatePrimaryNamesChainIds", () => { { chainIds: null, label: "null" }, { chainIds: [1], label: "single chain" }, { chainIds: [1, 10], label: "multiple chains" }, - { chainIds: [DEFAULT_EVM_CHAIN_ID], label: "default chain only" }, ])("allows $label", ({ chainIds }) => { expect(() => validatePrimaryNamesChainIds(chainIds)).not.toThrow(); }); @@ -21,10 +18,4 @@ describe("validatePrimaryNamesChainIds", () => { it("rejects empty chainIds", () => { expect(() => validatePrimaryNamesChainIds([])).toThrow(EMPTY_CHAIN_IDS_MESSAGE); }); - - it("rejects default chain id combined with other chain ids", () => { - expect(() => validatePrimaryNamesChainIds([DEFAULT_EVM_CHAIN_ID, 1])).toThrow( - DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE, - ); - }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts index de9e3bfac4..5015efdc68 100644 --- a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts +++ b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts @@ -1,26 +1,18 @@ -import { DEFAULT_EVM_CHAIN_ID, type DefaultableChainId } from "enssdk"; +import type { ChainId } from "enssdk"; import { GraphQLError } from "graphql"; export const EMPTY_CHAIN_IDS_MESSAGE = "chainIds cannot be empty."; -export const DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE = `Must not include the default EVM chain id (${DEFAULT_EVM_CHAIN_ID}) with other chain ids.`; /** * Validates `chainIds` for primary name resolution. * * - `undefined` (omitted) is allowed — resolves all ENSIP-19 supported chains. * - Empty array is rejected. - * - `DEFAULT_EVM_CHAIN_ID` (0) is allowed only when it is the sole chain id. */ -export function validatePrimaryNamesChainIds( - chainIds: DefaultableChainId[] | null | undefined, -): void { +export function validatePrimaryNamesChainIds(chainIds: ChainId[] | null | undefined): void { if (chainIds === null || chainIds === undefined) return; if (chainIds.length === 0) { throw new GraphQLError(EMPTY_CHAIN_IDS_MESSAGE); } - - if (chainIds.includes(DEFAULT_EVM_CHAIN_ID) && chainIds.length > 1) { - throw new GraphQLError(DEFAULT_CHAIN_ID_WITH_OTHERS_MESSAGE); - } } 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 3c23fba5d8..ca09ce01a6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -321,7 +321,7 @@ describe("Account.primaryNames", () => { }; const AccountPrimaryNames = gql` - query AccountPrimaryNames($address: Address!, $chainIds: [DefaultableChainId!]) { + query AccountPrimaryNames($address: Address!, $chainIds: [ChainId!]) { account(by: { address: $address }) { primaryNames(chainIds: $chainIds) { chainId @@ -370,7 +370,7 @@ describe("Account.primaryNames", () => { ); }); - it("rejects default chain id combined with other chain ids", async () => { + it("rejects chain id 0 at GraphQL validation", async () => { await expect( request(AccountPrimaryNames, { address: accounts.owner.address, diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 5450b09cdb..aedb0de9cb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,6 +1,6 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address, DefaultableChainId, InterpretedName } from "enssdk"; +import type { Address, ChainId, InterpretedName } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; @@ -79,24 +79,30 @@ AccountRef.implement({ nullable: false, args: { chainIds: t.arg({ - type: ["DefaultableChainId"], + type: ["ChainId"], required: false, description: - "Chain ids to resolve primary names for. Use 0 for the default EVM chain per ENSIP-19. Omit to resolve all ENSIP-19 supported chains.", + "Chain ids to resolve primary names for. Omit to resolve all ENSIP-19 supported chains.", + }), + disableAcceleration: t.arg.boolean({ + required: false, + defaultValue: false, + description: + "When true, disables protocol acceleration and resolves via the full on-chain specification.", }), }, - resolve: async (account, { chainIds }) => { + resolve: async (account, { chainIds, disableAcceleration }, context) => { validatePrimaryNamesChainIds(chainIds); const { result } = await runWithTrace(() => resolvePrimaryNames(account.id, chainIds ?? undefined, { - accelerate: false, - canAccelerate: false, + accelerate: !disableAcceleration, + canAccelerate: context.canAccelerate, }), ); return Object.entries(result).map(([chainId, name]) => ({ - chainId: Number(chainId) as DefaultableChainId, + chainId: Number(chainId) as ChainId, name: name as InterpretedName | null, })); }, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 71a0825ae0..75efa1ccb0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -187,14 +187,25 @@ DomainInterfaceRef.implement({ "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", type: ResolvedRecordsRef, nullable: true, - resolve: async (domain, _args, _context, info) => { + args: { + disableAcceleration: t.arg.boolean({ + required: false, + defaultValue: false, + description: + "When true, disables protocol acceleration and resolves via the full on-chain specification.", + }), + }, + resolve: async (domain, { disableAcceleration }, context, info) => { const name = domain.canonicalName; if (!name) return null; const selection = buildRecordsSelectionFromResolveInfo(info); const { result } = await runWithTrace(() => - resolveForward(name, selection, { accelerate: false, canAccelerate: false }), + resolveForward(name, selection, { + accelerate: !disableAcceleration, + canAccelerate: context.canAccelerate, + }), ); return result as ResolverRecordsResponseBase; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index fc7249712e..528788fa23 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,6 +1,6 @@ import type { + ChainId, CoinType, - DefaultableChainId, Hex, InterfaceId, InterpretedName, @@ -15,14 +15,13 @@ import { builder } from "@/omnigraph-api/builder"; // PrimaryNameByChain ////////////////////// export const PrimaryNameByChainRef = builder - .objectRef<{ chainId: DefaultableChainId; name: InterpretedName | null }>("PrimaryNameByChain") + .objectRef<{ chainId: ChainId; name: InterpretedName | null }>("PrimaryNameByChain") .implement({ description: "An ENSIP-19 primary name for an Account on a specific chain.", fields: (t) => ({ chainId: t.field({ - description: - "The chain on which the primary name was resolved. 0 denotes the default EVM chain per ENSIP-19.", - type: "DefaultableChainId", + description: "The chain on which the primary name was resolved.", + type: "ChainId", nullable: false, resolve: (r) => r.chainId, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index a60f6e0dd1..77113fe54a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -3,7 +3,6 @@ import { asInterpretedName, type ChainId, type CoinType, - type DefaultableChainId, type DomainId, type Hex, type InterfaceId, @@ -28,7 +27,6 @@ import { z } from "zod/v4"; import { makeChainIdSchema, makeCoinTypeSchema, - makeDefaultableChainIdSchema, makeNormalizedAddressSchema, } from "@ensnode/ensnode-sdk/internal"; @@ -71,13 +69,6 @@ builder.scalarType("ChainId", { parseValue: (value) => makeChainIdSchema("ChainId").parse(value), }); -builder.scalarType("DefaultableChainId", { - description: - "DefaultableChainId represents a enssdk#DefaultableChainId. Use 0 for the default EVM chain per ENSIP-19.", - serialize: (value: DefaultableChainId) => value, - parseValue: (value) => makeDefaultableChainIdSchema("DefaultableChainId").parse(value), -}); - builder.scalarType("CoinType", { description: "CoinType represents a enssdk#CoinType.", serialize: (value: CoinType) => value, diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts index dd13148fa0..936e6d350e 100644 --- a/apps/ensapi/src/omnigraph-api/yoga.ts +++ b/apps/ensapi/src/omnigraph-api/yoga.ts @@ -5,15 +5,19 @@ import { createYoga } from "graphql-yoga"; import { makeLogger } from "@/lib/logger"; -import { context } from "@/omnigraph-api/context"; +import { + createOmnigraphContext, + type OmnigraphContext, + type OmnigraphYogaServerContext, +} from "@/omnigraph-api/context"; import { schema } from "@/omnigraph-api/schema"; const logger = makeLogger("omnigraph"); -export const yoga = createYoga({ +export const yoga = createYoga({ graphqlEndpoint: "*", schema, - context, + context: ({ canAccelerate }) => createOmnigraphContext({ canAccelerate }), // CORS is handled by the Hono middleware in app.ts cors: false, graphiql: { diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 40c908898a..c54631d31e 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -224,10 +224,18 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "DefaultableChainId" + "name": "ChainId" } } } + }, + { + "name": "disableAcceleration", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "false" } ], "isDeprecated": false @@ -1097,10 +1105,6 @@ const introspection = { "kind": "SCALAR", "name": "CoinType" }, - { - "kind": "SCALAR", - "name": "DefaultableChainId" - }, { "kind": "INTERFACE", "name": "Domain", @@ -1207,7 +1211,16 @@ const introspection = { "kind": "OBJECT", "name": "ResolvedRecords" }, - "args": [], + "args": [ + { + "name": "disableAcceleration", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "false" + } + ], "isDeprecated": false }, { @@ -1975,7 +1988,16 @@ const introspection = { "kind": "OBJECT", "name": "ResolvedRecords" }, - "args": [], + "args": [ + { + "name": "disableAcceleration", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "false" + } + ], "isDeprecated": false }, { @@ -2584,7 +2606,16 @@ const introspection = { "kind": "OBJECT", "name": "ResolvedRecords" }, - "args": [], + "args": [ + { + "name": "disableAcceleration", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "false" + } + ], "isDeprecated": false }, { @@ -4618,7 +4649,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "DefaultableChainId" + "name": "ChainId" } }, "args": [], diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 1fdae9b5e5..18c4e9667e 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -24,9 +24,14 @@ type Account { """ primaryNames( """ - Chain ids to resolve primary names for. Use 0 for the default EVM chain per ENSIP-19. Omit to resolve all ENSIP-19 supported chains. + Chain ids to resolve primary names for. Omit to resolve all ENSIP-19 supported chains. """ - chainIds: [DefaultableChainId!] + chainIds: [ChainId!] + + """ + When true, disables protocol acceleration and resolves via the full on-chain specification. + """ + disableAcceleration: Boolean = false ): [PrimaryNameByChain!]! """The Permissions on Registries granted to this Account.""" @@ -233,11 +238,6 @@ scalar ChainId """CoinType represents a enssdk#CoinType.""" scalar CoinType -""" -DefaultableChainId represents a enssdk#DefaultableChainId. Use 0 for the default EVM chain per ENSIP-19. -""" -scalar DefaultableChainId - """ Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ @@ -269,7 +269,12 @@ interface Domain { """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records: ResolvedRecords + records( + """ + When true, disables protocol acceleration and resolves via the full on-chain specification. + """ + disableAcceleration: Boolean = false + ): ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -469,7 +474,12 @@ type ENSv1Domain implements Domain { """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records: ResolvedRecords + records( + """ + When true, disables protocol acceleration and resolves via the full on-chain specification. + """ + disableAcceleration: Boolean = false + ): ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -586,7 +596,12 @@ type ENSv2Domain implements Domain { """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ - records: ResolvedRecords + records( + """ + When true, disables protocol acceleration and resolves via the full on-chain specification. + """ + disableAcceleration: Boolean = false + ): ResolvedRecords """The latest Registration for this Domain, if exists.""" registration: Registration @@ -1088,10 +1103,8 @@ scalar PermissionsUserId """An ENSIP-19 primary name for an Account on a specific chain.""" type PrimaryNameByChain { - """ - The chain on which the primary name was resolved. 0 denotes the default EVM chain per ENSIP-19. - """ - chainId: DefaultableChainId! + """The chain on which the primary name was resolved.""" + chainId: ChainId! """ The validated primary name for this Account on this chain, or null if none is set. diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index dfeb8dd4a3..dc93d42699 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -3,7 +3,6 @@ import { initGraphQLTada } from "gql.tada"; import type { ChainId, CoinType, - DefaultableChainId, DomainId, Hex, InterfaceId, @@ -41,7 +40,6 @@ export type OmnigraphScalars = { Address: NormalizedAddress; Hex: Hex; ChainId: ChainId; - DefaultableChainId: DefaultableChainId; CoinType: CoinType; InterfaceId: InterfaceId; InterpretedName: InterpretedName; From 09b14353ff9f2e732e2ec8875271450e325ff879 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 20:28:02 +0300 Subject: [PATCH 07/30] fix docs --- apps/ensapi/src/omnigraph-api/schema/account.ts | 2 +- apps/ensapi/src/omnigraph-api/schema/domain.ts | 2 +- .../src/omnigraph/generated/schema.graphql | 16 ++++------------ 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index aedb0de9cb..adf2e84882 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -88,7 +88,7 @@ AccountRef.implement({ required: false, defaultValue: false, description: - "When true, disables protocol acceleration and resolves via the full on-chain specification.", + "When true, disables protocol acceleration feature.", }), }, resolve: async (account, { chainIds, disableAcceleration }, context) => { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 75efa1ccb0..359cdf567d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -192,7 +192,7 @@ DomainInterfaceRef.implement({ required: false, defaultValue: false, description: - "When true, disables protocol acceleration and resolves via the full on-chain specification.", + "When true, disables protocol acceleration feature.", }), }, resolve: async (domain, { disableAcceleration }, context, info) => { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 18c4e9667e..18e6d1c0de 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -28,9 +28,7 @@ type Account { """ chainIds: [ChainId!] - """ - When true, disables protocol acceleration and resolves via the full on-chain specification. - """ + """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false ): [PrimaryNameByChain!]! @@ -270,9 +268,7 @@ interface Domain { Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ records( - """ - When true, disables protocol acceleration and resolves via the full on-chain specification. - """ + """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false ): ResolvedRecords @@ -475,9 +471,7 @@ type ENSv1Domain implements Domain { Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ records( - """ - When true, disables protocol acceleration and resolves via the full on-chain specification. - """ + """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false ): ResolvedRecords @@ -597,9 +591,7 @@ type ENSv2Domain implements Domain { Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ records( - """ - When true, disables protocol acceleration and resolves via the full on-chain specification. - """ + """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false ): ResolvedRecords From 7668d50ee5e7544a94fc0e4aec67ac8c5800c439 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 20:36:39 +0300 Subject: [PATCH 08/30] fix tests a little bit --- .../src/omnigraph-api/schema/account.ts | 3 +- .../schema/domain.integration.test.ts | 69 +++++++++---------- .../ensapi/src/omnigraph-api/schema/domain.ts | 3 +- 3 files changed, 34 insertions(+), 41 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index adf2e84882..36392d618c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -87,8 +87,7 @@ AccountRef.implement({ disableAcceleration: t.arg.boolean({ required: false, defaultValue: false, - description: - "When true, disables protocol acceleration feature.", + description: "When true, disables protocol acceleration feature.", }), }, resolve: async (account, { chainIds, disableAcceleration }, context) => { 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 94d9c365a2..4b7a2fadc1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -548,13 +548,6 @@ describe("Domain.records", () => { } `; - const textRecordsByKey = (texts: Array<{ key: string; value: string | null }>) => - Object.fromEntries(texts.map(({ key, value }) => [key, value])); - - const addressRecordsByCoinType = ( - addresses: Array<{ coinType: CoinType; address: string | null }>, - ) => Object.fromEntries(addresses.map(({ coinType, address }) => [coinType, address])); - it("resolves address and text records for example.eth", async () => { const result = await request(DomainRecords, { name: "example.eth", @@ -562,10 +555,14 @@ describe("Domain.records", () => { texts: ["description"], }); - expect(result.domain.records?.addresses).toEqual([ - { coinType: 60, address: accounts.owner.address }, - ]); - expect(result.domain.records?.texts).toEqual([{ key: "description", value: "example.eth" }]); + expect(result).toMatchObject({ + domain: { + records: { + texts: [{ key: "description", value: "example.eth" }], + addresses: [{ coinType: 60, address: accounts.owner.address }], + }, + }, + }); }); it("resolves every supported record type for test.eth", async () => { @@ -577,32 +574,30 @@ describe("Domain.records", () => { interfaceIds: [fixtures.fourBytesInterface], }); - const records = result.domain.records; - expect(records).toBeDefined(); - - expect(records?.contenthash).toBe(fixtures.contenthash); - expect(records?.pubkey).toEqual({ x: fixtures.publicKeyX, y: fixtures.publicKeyY }); - expect(records?.dnszonehash).toBeNull(); - expect(records?.version).toEqual(expect.any(String)); - expect(records?.abi).toEqual({ - contentType: "1", - data: fixtures.abiBytes, - }); - expect(records?.interfaces).toEqual([ - { interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }, - ]); - expect(addressRecordsByCoinType(records?.addresses ?? [])).toEqual({ - 60: accounts.owner.address, - 0: fixtures.bitcoinAddress, - 2: fixtures.litecoinAddress, - }); - expect(textRecordsByKey(records?.texts ?? [])).toEqual({ - avatar: "https://example.com/avatar.png", - description: "test.eth", - url: "https://ens.domains", - email: "test@ens.domains", - "com.twitter": "ensdomains", - "com.github": "ensdomains", + expect(result).toMatchObject({ + domain: { + records: { + contenthash: fixtures.contenthash, + pubkey: { x: fixtures.publicKeyX, y: fixtures.publicKeyY }, + dnszonehash: null, + version: expect.any(String), + abi: { contentType: "1", data: fixtures.abiBytes }, + interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], + addresses: [ + { coinType: 60, address: accounts.owner.address }, + { coinType: 0, address: fixtures.bitcoinAddress }, + { coinType: 2, address: fixtures.litecoinAddress }, + ], + texts: [ + { key: "avatar", value: "https://example.com/avatar.png" }, + { key: "description", value: "test.eth" }, + { key: "url", value: "https://ens.domains" }, + { key: "email", value: "test@ens.domains" }, + { key: "com.twitter", value: "ensdomains" }, + { key: "com.github", value: "ensdomains" }, + ], + }, + }, }); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 359cdf567d..558eea29da 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -191,8 +191,7 @@ DomainInterfaceRef.implement({ disableAcceleration: t.arg.boolean({ required: false, defaultValue: false, - description: - "When true, disables protocol acceleration feature.", + description: "When true, disables protocol acceleration feature.", }), }, resolve: async (domain, { disableAcceleration }, context, info) => { From e1e5680c962259bc4b0b33e43c4dc898e77452a9 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 22:23:27 +0300 Subject: [PATCH 09/30] refactor --- .../lib/build-records-selection.test.ts | 61 +++++++++++-------- .../lib/build-records-selection.ts | 58 ++++++++++++------ .../lib/records-selection-config.ts | 43 +++++++------ .../ensapi/src/omnigraph-api/schema/domain.ts | 4 +- 4 files changed, 99 insertions(+), 67 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts index 8b780e0284..8e8ef363ae 100644 --- a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts @@ -61,7 +61,7 @@ function buildMockResolvedRecordsType() { const ResolvedRecordsType = buildMockResolvedRecordsType(); -function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { +function parseRecordsFieldNode(subselection: string) { const document = parse(`{ records { ${subselection} } }`); const operation = document.definitions[0]; if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); @@ -69,20 +69,36 @@ function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolve const recordsField = operation.selectionSet.selections[0]; if (recordsField.kind !== "Field") throw new Error("expected field"); + return recordsField; +} + +function mockResolveInfo( + fieldNodes: ReturnType[], + variableValues: Record = {}, +): GraphQLResolveInfo { return { - fieldNodes: [recordsField], + fieldNodes, fragments: {}, returnType: ResolvedRecordsType, - variableValues: {}, + variableValues, } as unknown as GraphQLResolveInfo; } +function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { + return mockResolveInfo([parseRecordsFieldNode(subselection)]); +} + +/** Simulates GraphQL passing multiple AST field nodes for the same `records` resolver. */ +function resolveInfoForMultipleRecordsFieldNodes(...subselections: string[]): GraphQLResolveInfo { + return mockResolveInfo(subselections.map(parseRecordsFieldNode)); +} + describe("buildRecordsSelectionFromResolveInfo", () => { it.each(RECORDS_SELECTION_SIMPLE_FIELDS)( - "selects $graphqlField as $selectionKey", - ({ graphqlField, selectionKey }) => { + "selects $graphqlField as $recordsSelectionKey", + ({ graphqlField, recordsSelectionKey }) => { const info = resolveInfoForRecordsSubselection(graphqlField); - expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ [selectionKey]: true }); + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ [recordsSelectionKey]: true }); }, ); @@ -131,6 +147,18 @@ describe("buildRecordsSelectionFromResolveInfo", () => { expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ name: true }); }); + it("merges selections from multiple field nodes", () => { + const info = resolveInfoForMultipleRecordsFieldNodes( + 'texts(keys: ["description"])', + "addresses(coinTypes: [60])", + ); + + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ + texts: ["description"], + addresses: [60], + }); + }); + it("throws when selection is empty", () => { const info = resolveInfoForRecordsSubselection("__typename"); expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( @@ -139,26 +167,7 @@ describe("buildRecordsSelectionFromResolveInfo", () => { }); it("throws when only unknown fields are selected", () => { - const info = { - fieldNodes: [ - { - kind: "Field", - name: { kind: "Name", value: "records" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { - kind: "Field", - name: { kind: "Name", value: "unknownField" }, - }, - ], - }, - }, - ], - fragments: {}, - returnType: ResolvedRecordsType, - variableValues: {}, - } as unknown as GraphQLResolveInfo; + const info = resolveInfoForRecordsSubselection("unknownField"); expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( EMPTY_RECORDS_SELECTION_MESSAGE, diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts index 7054e272da..485af60e8e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts @@ -5,6 +5,7 @@ import { getArgumentValues, getNamedType, isObjectType, + Kind, type SelectionSetNode, } from "graphql"; @@ -17,17 +18,18 @@ import { export const EMPTY_RECORDS_SELECTION_MESSAGE = "Records selection cannot be empty."; -function collectFieldNodes(selectionSet: SelectionSetNode, info: GraphQLResolveInfo): FieldNode[] { +/** Recursively flatten a GraphQL selection set into Field nodes (expanding fragments). */ +function collectFieldNodes(graphqlSelectionSet: SelectionSetNode, info: GraphQLResolveInfo): FieldNode[] { const fields: FieldNode[] = []; - for (const selection of selectionSet.selections) { - if (selection.kind === "Field") { - if (selection.name.value === "__typename") continue; - fields.push(selection); - } else if (selection.kind === "InlineFragment") { - fields.push(...collectFieldNodes(selection.selectionSet, info)); - } else if (selection.kind === "FragmentSpread") { - const fragment = info.fragments[selection.name.value]; + for (const graphqlSelection of graphqlSelectionSet.selections) { + if (graphqlSelection.kind === "Field") { + if (graphqlSelection.name.value === "__typename") continue; + fields.push(graphqlSelection); + } else if (graphqlSelection.kind === "InlineFragment") { + fields.push(...collectFieldNodes(graphqlSelection.selectionSet, info)); + } else if (graphqlSelection.kind === "FragmentSpread") { + const fragment = info.fragments[graphqlSelection.name.value]; if (fragment) fields.push(...collectFieldNodes(fragment.selectionSet, info)); } } @@ -36,33 +38,50 @@ function collectFieldNodes(selectionSet: SelectionSetNode, info: GraphQLResolveI } /** - * Builds a {@link ResolverRecordsSelection} from the GraphQL selection set and field - * arguments on `Domain.records` → `ResolvedRecords`. + * Builds a {@link ResolverRecordsSelection} from the GraphQL field selection on `Domain.records`. + * + * GraphQL clients express *what* to resolve via a field selection set (e.g. `records { texts(...) }`). + * The ENS resolution layer expects a flat {@link ResolverRecordsSelection} instead — this function + * translates between the two. */ export function buildRecordsSelectionFromResolveInfo( info: GraphQLResolveInfo, ): ResolverRecordsSelection { - const fieldNode = info.fieldNodes[0]; - if (!fieldNode?.selectionSet) { + // GraphQL may pass multiple AST field nodes for the same resolver when the client splits + // `records { ... }` across inline fragments (common on the `Domain` interface). Merge their + // GraphQL selection lists so we don't drop subselections on fieldNodes[1], fieldNodes[2], etc. + const graphqlSelections = info.fieldNodes.flatMap((node) => node.selectionSet?.selections ?? []); + + if (graphqlSelections.length === 0) { throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); } + // collectFieldNodes expects a SelectionSetNode; wrap the merged GraphQL selections into one. + const mergedGraphqlSelectionSet: SelectionSetNode = { + kind: Kind.SELECTION_SET, + selections: graphqlSelections, + }; + const returnType = getNamedType(info.returnType); if (!isObjectType(returnType)) { throw new GraphQLError("Return type must be an object type."); } - const selection: ResolverRecordsSelection = {}; + // Output for resolveForward(), e.g. { texts: ["description"], addresses: [60] }. + const recordsSelection: ResolverRecordsSelection = {}; - for (const childField of collectFieldNodes(fieldNode.selectionSet, info)) { + // Walk every GraphQL child field under `records` (skipping __typename, expanding fragments). + for (const childField of collectFieldNodes(mergedGraphqlSelectionSet, info)) { const graphqlField = childField.name.value; + // Simple GraphQL fields (contenthash, pubkey, …) map 1:1 to a boolean in recordsSelection. const simple = getSimpleRecordsSelectionField(graphqlField); if (simple) { - selection[simple.selectionKey] = true; + recordsSelection[simple.recordsSelectionKey] = true; continue; } + // Parametric GraphQL fields (texts, addresses, …) carry args we copy into recordsSelection. const parametric = getParametricRecordsSelectionField(graphqlField); if (!parametric) continue; @@ -70,12 +89,13 @@ export function buildRecordsSelectionFromResolveInfo( if (!fieldDef) continue; const args = getArgumentValues(fieldDef, childField, info.variableValues); - parametric.applySelection(selection, args); + parametric.applyToRecordsSelection(recordsSelection, args); } - if (isSelectionEmpty(selection)) { + // GraphQL query selected only __typename or unknown fields — nothing to resolve. + if (isSelectionEmpty(recordsSelection)) { throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); } - return selection; + return recordsSelection; } diff --git a/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts b/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts index 250cf8d994..d03600576a 100644 --- a/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts +++ b/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts @@ -14,14 +14,17 @@ export type RecordsSelectionParametricKey = Extract< export type RecordsSelectionSimpleField = { graphqlField: string; - selectionKey: RecordsSelectionSimpleKey; + recordsSelectionKey: RecordsSelectionSimpleKey; }; export type RecordsSelectionParametricField = { graphqlField: string; argName: string; - selectionKey: RecordsSelectionParametricKey; - applySelection: (selection: ResolverRecordsSelection, args: Record) => void; + recordsSelectionKey: RecordsSelectionParametricKey; + applyToRecordsSelection: ( + recordsSelection: ResolverRecordsSelection, + args: Record, + ) => void; }; /** @@ -29,11 +32,11 @@ export type RecordsSelectionParametricField = { * Querying the field (no args) selects that record for resolution. */ export const RECORDS_SELECTION_SIMPLE_FIELDS = [ - { graphqlField: "reverseName", selectionKey: "name" }, - { graphqlField: "contenthash", selectionKey: "contenthash" }, - { graphqlField: "pubkey", selectionKey: "pubkey" }, - { graphqlField: "dnszonehash", selectionKey: "dnszonehash" }, - { graphqlField: "version", selectionKey: "version" }, + { graphqlField: "reverseName", recordsSelectionKey: "name" }, + { graphqlField: "contenthash", recordsSelectionKey: "contenthash" }, + { graphqlField: "pubkey", recordsSelectionKey: "pubkey" }, + { graphqlField: "dnszonehash", recordsSelectionKey: "dnszonehash" }, + { graphqlField: "version", recordsSelectionKey: "version" }, ] as const satisfies readonly RecordsSelectionSimpleField[]; /** @@ -43,33 +46,33 @@ export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ { graphqlField: "texts", argName: "keys", - selectionKey: "texts", - applySelection: (selection, args) => { - selection.texts = args.keys as string[]; + recordsSelectionKey: "texts", + applyToRecordsSelection: (recordsSelection, args) => { + recordsSelection.texts = args.keys as string[]; }, }, { graphqlField: "addresses", argName: "coinTypes", - selectionKey: "addresses", - applySelection: (selection, args) => { - selection.addresses = args.coinTypes as CoinType[]; + recordsSelectionKey: "addresses", + applyToRecordsSelection: (recordsSelection, args) => { + recordsSelection.addresses = args.coinTypes as CoinType[]; }, }, { graphqlField: "abi", argName: "contentTypeMask", - selectionKey: "abi", - applySelection: (selection, args) => { - selection.abi = args.contentTypeMask as ContentType; + recordsSelectionKey: "abi", + applyToRecordsSelection: (recordsSelection, args) => { + recordsSelection.abi = args.contentTypeMask as ContentType; }, }, { graphqlField: "interfaces", argName: "ids", - selectionKey: "interfaces", - applySelection: (selection, args) => { - selection.interfaces = args.ids as InterfaceId[]; + recordsSelectionKey: "interfaces", + applyToRecordsSelection: (recordsSelection, args) => { + recordsSelection.interfaces = args.ids as InterfaceId[]; }, }, ] as const satisfies readonly RecordsSelectionParametricField[]; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 558eea29da..a573c354ad 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -198,10 +198,10 @@ DomainInterfaceRef.implement({ const name = domain.canonicalName; if (!name) return null; - const selection = buildRecordsSelectionFromResolveInfo(info); + const recordsSelection = buildRecordsSelectionFromResolveInfo(info); const { result } = await runWithTrace(() => - resolveForward(name, selection, { + resolveForward(name, recordsSelection, { accelerate: !disableAcceleration, canAccelerate: context.canAccelerate, }), From c88dde293a492f060bf2586f3172259590358886 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 22:35:30 +0300 Subject: [PATCH 10/30] fix tests --- .../lib/build-records-selection.ts | 5 +++- .../src/omnigraph-api/schema/resolution.ts | 23 +++++++++++-------- apps/ensapi/src/omnigraph-api/yoga.ts | 1 - 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts index 485af60e8e..e89e99a19c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts @@ -19,7 +19,10 @@ import { export const EMPTY_RECORDS_SELECTION_MESSAGE = "Records selection cannot be empty."; /** Recursively flatten a GraphQL selection set into Field nodes (expanding fragments). */ -function collectFieldNodes(graphqlSelectionSet: SelectionSetNode, info: GraphQLResolveInfo): FieldNode[] { +function collectFieldNodes( + graphqlSelectionSet: SelectionSetNode, + info: GraphQLResolveInfo, +): FieldNode[] { const fields: FieldNode[] = []; for (const graphqlSelection of graphqlSelectionSet.selections) { diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 528788fa23..aaab53c079 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -204,11 +204,12 @@ export const ResolvedRecordsRef = builder description: "ERC-165 interface ids to resolve (4-byte hex selectors).", }), }, - resolve: (r) => + resolve: (r, { ids }) => + // preserve the order of requested interface ids r.interfaces - ? Object.entries(r.interfaces).map(([interfaceId, implementer]) => ({ - interfaceId: interfaceId as InterfaceId, - implementer, + ? ids.map((interfaceId) => ({ + interfaceId, + implementer: r.interfaces![interfaceId] ?? null, })) : [], }), @@ -222,8 +223,9 @@ export const ResolvedRecordsRef = builder description: "Text record keys to resolve (e.g. `avatar`, `description`).", }), }, - resolve: (r) => - r.texts ? Object.entries(r.texts).map(([key, value]) => ({ key, value })) : [], + resolve: (r, { keys }) => + // preserve the order of requested text keys + r.texts ? keys.map((key) => ({ key, value: r.texts![key] ?? null })) : [], }), addresses: t.field({ description: "Resolved address records for the requested coin types.", @@ -236,11 +238,12 @@ export const ResolvedRecordsRef = builder description: "Coin types to resolve (e.g. `60` for ETH).", }), }, - resolve: (r) => + resolve: (r, { coinTypes }) => r.addresses - ? Object.entries(r.addresses).map(([coinType, address]) => ({ - coinType: Number(coinType) as CoinType, - address, + ? // preserve the order of requested coin types + coinTypes.map((coinType) => ({ + coinType, + address: r.addresses![coinType] ?? null, })) : [], }), diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts index 334d92531a..4b963abb7a 100644 --- a/apps/ensapi/src/omnigraph-api/yoga.ts +++ b/apps/ensapi/src/omnigraph-api/yoga.ts @@ -16,7 +16,6 @@ import { schema } from "@/omnigraph-api/schema"; const logger = makeLogger("omnigraph"); - // tests exact ZodError or GraphQLError-wrapped ZodError const isZodError = (value: unknown): boolean => value instanceof ZodError || From 380dadf4347a7cd520da1c734e69c4d197a81489 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 20 May 2026 22:54:24 +0300 Subject: [PATCH 11/30] forgot changeset --- .changeset/omnigraph-resolution-api.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/omnigraph-resolution-api.md diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md new file mode 100644 index 0000000000..72c6e44efc --- /dev/null +++ b/.changeset/omnigraph-resolution-api.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +**Omnigraph**: add live ENS resolution fields — `Domain.records` for forward resolution (texts, addresses, contenthash, pubkey, ABI, interfaces, and related record types) and `Account.primaryNames` for ENSIP-19 multichain primary names. Record types to resolve are selected via the GraphQL field selection on `records`; both fields accept an optional `disableAcceleration` argument. From b1232aba579b7a45a7d061f36cc972643d38103b Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 21 May 2026 21:40:15 +0300 Subject: [PATCH 12/30] fix PR suggestions --- apps/ensapi/src/omnigraph-api/builder.ts | 4 +- apps/ensapi/src/omnigraph-api/context.ts | 2 +- .../lib/find-domains/find-domains-resolver.ts | 4 +- .../build-records-selection.test.ts | 4 +- .../build-records-selection.ts | 2 +- .../records-selection-config.ts | 0 .../validate-primary-names-chain-ids.test.ts | 21 - .../lib/validate-primary-names-chain-ids.ts | 18 - .../schema/account.integration.test.ts | 43 +- .../src/omnigraph-api/schema/account.ts | 9 +- .../schema/domain.integration.test.ts | 32 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 3 +- .../src/omnigraph-api/schema/resolution.ts | 436 +++++++++--------- .../src/omnigraph-api/schema/scalars.ts | 3 +- apps/ensapi/src/omnigraph-api/yoga.ts | 6 +- .../react/omnigraph/_lib/cache-exchange.ts | 7 + 16 files changed, 294 insertions(+), 300 deletions(-) rename apps/ensapi/src/omnigraph-api/lib/{ => resolution}/build-records-selection.test.ts (97%) rename apps/ensapi/src/omnigraph-api/lib/{ => resolution}/build-records-selection.ts (98%) rename apps/ensapi/src/omnigraph-api/lib/{ => resolution}/records-selection-config.ts (100%) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts delete mode 100644 apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 6f0157cc2d..c30b904bd4 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -29,7 +29,7 @@ import type { import { getNamedType } from "graphql"; import superjson from "superjson"; -import type { OmnigraphContext } from "@/omnigraph-api/context"; +import type { Context } from "@/omnigraph-api/context"; const tracer = trace.getTracer("graphql"); const createSpan = createOpenTelemetryWrapper(tracer, { @@ -84,7 +84,7 @@ export type BuilderScalars = { }; export const builder = new SchemaBuilder<{ - Context: OmnigraphContext; + Context: Context; Scalars: BuilderScalars; // the following ensures via typechecker that every t.connection returns a totalCount field diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index e294f2f725..50abc23c77 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -35,4 +35,4 @@ export const createOmnigraphContext = (serverContext: OmnigraphYogaServerContext canAccelerate: serverContext.canAccelerate, }); -export type OmnigraphContext = ReturnType; +export type Context = ReturnType; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts index 43145fcb2b..5ef1be0c65 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts @@ -6,7 +6,7 @@ import type { NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; -import type { OmnigraphContext } from "@/omnigraph-api/context"; +import type { Context } from "@/omnigraph-api/context"; import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor"; import { cursorFilter, @@ -101,7 +101,7 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa * @param args - Compound `where` filter, optional ordering, and relay connection args */ export function resolveFindDomains( - context: OmnigraphContext, + context: Context, { where, order, diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts similarity index 97% rename from apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts index 8e8ef363ae..a7c68d6273 100644 --- a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts @@ -15,11 +15,11 @@ import { describe, expect, it } from "vitest"; import { buildRecordsSelectionFromResolveInfo, EMPTY_RECORDS_SELECTION_MESSAGE, -} from "@/omnigraph-api/lib/build-records-selection"; +} from "@/omnigraph-api/lib/resolution/build-records-selection"; import { RECORDS_SELECTION_PARAMETRIC_FIELDS, RECORDS_SELECTION_SIMPLE_FIELDS, -} from "@/omnigraph-api/lib/records-selection-config"; +} from "@/omnigraph-api/lib/resolution/records-selection-config"; const stringListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))); const intListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLInt))); diff --git a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts similarity index 98% rename from apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts index e89e99a19c..3529941664 100644 --- a/apps/ensapi/src/omnigraph-api/lib/build-records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts @@ -14,7 +14,7 @@ import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnod import { getParametricRecordsSelectionField, getSimpleRecordsSelectionField, -} from "@/omnigraph-api/lib/records-selection-config"; +} from "@/omnigraph-api/lib/resolution/records-selection-config"; export const EMPTY_RECORDS_SELECTION_MESSAGE = "Records selection cannot be empty."; diff --git a/apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts similarity index 100% rename from apps/ensapi/src/omnigraph-api/lib/records-selection-config.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts deleted file mode 100644 index 1e54184557..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - EMPTY_CHAIN_IDS_MESSAGE, - validatePrimaryNamesChainIds, -} from "@/omnigraph-api/lib/validate-primary-names-chain-ids"; - -describe("validatePrimaryNamesChainIds", () => { - it.each([ - { chainIds: undefined, label: "omitted" }, - { chainIds: null, label: "null" }, - { chainIds: [1], label: "single chain" }, - { chainIds: [1, 10], label: "multiple chains" }, - ])("allows $label", ({ chainIds }) => { - expect(() => validatePrimaryNamesChainIds(chainIds)).not.toThrow(); - }); - - it("rejects empty chainIds", () => { - expect(() => validatePrimaryNamesChainIds([])).toThrow(EMPTY_CHAIN_IDS_MESSAGE); - }); -}); diff --git a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts b/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts deleted file mode 100644 index 5015efdc68..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/validate-primary-names-chain-ids.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ChainId } from "enssdk"; -import { GraphQLError } from "graphql"; - -export const EMPTY_CHAIN_IDS_MESSAGE = "chainIds cannot be empty."; - -/** - * Validates `chainIds` for primary name resolution. - * - * - `undefined` (omitted) is allowed — resolves all ENSIP-19 supported chains. - * - Empty array is rejected. - */ -export function validatePrimaryNamesChainIds(chainIds: ChainId[] | null | undefined): void { - if (chainIds === null || chainIds === undefined) return; - - if (chainIds.length === 0) { - throw new GraphQLError(EMPTY_CHAIN_IDS_MESSAGE); - } -} 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 ca09ce01a6..2b5cf89ba0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -343,31 +343,40 @@ describe("Account.primaryNames", () => { `; it("resolves primary name for owner on chain 1", async () => { - const result = await request(AccountPrimaryNames, { - address: accounts.owner.address, - chainIds: [1], - }); - - expect(result.account.primaryNames).toEqual([{ chainId: 1, name: "test.eth" }]); + await expect( + request(AccountPrimaryNames, { + address: accounts.owner.address, + chainIds: [1], + }), + ).resolves.toEqual({ account: { primaryNames: [{ chainId: 1, name: "test.eth" }] } }); }); it("returns null for user without a primary name", async () => { - const result = await request(AccountPrimaryNames, { - address: accounts.user.address, - chainIds: [1], - }); - - expect(result.account.primaryNames).toEqual([{ chainId: 1, name: null }]); + await expect( + request(AccountPrimaryNames, { + address: accounts.user.address, + chainIds: [1], + }), + ).resolves.toEqual({ account: { primaryNames: [{ chainId: 1, name: null }] } }); }); it("resolves all ENSIP-19 supported chains when chainIds is omitted from the query", async () => { - const result = await request(AccountPrimaryNamesAllChains, { - address: accounts.owner.address, + await expect( + request(AccountPrimaryNamesAllChains, { + address: accounts.owner.address, + }), + ).resolves.toMatchObject({ + account: { primaryNames: expect.arrayContaining([{ chainId: 1, name: "test.eth" }]) }, }); + }); - expect(result.account.primaryNames).toEqual( - expect.arrayContaining([{ chainId: 1, name: "test.eth" }]), - ); + it("rejects empty chainIds at GraphQL validation", async () => { + await expect( + request(AccountPrimaryNames, { + address: accounts.owner.address, + chainIds: [], + }), + ).rejects.toThrow(); }); it("rejects chain id 0 at GraphQL validation", async () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index f07a66ed24..25dff4e966 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -11,7 +11,6 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain 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 { validatePrimaryNamesChainIds } from "@/omnigraph-api/lib/validate-primary-names-chain-ids"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -75,6 +74,7 @@ AccountRef.implement({ required: false, description: "Chain ids to resolve primary names for. Omit to resolve all ENSIP-19 supported chains.", + validate: { minLength: 1 }, }), disableAcceleration: t.arg.boolean({ required: false, @@ -83,8 +83,6 @@ AccountRef.implement({ }), }, resolve: async (account, { chainIds, disableAcceleration }, context) => { - validatePrimaryNamesChainIds(chainIds); - const { result } = await runWithTrace(() => resolvePrimaryNames(account.id, chainIds ?? undefined, { accelerate: !disableAcceleration, @@ -92,8 +90,11 @@ AccountRef.implement({ }), ); + // Object.entries erases key/value types, + // but values are already ChainId / InterpretedName | null, + // so cast is safe. return Object.entries(result).map(([chainId, name]) => ({ - chainId: Number(chainId) as ChainId, + chainId: chainId as unknown as ChainId, name: name as InterpretedName | null, })); }, 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 4b7a2fadc1..1c1641faa3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -549,13 +549,13 @@ describe("Domain.records", () => { `; it("resolves address and text records for example.eth", async () => { - const result = await request(DomainRecords, { - name: "example.eth", - addresses: [60], - texts: ["description"], - }); - - expect(result).toMatchObject({ + await expect( + request(DomainRecords, { + name: "example.eth", + addresses: [60], + texts: ["description"], + }), + ).resolves.toMatchObject({ domain: { records: { texts: [{ key: "description", value: "example.eth" }], @@ -566,15 +566,15 @@ describe("Domain.records", () => { }); it("resolves every supported record type for test.eth", async () => { - const result = await request(DomainRecordsAll, { - name: "test.eth", - addresses: [60, 0, 2], - texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], - contentTypeMask: "1", - interfaceIds: [fixtures.fourBytesInterface], - }); - - expect(result).toMatchObject({ + await expect( + request(DomainRecordsAll, { + name: "test.eth", + addresses: [60, 0, 2], + texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], + contentTypeMask: "1", + interfaceIds: [fixtures.fourBytesInterface], + }), + ).resolves.toMatchObject({ domain: { records: { contenthash: fixtures.contenthash, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 35f529c2e5..98af3c779e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -14,7 +14,6 @@ import { withSpanAsync } from "@/lib/instrumentation/auto-span"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; -import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/build-records-selection"; import { EMPTY_CONNECTION, orderPaginationBy, @@ -27,6 +26,7 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/resolution/build-records-selection"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS, @@ -182,6 +182,7 @@ DomainInterfaceRef.implement({ "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", type: ResolvedRecordsRef, nullable: true, + tracing: true, args: { disableAcceleration: t.arg.boolean({ required: false, diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index aaab53c079..0d5e6ebd77 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -14,238 +14,252 @@ import { builder } from "@/omnigraph-api/builder"; ////////////////////// // PrimaryNameByChain ////////////////////// -export const PrimaryNameByChainRef = builder - .objectRef<{ chainId: ChainId; name: InterpretedName | null }>("PrimaryNameByChain") - .implement({ - description: "An ENSIP-19 primary name for an Account on a specific chain.", - fields: (t) => ({ - chainId: t.field({ - description: "The chain on which the primary name was resolved.", - type: "ChainId", - nullable: false, - resolve: (r) => r.chainId, - }), - name: t.field({ - description: - "The validated primary name for this Account on this chain, or null if none is set.", - type: "InterpretedName", - nullable: true, - resolve: (r) => r.name, - }), - }), - }); +export const PrimaryNameByChainRef = builder.objectRef<{ + chainId: ChainId; + name: InterpretedName | null; +}>("PrimaryNameByChain"); + +PrimaryNameByChainRef.implement({ + description: "An ENSIP-19 primary name for an Account on a specific chain.", + fields: (t) => ({ + chainId: t.field({ + description: "The chain on which the primary name was resolved.", + type: "ChainId", + nullable: false, + resolve: (r) => r.chainId, + }), + name: t.field({ + description: + "The validated primary name for this Account on this chain, or null if none is set.", + type: "InterpretedName", + nullable: true, + resolve: (r) => r.name, + }), + }), +}); /////////////////////// // 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, - }), - }), - }); +export const ResolvedTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( + "ResolvedTextRecord", +); + +ResolvedTextRecordRef.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, - }), - }), - }); +export const ResolvedAddressRecordRef = builder.objectRef<{ + coinType: CoinType; + address: string | null; +}>("ResolvedAddressRecord"); + +ResolvedAddressRecordRef.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, + }), + }), +}); //////////////////////// // ResolvedPubkeyRecord //////////////////////// -export const ResolvedPubkeyRecordRef = builder - .objectRef<{ x: Hex; y: Hex }>("ResolvedPubkeyRecord") - .implement({ - description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", - fields: (t) => ({ - x: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.x, - }), - y: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.y, - }), - }), - }); +export const ResolvedPubkeyRecordRef = builder.objectRef<{ x: Hex; y: Hex }>( + "ResolvedPubkeyRecord", +); + +ResolvedPubkeyRecordRef.implement({ + description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", + fields: (t) => ({ + x: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.x, + }), + y: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.y, + }), + }), +}); /////////////////////// // ResolvedAbiRecord /////////////////////// -export const ResolvedAbiRecordRef = builder - .objectRef<{ contentType: bigint; data: Hex }>("ResolvedAbiRecord") - .implement({ - description: "A resolved ABI record for an ENS name.", - fields: (t) => ({ - contentType: t.field({ - type: "BigInt", - nullable: false, - resolve: (r) => r.contentType, - }), - data: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.data, - }), - }), - }); +export const ResolvedAbiRecordRef = builder.objectRef<{ contentType: bigint; data: Hex }>( + "ResolvedAbiRecord", +); + +ResolvedAbiRecordRef.implement({ + description: "A resolved ABI record for an ENS name.", + fields: (t) => ({ + contentType: t.field({ + type: "BigInt", + nullable: false, + resolve: (r) => r.contentType, + }), + data: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.data, + }), + }), +}); //////////////////////////// // ResolvedInterfaceRecord //////////////////////////// -export const ResolvedInterfaceRecordRef = builder - .objectRef<{ interfaceId: InterfaceId; implementer: NormalizedAddress | null }>( - "ResolvedInterfaceRecord", - ) - .implement({ - description: "A resolved ERC-165 interface implementer record for an ENS name.", - fields: (t) => ({ - interfaceId: t.field({ - type: "InterfaceId", - nullable: false, - resolve: (r) => r.interfaceId, - }), - implementer: t.field({ - type: "Address", - nullable: true, - resolve: (r) => r.implementer, - }), - }), - }); +export const ResolvedInterfaceRecordRef = builder.objectRef<{ + interfaceId: InterfaceId; + implementer: NormalizedAddress | null; +}>("ResolvedInterfaceRecord"); + +ResolvedInterfaceRecordRef.implement({ + description: "A resolved ERC-165 interface implementer record for an ENS name.", + fields: (t) => ({ + interfaceId: t.field({ + type: "InterfaceId", + nullable: false, + resolve: (r) => r.interfaceId, + }), + implementer: t.field({ + type: "Address", + nullable: true, + resolve: (r) => r.implementer, + }), + }), +}); //////////////////// // ResolvedRecords //////////////////// -export const ResolvedRecordsRef = builder - .objectRef>("ResolvedRecords") - .implement({ - description: "Records resolved for a specific ENS name via the ENS protocol.", - fields: (t) => ({ - reverseName: t.string({ - description: - "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set.", - nullable: true, - resolve: (r) => r.name ?? null, - }), - contenthash: t.field({ - description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", - type: "Hex", - nullable: true, - resolve: (r) => r.contenthash ?? null, - }), - pubkey: t.field({ - description: "The PubkeyResolver (x, y) pair, or null if not set.", - type: ResolvedPubkeyRecordRef, - nullable: true, - resolve: (r) => r.pubkey ?? null, - }), - dnszonehash: t.field({ - description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", - type: "Hex", - nullable: true, - resolve: (r) => r.dnszonehash ?? null, - }), - version: t.field({ - description: "The IVersionableResolver version, or null if not set or unavailable.", - type: "BigInt", - nullable: true, - resolve: (r) => r.version ?? null, - }), - abi: t.field({ - description: - "The first stored ABI matching the requested content-type bitmask, or null if not set.", - type: ResolvedAbiRecordRef, - nullable: true, - args: { - contentTypeMask: t.arg({ - type: "BigInt", - required: true, - description: - "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", - }), - }, - resolve: (r) => r.abi ?? null, - }), - interfaces: t.field({ - description: "Resolved ERC-165 interface implementer records for the requested ids.", - type: [ResolvedInterfaceRecordRef], - nullable: false, - args: { - ids: t.arg({ - type: ["InterfaceId"], - required: true, - description: "ERC-165 interface ids to resolve (4-byte hex selectors).", - }), - }, - resolve: (r, { ids }) => - // preserve the order of requested interface ids - r.interfaces - ? ids.map((interfaceId) => ({ - interfaceId, - implementer: r.interfaces![interfaceId] ?? null, - })) - : [], - }), - texts: t.field({ - description: "Resolved text records for the requested keys.", - type: [ResolvedTextRecordRef], - nullable: false, - args: { - keys: t.arg.stringList({ - required: true, - description: "Text record keys to resolve (e.g. `avatar`, `description`).", - }), - }, - resolve: (r, { keys }) => - // preserve the order of requested text keys - r.texts ? keys.map((key) => ({ key, value: r.texts![key] ?? null })) : [], - }), - addresses: t.field({ - description: "Resolved address records for the requested coin types.", - type: [ResolvedAddressRecordRef], - nullable: false, - args: { - coinTypes: t.arg({ - type: ["CoinType"], - required: true, - description: "Coin types to resolve (e.g. `60` for ETH).", - }), - }, - resolve: (r, { coinTypes }) => - r.addresses - ? // preserve the order of requested coin types - coinTypes.map((coinType) => ({ - coinType, - address: r.addresses![coinType] ?? null, - })) - : [], - }), - }), - }); +export const ResolvedRecordsRef = + builder.objectRef>("ResolvedRecords"); + +ResolvedRecordsRef.implement({ + description: "Records resolved for a specific ENS name via the ENS protocol.", + fields: (t) => ({ + reverseName: t.string({ + description: + "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set.", + nullable: true, + resolve: (r) => r.name ?? null, + }), + contenthash: t.field({ + description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.contenthash ?? null, + }), + pubkey: t.field({ + description: "The PubkeyResolver (x, y) pair, or null if not set.", + type: ResolvedPubkeyRecordRef, + nullable: true, + resolve: (r) => r.pubkey ?? null, + }), + dnszonehash: t.field({ + description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.dnszonehash ?? null, + }), + version: t.field({ + description: "The IVersionableResolver version, or null if not set or unavailable.", + type: "BigInt", + nullable: true, + resolve: (r) => r.version ?? null, + }), + abi: t.field({ + description: + "The first stored ABI matching the requested content-type bitmask, or null if not set.", + type: ResolvedAbiRecordRef, + nullable: true, + args: { + contentTypeMask: t.arg({ + type: "BigInt", + required: true, + description: + "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", + }), + }, + resolve: (r) => r.abi ?? null, + }), + interfaces: t.field({ + description: "Resolved ERC-165 interface implementer records for the requested ids.", + type: [ResolvedInterfaceRecordRef], + nullable: false, + args: { + ids: t.arg({ + type: ["InterfaceId"], + required: true, + description: "ERC-165 interface ids to resolve (4-byte hex selectors).", + }), + }, + resolve: (r, { ids }) => + // preserve the order of requested interface ids + r.interfaces + ? ids.map((interfaceId) => ({ + interfaceId, + implementer: r.interfaces?.[interfaceId] ?? null, + })) + : [], + }), + texts: t.field({ + description: "Resolved text records for the requested keys.", + type: [ResolvedTextRecordRef], + nullable: false, + args: { + keys: t.arg.stringList({ + required: true, + description: "Text record keys to resolve (e.g. `avatar`, `description`).", + }), + }, + resolve: (r, { keys }) => + // preserve the order of requested text keys + r.texts ? keys.map((key) => ({ key, value: r.texts?.[key] ?? null })) : [], + }), + addresses: t.field({ + description: "Resolved address records for the requested coin types.", + type: [ResolvedAddressRecordRef], + nullable: false, + args: { + coinTypes: t.arg({ + type: ["CoinType"], + required: true, + description: "Coin types to resolve (e.g. `60` for ETH).", + }), + }, + resolve: (r, { coinTypes }) => + r.addresses + ? // preserve the order of requested coin types + coinTypes.map((coinType) => ({ + coinType, + address: r.addresses?.[coinType] ?? null, + })) + : [], + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index cdcb8ff83f..f961b835d9 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -83,6 +83,7 @@ builder.scalarType("InterfaceId", { parseValue: (value) => z.coerce .string() + .transform((val) => val.toLowerCase()) .check((ctx) => { if (!isInterfaceId(ctx.value)) { ctx.issues.push({ @@ -92,7 +93,7 @@ builder.scalarType("InterfaceId", { }); } }) - .transform((val) => val.toLowerCase() as InterfaceId) + .transform((val) => val as InterfaceId) .parse(value), }); diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts index 4b963abb7a..4336c57b91 100644 --- a/apps/ensapi/src/omnigraph-api/yoga.ts +++ b/apps/ensapi/src/omnigraph-api/yoga.ts @@ -8,8 +8,8 @@ import { ZodError } from "zod/v4"; import { makeLogger } from "@/lib/logger"; import { + type Context, createOmnigraphContext, - type OmnigraphContext, type OmnigraphYogaServerContext, } from "@/omnigraph-api/context"; import { schema } from "@/omnigraph-api/schema"; @@ -37,10 +37,10 @@ const yogaLogger = { }, }; -export const yoga = createYoga({ +export const yoga = createYoga({ graphqlEndpoint: "*", schema, - context: ({ canAccelerate }) => createOmnigraphContext({ canAccelerate }), + context: createOmnigraphContext, // CORS is handled by the Hono middleware in app.ts cors: false, graphiql: { diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 6d890670b8..b449c948d3 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -40,6 +40,13 @@ export const omnigraphCacheExchange = cacheExchange({ // These entities are Embedded Data and don't have a relevant key Label: EMBEDDED_DATA, WrappedBaseRegistrarRegistration: EMBEDDED_DATA, + PrimaryNameByChain: EMBEDDED_DATA, + ResolvedAbiRecord: EMBEDDED_DATA, + ResolvedAddressRecord: EMBEDDED_DATA, + ResolvedInterfaceRecord: EMBEDDED_DATA, + ResolvedPubkeyRecord: EMBEDDED_DATA, + ResolvedRecords: EMBEDDED_DATA, + ResolvedTextRecord: EMBEDDED_DATA, }, resolvers: mergeResolverMaps( // produce relayPagination() local resolvers for each t.connection in the schema From bf275b99c589bf6e4cc29fe8c9f3b273be2126f2 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 21 May 2026 21:59:09 +0300 Subject: [PATCH 13/30] fix the cast problem --- apps/ensapi/src/omnigraph-api/schema/account.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 25dff4e966..9653690ee3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -90,11 +90,11 @@ AccountRef.implement({ }), ); - // Object.entries erases key/value types, - // but values are already ChainId / InterpretedName | null, - // so cast is safe. + // Object.entries returns key/value pairs as [string, string | null], + // but the values are already ChainId / InterpretedName | null, + // so the cast is safe. return Object.entries(result).map(([chainId, name]) => ({ - chainId: chainId as unknown as ChainId, + chainId: Number(chainId) as ChainId, name: name as InterpretedName | null, })); }, From cbdca1182d1bb130f3fd3b1561a2cc63ef72fbef Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 21 May 2026 22:11:02 +0300 Subject: [PATCH 14/30] remove seed-cli --- package.json | 1 - packages/integration-test-env/package.json | 1 - packages/integration-test-env/src/seed-cli.ts | 17 ----------------- 3 files changed, 19 deletions(-) delete mode 100644 packages/integration-test-env/src/seed-cli.ts diff --git a/package.json b/package.json index ebc363acf4..a96afda174 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "release:postversion": "pnpm docker:version:sync && pnpm generate:openapi", "packages:prepublish": "pnpm -r prepublish", "devnet": "docker compose -f docker/services/devnet.yml up", - "seed:devnet": "pnpm -F @ensnode/integration-test-env seed:devnet", "docker:build:ensnode": "pnpm run -w --parallel \"/^docker:build:.*/\"", "docker:version:sync": "node ./scripts/sync-docker-services-tags.mjs \"$(pnpm -F ensapi -s version:current)\"", "docker:build:ensindexer": "docker build -f apps/ensindexer/Dockerfile -t ghcr.io/namehash/ensnode/ensindexer:latest .", diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index 635603be6d..3b0e515951 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -6,7 +6,6 @@ "type": "module", "description": "Integration test environment orchestration for ENSNode", "scripts": { - "seed:devnet": "tsx src/seed-cli.ts", "start": "tsx src/start.ts", "start:ci": "CI=1 tsx src/ci.ts", "typecheck": "tsc --noEmit" diff --git a/packages/integration-test-env/src/seed-cli.ts b/packages/integration-test-env/src/seed-cli.ts deleted file mode 100644 index 339d9aedcf..0000000000 --- a/packages/integration-test-env/src/seed-cli.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ensTestEnvChain } from "@ensnode/datasources"; - -import { seedDevnet } from "./seed/index"; - -const defaultRpcUrl = ensTestEnvChain.rpcUrls.default.http[0]; -const rpcUrl = process.env.DEVNET_RPC_URL ?? defaultRpcUrl; - -async function main() { - console.log(`[seed:devnet] Seeding devnet at ${rpcUrl}...`); - await seedDevnet(rpcUrl); - console.log("[seed:devnet] Done"); -} - -main().catch((error: unknown) => { - console.error("[seed:devnet] Failed:", error); - process.exit(1); -}); From 7c80a612b10b5ed775e129cbea55278deafa274d Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 27 May 2026 13:31:48 +0300 Subject: [PATCH 15/30] update resolution api --- .changeset/omnigraph-resolution-api.md | 4 +- .../multichain-primary-name-resolution.ts | 43 ++- .../src/lib/resolution/reverse-resolution.ts | 53 ++- .../lib/resolution/chain-coin-type.ts | 48 +++ .../lib/resolution/primary-name-input.ts | 40 +++ .../resolve-primary-name-records.ts | 99 ++++++ .../schema/account.integration.test.ts | 232 +++++++++++-- .../src/omnigraph-api/schema/account.ts | 83 +++-- .../schema/domain.integration.test.ts | 44 +++ .../ensapi/src/omnigraph-api/schema/domain.ts | 13 +- .../src/omnigraph-api/schema/resolution.ts | 306 +++++++++++++++++- .../react/omnigraph/_lib/cache-exchange.ts | 10 +- .../src/omnigraph-api/example-queries.ts | 45 ++- .../src/omnigraph/generated/schema.graphql | 179 +++++++++- 14 files changed, 1104 insertions(+), 95 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md index 72c6e44efc..fd26b7490a 100644 --- a/.changeset/omnigraph-resolution-api.md +++ b/.changeset/omnigraph-resolution-api.md @@ -2,4 +2,6 @@ "ensapi": patch --- -**Omnigraph**: add live ENS resolution fields — `Domain.records` for forward resolution (texts, addresses, contenthash, pubkey, ABI, interfaces, and related record types) and `Account.primaryNames` for ENSIP-19 multichain primary names. Record types to resolve are selected via the GraphQL field selection on `records`; both fields accept an optional `disableAcceleration` argument. +**Omnigraph (breaking)**: replace `Account.primaryNames(chainIds:)` and `PrimaryNameByChain` with `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(by: PrimaryNamesByInput)`. Primary name lookups now accept `coinType`/`coinTypes` or `chain`/`chains` via `@oneOf` inputs; `ENSIP19Chain` covers ENSIP-19 supported chains only. `PrimaryNameRecord` exposes `coinType`, `chain`, `name`, wired `records` (chained forward resolution), and a preview `profile` field. + +**Omnigraph (additive)**: add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileName`, `ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null. `Domain.records` is unchanged. diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index dc433dccbd..9d2017adad 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -1,20 +1,22 @@ import { trace } from "@opentelemetry/api"; +import { type Address, type CoinType, evmChainIdToCoinType } from "enssdk"; import { mainnet } from "viem/chains"; import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { type MultichainPrimaryNameResolutionArgs, type MultichainPrimaryNameResolutionResult, + type ReverseResolutionResult, uniq, } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { resolveReverse } from "@/lib/resolution/reverse-resolution"; +import { resolveReverse, resolveReverseByCoinType } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); -const getENSIP19SupportedChainIds = () => { +export const getENSIP19SupportedChainIds = () => { return uniq([ // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, // regardless of the current namespace @@ -34,6 +36,43 @@ const getENSIP19SupportedChainIds = () => { ]); }; +/** Coin types corresponding to {@link getENSIP19SupportedChainIds} in the current namespace. */ +export const getENSIP19SupportedCoinTypes = (): CoinType[] => + uniq(getENSIP19SupportedChainIds().map(evmChainIdToCoinType)); + +export type MultichainPrimaryNameByCoinTypeResolutionResult = Partial< + Record +>; + +type PrimaryNameResolutionOptions = Parameters[2]; + +/** + * Batch-resolves an address' primary name for each requested coin type. + * + * @see https://docs.ens.domains/ensip/19 + */ +export async function resolvePrimaryNamesByCoinTypes( + address: Address, + coinTypes: CoinType[], + options: PrimaryNameResolutionOptions, +): Promise { + const names = await withActiveSpanAsync( + tracer, + "resolvePrimaryNamesByCoinTypes", + { address }, + () => + Promise.all( + coinTypes.map((coinType) => resolveReverseByCoinType(address, coinType, options)), + ), + ); + + return coinTypes.reduce((memo, coinType, i) => { + // biome-ignore lint/style/noNonNullAssertion: names[i] guaranteed to be defined + memo[coinType] = names[i]!; + return memo; + }, {} as MultichainPrimaryNameByCoinTypeResolutionResult); +} + /** * Implements batch resolution of an address' Primary Name across the provided `chainIds`. * diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index 677d16188f..cf7468dfa0 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -1,10 +1,16 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; -import { coinTypeReverseLabel, evmChainIdToCoinType, reverseName } from "enssdk"; +import { + type Address, + type ChainId, + type CoinType, + coinTypeReverseLabel, + evmChainIdToCoinType, + reverseName, +} from "enssdk"; import { isAddress, isAddressEqual } from "viem"; import { type ResolverRecordsSelection, - type ReverseResolutionArgs, ReverseResolutionProtocolStep, type ReverseResolutionResult, TraceableENSProtocol, @@ -24,23 +30,24 @@ export const REVERSE_RESOLUTION_SELECTION = { const tracer = trace.getTracer("reverse-resolution"); +type ReverseResolutionOptions = Parameters[2]; + /** - * Implements ENS Reverse Resolution, including support for ENSIP-19 L2 Primary Names. + * Implements ENS Reverse Resolution for a specific coin type, including ENSIP-19 L2 Primary Names. * * @see https://docs.ens.domains/ensip/19/#algorithm * - * The DEFAULT_EVM_CHAIN_ID (0) is a valid chainId in this context. * * @param address the adddress whose Primary Name to resolve - * @param chainId the chainId within which to resolve the address' Primary Name + * @param coinType the coinType within which to resolve the address' Primary Name * @param options Optional settings * @param options.accelerate Whether to accelerate resolution (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) */ -export async function resolveReverse( - address: ReverseResolutionArgs["address"], - chainId: ReverseResolutionArgs["chainId"], - options: Parameters[2], +export async function resolveReverseByCoinType( + address: Address, + coinType: CoinType, + options: ReverseResolutionOptions, ): Promise { const { accelerate = true } = options; @@ -48,13 +55,13 @@ export async function resolveReverse( return withProtocolStep( TraceableENSProtocol.ReverseResolution, ReverseResolutionProtocolStep.Operation, - { address, chainId, accelerate }, + { address, coinType, accelerate }, (protocolTracingSpan) => // trace for internal metrics withActiveSpanAsync( tracer, - `resolveReverse(${address}, chainId: ${chainId})`, - { address, chainId, accelerate }, + `resolveReverseByCoinType(${address}, coinType: ${coinType})`, + { address, coinType, accelerate }, async (span) => { ///////////////////////////////////////////////////////// // Reverse Resolution @@ -62,7 +69,6 @@ export async function resolveReverse( ///////////////////////////////////////////////////////// // Steps 1-3 — Resolve coinType-specific name record - const coinType = evmChainIdToCoinType(chainId); const _reverseName = reverseName(address, coinType); const { name } = await withProtocolStep( TraceableENSProtocol.ReverseResolution, @@ -173,3 +179,24 @@ export async function resolveReverse( ), ); } + +/** + * Implements ENS Reverse Resolution, including support for ENSIP-19 L2 Primary Names. + * + * @see https://docs.ens.domains/ensip/19/#algorithm + * + * The DEFAULT_EVM_CHAIN_ID (0) is a valid chainId in this context. + * + * @param address the adddress whose Primary Name to resolve + * @param chainId the chainId within which to resolve the address' Primary Name + * @param options Optional settings + * @param options.accelerate Whether to accelerate resolution (default: true) + * @param options.canAccelerate Whether acceleration is currently possible (default: false) + */ +export async function resolveReverse( + address: Address, + chainId: ChainId, + options: ReverseResolutionOptions, +): Promise { + return resolveReverseByCoinType(address, evmChainIdToCoinType(chainId), options); +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts new file mode 100644 index 0000000000..c58628607b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -0,0 +1,48 @@ +import { + type ChainId, + type CoinType, + coinTypeToEvmChainId, + ETH_COIN_TYPE, + evmChainIdToCoinType, +} from "enssdk"; +import { arbitrum, base, linea, optimism, scroll } from "viem/chains"; + +/** GraphQL `ENSIP19Chain` enum values — chains that can have an ENSIP-19 primary name. */ +export const ENSIP19_CHAIN_VALUES = [ + "ETHEREUM", + "BASE", + "OPTIMISM", + "ARBITRUM", + "LINEA", + "SCROLL", +] as const; + +export type ENSIP19ChainValue = (typeof ENSIP19_CHAIN_VALUES)[number]; + +const ENSIP19_CHAIN_TO_COIN_TYPE: Record = { + ETHEREUM: ETH_COIN_TYPE, + BASE: evmChainIdToCoinType(base.id), + OPTIMISM: evmChainIdToCoinType(optimism.id), + ARBITRUM: evmChainIdToCoinType(arbitrum.id), + LINEA: evmChainIdToCoinType(linea.id), + SCROLL: evmChainIdToCoinType(scroll.id), +}; + +/** Maps an `ENSIP19Chain` enum value to its canonical ENSIP-9 coin type. */ +export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => + ENSIP19_CHAIN_TO_COIN_TYPE[chain]; + +/** Maps a coin type to an `ENSIP19Chain` enum value, or null when not ENSIP-19 supported. */ +export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => { + for (const chain of ENSIP19_CHAIN_VALUES) { + if (ENSIP19_CHAIN_TO_COIN_TYPE[chain] === coinType) return chain; + } + return null; +}; + +/** Maps an `ENSIP19Chain` enum value to the EVM chain id used for reverse resolution. */ +export const ensip19ChainToChainId = (chain: ENSIP19ChainValue): ChainId => + coinTypeToEvmChainId(ensip19ChainToCoinType(chain)); + +/** Coin types corresponding to all values in the `ENSIP19Chain` enum. */ +export const ALL_ENSIP19_COIN_TYPES: CoinType[] = ENSIP19_CHAIN_VALUES.map(ensip19ChainToCoinType); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts new file mode 100644 index 0000000000..57a302287c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -0,0 +1,40 @@ +import type { CoinType } from "enssdk"; + +import { + ALL_ENSIP19_COIN_TYPES, + coinTypeToEnsip19Chain, + type ENSIP19ChainValue, + ensip19ChainToCoinType, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; + +export type PrimaryNameByInput = { + coinType?: CoinType | null; + chain?: ENSIP19ChainValue | null; +}; + +export type PrimaryNamesByInput = { + coinTypes?: CoinType[] | null; + chains?: ENSIP19ChainValue[] | null; +}; + +/** Normalizes a singular `PrimaryNameByInput` to a coin type. */ +export const normalizePrimaryNameByInput = (by: PrimaryNameByInput): CoinType => { + if (by.coinType != null) return by.coinType; + if (by.chain != null) return ensip19ChainToCoinType(by.chain); + throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); +}; + +/** + * Normalizes a plural `PrimaryNamesByInput` to an ordered coin-type list. + * When `by` is omitted, returns all ENSIP-19 enum coin types. + */ +export const normalizePrimaryNamesByInput = (by?: PrimaryNamesByInput | null): CoinType[] => { + if (!by) return [...ALL_ENSIP19_COIN_TYPES]; + if (by.coinTypes != null) return by.coinTypes; + if (by.chains != null) return by.chains.map(ensip19ChainToCoinType); + throw new Error("PrimaryNamesByInput must specify exactly one of coinTypes or chains."); +}; + +/** Projects a coin type to its ENSIP19Chain enum value, if applicable. */ +export const projectCoinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => + coinTypeToEnsip19Chain(coinType); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts new file mode 100644 index 0000000000..98ff3bae2d --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -0,0 +1,99 @@ +import type { Address, CoinType, InterpretedName } from "enssdk"; + +import { + getENSIP19SupportedCoinTypes, + type MultichainPrimaryNameByCoinTypeResolutionResult, + resolvePrimaryNamesByCoinTypes, +} from "@/lib/resolution/multichain-primary-name-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; +import { coinTypeToEnsip19Chain } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/resolution"; + +type PrimaryNameResolutionOptions = { + disableAcceleration: boolean; + canAccelerate: boolean; +}; + +const toResolutionOptions = (options: PrimaryNameResolutionOptions) => ({ + accelerate: !options.disableAcceleration, + canAccelerate: options.canAccelerate, +}); + +const toPrimaryNameRecord = ( + address: Address, + coinType: CoinType, + name: InterpretedName | null, + options: PrimaryNameResolutionOptions, +): PrimaryNameRecordModel => ({ + address, + coinType, + chain: coinTypeToEnsip19Chain(coinType), + name, + disableAcceleration: options.disableAcceleration, + canAccelerate: options.canAccelerate, +}); + +/** Resolves primary names for the provided coin types, preserving input order. */ +export async function resolvePrimaryNameRecords( + address: Address, + coinTypes: CoinType[], + options: PrimaryNameResolutionOptions, +): Promise { + const supportedCoinTypes = new Set(getENSIP19SupportedCoinTypes()); + const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); + const nonResolvableCoinTypes = coinTypes.filter((coinType) => !supportedCoinTypes.has(coinType)); + + let resolvedByCoinType: MultichainPrimaryNameByCoinTypeResolutionResult = {}; + if (resolvableCoinTypes.length > 0) { + const { result } = await runWithTrace(() => + resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, toResolutionOptions(options)), + ); + resolvedByCoinType = result; + } + + const recordsByCoinType = new Map(); + + for (const coinType of resolvableCoinTypes) { + recordsByCoinType.set( + coinType, + toPrimaryNameRecord( + address, + coinType, + (resolvedByCoinType[coinType] ?? null) as InterpretedName | null, + options, + ), + ); + } + + for (const coinType of nonResolvableCoinTypes) { + recordsByCoinType.set(coinType, toPrimaryNameRecord(address, coinType, null, options)); + } + + return coinTypes.map((coinType) => { + const record = recordsByCoinType.get(coinType); + if (!record) { + throw new Error(`Missing primary name record for coinType ${coinType}.`); + } + return record; + }); +} + +/** Resolves primary names for all ENSIP-19 supported coin types in the current namespace. */ +export async function resolveDefaultPrimaryNameRecords( + address: Address, + options: PrimaryNameResolutionOptions, +): Promise { + const coinTypes = getENSIP19SupportedCoinTypes(); + const { result } = await runWithTrace(() => + resolvePrimaryNamesByCoinTypes(address, coinTypes, toResolutionOptions(options)), + ); + + return coinTypes.map((coinType) => + toPrimaryNameRecord( + address, + coinType, + (result[coinType] ?? null) as InterpretedName | null, + options, + ), + ); +} 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 2b5cf89ba0..0e39e27ad2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -313,18 +313,72 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { }); }); -describe("Account.primaryNames", () => { +describe("Account.primaryName and Account.primaryNames", () => { + type PrimaryNameRecordResult = { + coinType: number; + chain: string | null; + name: string | null; + records?: { addresses: Array<{ coinType: number; address: string | null }> } | null; + profile?: { + name: { normalized: string | null } | null; + addresses: { ethereum: string | null } | null; + }; + }; + + type AccountPrimaryNameResult = { + account: { + primaryName: PrimaryNameRecordResult; + }; + }; + type AccountPrimaryNamesResult = { account: { - primaryNames: Array<{ chainId: number; name: string | null }>; + primaryNames: PrimaryNameRecordResult[]; }; }; - const AccountPrimaryNames = gql` - query AccountPrimaryNames($address: Address!, $chainIds: [ChainId!]) { + const AccountPrimaryNameByCoinType = gql` + query AccountPrimaryNameByCoinType($address: Address!, $coinType: CoinType!) { + account(by: { address: $address }) { + primaryName(by: { coinType: $coinType }) { + coinType + chain + name + } + } + } + `; + + const AccountPrimaryNameByChain = gql` + query AccountPrimaryNameByChain($address: Address!) { + account(by: { address: $address }) { + primaryName(by: { chain: ETHEREUM }) { + coinType + chain + name + } + } + } + `; + + const AccountPrimaryNamesByCoinTypes = gql` + query AccountPrimaryNamesByCoinTypes($address: Address!, $coinTypes: [CoinType!]!) { + account(by: { address: $address }) { + primaryNames(by: { coinTypes: $coinTypes }) { + coinType + chain + name + } + } + } + `; + + const AccountPrimaryNamesByChains = gql` + query AccountPrimaryNamesByChains($address: Address!) { account(by: { address: $address }) { - primaryNames(chainIds: $chainIds) { - chainId + primaryNames(by: { chains: [ETHEREUM, BASE] }) { + coinType + chain name } } @@ -335,56 +389,188 @@ describe("Account.primaryNames", () => { query AccountPrimaryNamesAllChains($address: Address!) { account(by: { address: $address }) { primaryNames { - chainId + coinType + chain + name + } + } + } + `; + + const AccountPrimaryNameNonEnsip19 = gql` + query AccountPrimaryNameNonEnsip19($address: Address!) { + account(by: { address: $address }) { + primaryName(by: { coinType: 0 }) { + coinType + chain name + records { + addresses(coinTypes: [60]) { address } + } + profile { + name { normalized } + addresses { ethereum } + } } } } `; - it("resolves primary name for owner on chain 1", async () => { + const AccountPrimaryNameChainedRecords = gql` + query AccountPrimaryNameChainedRecords($address: Address!) { + account(by: { address: $address }) { + primaryName(by: { coinType: 60 }) { + name + records { + addresses(coinTypes: [60]) { coinType address } + } + } + } + } + `; + + it("resolves primary name by coinType for owner on Ethereum", async () => { await expect( - request(AccountPrimaryNames, { + request(AccountPrimaryNameByCoinType, { address: accounts.owner.address, - chainIds: [1], + coinType: 60, }), - ).resolves.toEqual({ account: { primaryNames: [{ chainId: 1, name: "test.eth" }] } }); + ).resolves.toEqual({ + account: { + primaryName: { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + }, + }); + }); + + it("resolves the same primary name by chain as by coinType", async () => { + await expect( + request(AccountPrimaryNameByChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + primaryName: { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + }, + }); }); it("returns null for user without a primary name", async () => { await expect( - request(AccountPrimaryNames, { + request(AccountPrimaryNameByCoinType, { address: accounts.user.address, - chainIds: [1], + coinType: 60, + }), + ).resolves.toEqual({ + account: { + primaryName: { coinType: 60, chain: "ETHEREUM", name: null }, + }, + }); + }); + + it("resolves primary names for requested coin types", async () => { + await expect( + request(AccountPrimaryNamesByCoinTypes, { + address: accounts.owner.address, + coinTypes: [60, 2147492101], }), - ).resolves.toEqual({ account: { primaryNames: [{ chainId: 1, name: null }] } }); + ).resolves.toMatchObject({ + account: { + primaryNames: [ + { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + { coinType: 2147492101, chain: "BASE", name: null }, + ], + }, + }); }); - it("resolves all ENSIP-19 supported chains when chainIds is omitted from the query", async () => { + it("resolves primary names for requested chains", async () => { + await expect( + request(AccountPrimaryNamesByChains, { + address: accounts.owner.address, + }), + ).resolves.toMatchObject({ + account: { + primaryNames: [ + { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + { coinType: 2147492101, chain: "BASE", name: null }, + ], + }, + }); + }); + + it("resolves all ENSIP-19 supported chains when by is omitted", async () => { await expect( request(AccountPrimaryNamesAllChains, { address: accounts.owner.address, }), ).resolves.toMatchObject({ - account: { primaryNames: expect.arrayContaining([{ chainId: 1, name: "test.eth" }]) }, + account: { + primaryNames: expect.arrayContaining([ + { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + ]), + }, }); }); - it("rejects empty chainIds at GraphQL validation", async () => { + it("returns null name and chain for non-ENSIP-19 coin types", async () => { await expect( - request(AccountPrimaryNames, { + request(AccountPrimaryNameNonEnsip19, { address: accounts.owner.address, - chainIds: [], }), - ).rejects.toThrow(); + ).resolves.toEqual({ + account: { + primaryName: { + coinType: 0, + chain: null, + name: null, + records: null, + profile: { + name: { normalized: null }, + addresses: { ethereum: null }, + }, + }, + }, + }); }); - it("rejects chain id 0 at GraphQL validation", async () => { + it("chains forward resolution through primaryName.records", async () => { await expect( - request(AccountPrimaryNames, { + request(AccountPrimaryNameChainedRecords, { address: accounts.owner.address, - chainIds: [0, 1], }), + ).resolves.toMatchObject({ + account: { + primaryName: { + name: "test.eth", + records: { + addresses: [{ coinType: 60, address: accounts.owner.address }], + }, + }, + }, + }); + }); + + it("rejects empty coinTypes at GraphQL validation", async () => { + await expect( + request(AccountPrimaryNamesByCoinTypes, { + address: accounts.owner.address, + coinTypes: [], + }), + ).rejects.toThrow(); + }); + + it("rejects empty chains at GraphQL validation", async () => { + await expect( + request( + gql` + query AccountPrimaryNamesEmptyChains($address: Address!) { + account(by: { address: $address }) { + primaryNames(by: { chains: [] }) { coinType } + } + } + `, + { address: accounts.owner.address }, + ), ).rejects.toThrow(); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index da4762c740..e5b69289ca 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,16 +1,22 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address, ChainId, InterpretedName } from "enssdk"; +import type { Address } from "enssdk"; import di from "@/di"; -import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; -import { runWithTrace } from "@/lib/tracing/tracing-api"; 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 { + normalizePrimaryNameByInput, + normalizePrimaryNamesByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { + resolveDefaultPrimaryNameRecords, + resolvePrimaryNameRecords, +} from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -19,7 +25,11 @@ import { EventRef } from "@/omnigraph-api/schema/event"; import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; -import { PrimaryNameByChainRef } from "@/omnigraph-api/schema/resolution"; +import { + PrimaryNameByInput, + PrimaryNameRecordRef, + PrimaryNamesByInput, +} from "@/omnigraph-api/schema/resolution"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; export const AccountRef = builder.loadableObjectRef("Account", { @@ -62,21 +72,49 @@ AccountRef.implement({ resolve: (parent) => parent.id, }), + /////////////////////// + // Account.primaryName + /////////////////////// + primaryName: t.field({ + description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", + type: PrimaryNameRecordRef, + nullable: false, + args: { + by: t.arg({ + type: PrimaryNameByInput, + required: true, + }), + disableAcceleration: t.arg.boolean({ + required: false, + defaultValue: false, + description: "When true, disables protocol acceleration feature.", + }), + }, + resolve: async (account, { by, disableAcceleration }, context) => { + const coinType = normalizePrimaryNameByInput(by); + const [record] = await resolvePrimaryNameRecords(account.id, [coinType], { + disableAcceleration: disableAcceleration ?? false, + canAccelerate: context.canAccelerate, + }); + // biome-ignore lint/style/noNonNullAssertion: exactly one coin type requested + return record!; + }, + }), + //////////////////////// // Account.primaryNames //////////////////////// primaryNames: t.field({ description: - "ENSIP-19 primary names for this Account. Omit chainIds to resolve all ENSIP-19 supported chains.", - type: [PrimaryNameByChainRef], + "ENSIP-19 primary names for this Account. Omit `by` to resolve all ENSIP-19 supported chains in the current namespace.", + type: [PrimaryNameRecordRef], nullable: false, args: { - chainIds: t.arg({ - type: ["ChainId"], + by: t.arg({ + type: PrimaryNamesByInput, required: false, description: - "Chain ids to resolve primary names for. Omit to resolve all ENSIP-19 supported chains.", - validate: { minLength: 1 }, + "Select coin types or chains to resolve. Omit to resolve all ENSIP-19 supported chains.", }), disableAcceleration: t.arg.boolean({ required: false, @@ -84,21 +122,18 @@ AccountRef.implement({ description: "When true, disables protocol acceleration feature.", }), }, - resolve: async (account, { chainIds, disableAcceleration }, context) => { - const { result } = await runWithTrace(() => - resolvePrimaryNames(account.id, chainIds ?? undefined, { - accelerate: !disableAcceleration, - canAccelerate: context.canAccelerate, - }), - ); + resolve: async (account, { by, disableAcceleration }, context) => { + const options = { + disableAcceleration: disableAcceleration ?? false, + canAccelerate: context.canAccelerate, + }; + + if (!by) { + return resolveDefaultPrimaryNameRecords(account.id, options); + } - // Object.entries returns key/value pairs as [string, string | null], - // but the values are already ChainId / InterpretedName | null, - // so the cast is safe. - return Object.entries(result).map(([chainId, name]) => ({ - chainId: Number(chainId) as ChainId, - name: name as InterpretedName | null, - })); + const coinTypes = normalizePrimaryNamesByInput(by); + return resolvePrimaryNameRecords(account.id, coinTypes, options); }, }), 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 1c1641faa3..e09e69545c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -601,3 +601,47 @@ describe("Domain.records", () => { }); }); }); + +describe("Domain.profile", () => { + type DomainProfileResult = { + domain: { + profile: { + name: { beautified: string | null; normalized: string | null } | null; + description: string | null; + avatar: { url: string | null } | null; + addresses: { ethereum: string | null } | null; + socials: { github: { handle: string | null; url: string | null } | null } | null; + } | null; + }; + }; + + const DomainProfile = gql` + query DomainProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + profile { + name { beautified normalized } + description + avatar { url } + addresses { ethereum } + socials { github { handle url } } + } + } + } + `; + + it("returns the preview null shape for a canonical domain", async () => { + await expect( + request(DomainProfile, { name: "test.eth" }), + ).resolves.toEqual({ + domain: { + profile: { + name: { beautified: null, normalized: null }, + description: null, + avatar: { url: null }, + addresses: { ethereum: null }, + socials: { github: { handle: null, url: null } }, + }, + }, + }); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index f825c522c9..50586f8f16 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -46,7 +46,7 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; -import { ResolvedRecordsRef } from "@/omnigraph-api/schema/resolution"; +import { DomainProfileRef, ResolvedRecordsRef } from "@/omnigraph-api/schema/resolution"; const tracer = trace.getTracer("schema/Domain"); @@ -208,6 +208,17 @@ DomainInterfaceRef.implement({ }, }), + /////////////////// + // Domain.profile + /////////////////// + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical.", + type: DomainProfileRef, + nullable: true, + resolve: (domain) => (domain.canonicalName ? {} : null), + }), + /////////////////////// // Domain.registration /////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 0d5e6ebd77..2781697f9a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,5 +1,5 @@ import type { - ChainId, + Address, CoinType, Hex, InterfaceId, @@ -9,31 +9,235 @@ import type { import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; +import { resolveForward } from "@/lib/resolution/forward-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; +import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/resolution/build-records-selection"; +import { + ENSIP19_CHAIN_VALUES, + type ENSIP19ChainValue, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; + +////////////////// +// ENSIP19Chain +////////////////// +export const ENSIP19Chain = builder.enumType("ENSIP19Chain", { + description: + "ENSIP-19 supported chains that can have a primary name. Non-EVM coin types are intentionally absent.", + values: ENSIP19_CHAIN_VALUES, +}); + +/////////////////////// +// PrimaryName inputs +/////////////////////// +export const PrimaryNameByInput = builder.inputType("PrimaryNameByInput", { + description: + "Select a primary name lookup target. Exactly one of `coinType` or `chain` must be provided.", + isOneOf: true, + fields: (t) => ({ + coinType: t.field({ + type: "CoinType", + description: "The ENSIP-9 coin type to resolve the primary name for.", + }), + chain: t.field({ + type: ENSIP19Chain, + description: "An ENSIP-19 supported chain to resolve the primary name for.", + }), + }), +}); + +export const PrimaryNamesByInput = builder.inputType("PrimaryNamesByInput", { + description: + "Select primary name lookup targets. Exactly one of `coinTypes` or `chains` must be provided.", + isOneOf: true, + fields: (t) => ({ + coinTypes: t.field({ + type: ["CoinType"], + description: "Coin types to resolve primary names for.", + validate: { minLength: 1 }, + }), + chains: t.field({ + type: [ENSIP19Chain], + description: "ENSIP-19 supported chains to resolve primary names for.", + validate: { minLength: 1 }, + }), + }), +}); ////////////////////// -// PrimaryNameByChain +// DomainProfile (preview — types only, no resolution wired yet) ////////////////////// -export const PrimaryNameByChainRef = builder.objectRef<{ - chainId: ChainId; - name: InterpretedName | null; -}>("PrimaryNameByChain"); +export type DomainProfileModel = Record; + +export const ProfileSocialAccountRef = + builder.objectRef("ProfileSocialAccount"); -PrimaryNameByChainRef.implement({ - description: "An ENSIP-19 primary name for an Account on a specific chain.", +ProfileSocialAccountRef.implement({ + description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", fields: (t) => ({ - chainId: t.field({ - description: "The chain on which the primary name was resolved.", - type: "ChainId", - nullable: false, - resolve: (r) => r.chainId, + handle: t.string({ + description: "The social handle, or null when unset.", + nullable: true, + resolve: () => null, + }), + url: t.string({ + description: "The social profile URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); + +ProfileSocialsRef.implement({ + description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", + fields: (t) => ({ + github: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + telegram: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + twitter: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + }), +}); + +export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); + +ProfileAddressesRef.implement({ + description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", + fields: (t) => ({ + ethereum: t.field({ + description: "The interpreted Ethereum address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + base: t.field({ + description: "The interpreted Base address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + bitcoin: t.string({ + description: "The interpreted Bitcoin address, or null when unset.", + nullable: true, + resolve: () => null, + }), + solana: t.string({ + description: "The interpreted Solana address, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileNameRef = builder.objectRef("ProfileName"); + +ProfileNameRef.implement({ + description: "PREVIEW: Interpreted name metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + beautified: t.string({ + description: "The beautified display form of the name, or null when unset.", + nullable: true, + resolve: () => null, + }), + normalized: t.string({ + description: "The normalized form of the name, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); + +ProfileAvatarRef.implement({ + description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved avatar URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileBannerRef = builder.objectRef("ProfileBanner"); + +ProfileBannerRef.implement({ + description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved banner URL, or null when unset.", + nullable: true, + resolve: () => null, }), + }), +}); + +export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); + +ProfileWebsiteRef.implement({ + description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved website URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const DomainProfileRef = builder.objectRef("DomainProfile"); + +DomainProfileRef.implement({ + description: + "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", + fields: (t) => ({ name: t.field({ - description: - "The validated primary name for this Account on this chain, or null if none is set.", - type: "InterpretedName", + type: ProfileNameRef, nullable: true, - resolve: (r) => r.name, + resolve: () => ({}), + }), + avatar: t.field({ + type: ProfileAvatarRef, + nullable: true, + resolve: () => ({}), + }), + banner: t.field({ + type: ProfileBannerRef, + nullable: true, + resolve: () => ({}), + }), + website: t.field({ + type: ProfileWebsiteRef, + nullable: true, + resolve: () => ({}), + }), + description: t.string({ + description: "The profile description, or null when unset.", + nullable: true, + resolve: () => null, + }), + addresses: t.field({ + type: ProfileAddressesRef, + nullable: true, + resolve: () => ({}), + }), + socials: t.field({ + type: ProfileSocialsRef, + nullable: true, + resolve: () => ({}), }), }), }); @@ -263,3 +467,71 @@ ResolvedRecordsRef.implement({ }), }), }); + +////////////////////// +// PrimaryNameRecord +////////////////////// +export type PrimaryNameRecordModel = { + address: Address; + coinType: CoinType; + chain: ENSIP19ChainValue | null; + name: InterpretedName | null; + disableAcceleration: boolean; + canAccelerate: boolean; +}; + +export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); + +PrimaryNameRecordRef.implement({ + description: "An ENSIP-19 primary name for an Account on a specific coin type.", + fields: (t) => ({ + coinType: t.field({ + description: "The canonical ENSIP-9 coin type for this primary name lookup.", + type: "CoinType", + nullable: false, + resolve: (r) => r.coinType, + }), + chain: t.field({ + description: + "The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`.", + type: ENSIP19Chain, + nullable: true, + resolve: (r) => r.chain, + }), + name: t.field({ + description: + "The validated primary name for this Account on this coin type, or null if none is set.", + type: "InterpretedName", + nullable: true, + resolve: (r) => r.name, + }), + records: t.field({ + description: + "Forward-resolve ENS records for the validated primary name. Null when `name` is null.", + type: ResolvedRecordsRef, + nullable: true, + tracing: true, + resolve: async (parent, _args, context, info) => { + const name = parent.name; + if (!name) return null; + + const recordsSelection = buildRecordsSelectionFromResolveInfo(info); + const { result } = await runWithTrace(() => + resolveForward(name, recordsSelection, { + accelerate: !parent.disableAcceleration, + canAccelerate: context.canAccelerate, + }), + ); + + return result as ResolverRecordsResponseBase; + }, + }), + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved.", + type: DomainProfileRef, + nullable: false, + resolve: () => ({}), + }), + }), +}); diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index d144aa4229..33f408e0d2 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -43,7 +43,15 @@ export const omnigraphCacheExchange = cacheExchange({ CanonicalName: EMBEDDED_DATA, DomainCanonical: EMBEDDED_DATA, DomainResolver: EMBEDDED_DATA, - PrimaryNameByChain: EMBEDDED_DATA, + PrimaryNameRecord: EMBEDDED_DATA, + DomainProfile: EMBEDDED_DATA, + ProfileName: EMBEDDED_DATA, + ProfileAvatar: EMBEDDED_DATA, + ProfileBanner: EMBEDDED_DATA, + ProfileWebsite: EMBEDDED_DATA, + ProfileAddresses: EMBEDDED_DATA, + ProfileSocials: EMBEDDED_DATA, + ProfileSocialAccount: EMBEDDED_DATA, ResolvedAbiRecord: EMBEDDED_DATA, ResolvedAddressRecord: EMBEDDED_DATA, ResolvedInterfaceRecord: EMBEDDED_DATA, diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 4de373c338..3a321e1531 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -221,7 +221,7 @@ query DomainRecords( name: DEVNET_NAME_WITH_OWNED_RESOLVER, }, [ENSNamespaceIds.SepoliaV2]: { - name: SEPOLIA_V2_NAME_WITH_OWNED_RESOLVER, + name: SEPOLIA_V2_NAME, }, }, }, @@ -338,16 +338,53 @@ query AccountDomains( query AccountPrimaryNames($address: Address!) { account(by: { address: $address }) { address - primaryNames { - chainId + primaryNames(by: { chains: [ETHEREUM, BASE] }) { + coinType + chain name + records { + addresses(coinTypes: [60]) { + coinType + address + } + } } } }`, variables: { default: { address: VITALIK_ADDRESS }, [ENSNamespaceIds.EnsTestEnv]: { address: accounts.owner.address }, - [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ADDRESS_WITH_LOT_OF_NAMES }, + [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ACCOUNT }, + }, + }, + + ////////////////// + // Domain Profile + ////////////////// + { + id: "domain-profile", + query: ` +query DomainProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + profile { + name { beautified normalized } + description + avatar { url } + banner { url } + website { url } + addresses { ethereum base bitcoin solana } + socials { + github { handle url } + telegram { handle url } + twitter { handle url } + } + } + } +}`, + variables: { + default: { name: "vitalik.eth" }, + [ENSNamespaceIds.EnsTestEnv]: { name: "test.eth" }, + [ENSNamespaceIds.SepoliaV2]: { name: "test.eth" }, }, }, diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index d2e081100f..bcc3c052a7 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -20,17 +20,27 @@ type Account { permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection """ - ENSIP-19 primary names for this Account. Omit chainIds to resolve all ENSIP-19 supported chains. + The ENSIP-19 primary name for this Account on a specific coin type or chain. + """ + primaryName( + by: PrimaryNameByInput! + + """When true, disables protocol acceleration feature.""" + disableAcceleration: Boolean = false + ): PrimaryNameRecord! + + """ + ENSIP-19 primary names for this Account. Omit `by` to resolve all ENSIP-19 supported chains in the current namespace. """ primaryNames( """ - Chain ids to resolve primary names for. Omit to resolve all ENSIP-19 supported chains. + Select coin types or chains to resolve. Omit to resolve all ENSIP-19 supported chains. """ - chainIds: [ChainId!] + by: PrimaryNamesByInput """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false - ): [PrimaryNameByChain!]! + ): [PrimaryNameRecord!]! """The Permissions on Registries granted to this Account.""" registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection @@ -279,6 +289,11 @@ interface Domain { """ parent: Domain + """ + PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. + """ + profile: DomainProfile + """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ @@ -370,6 +385,21 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } +""" +PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired. +""" +type DomainProfile { + addresses: ProfileAddresses + avatar: ProfileAvatar + banner: ProfileBanner + + """The profile description, or null when unset.""" + description: String + name: ProfileName + socials: ProfileSocials + website: ProfileWebsite +} + type DomainRegistrationsConnection { edges: [DomainRegistrationsConnectionEdge!]! pageInfo: PageInfo! @@ -447,6 +477,18 @@ input DomainsWhereInput { version: ENSProtocolVersion } +""" +ENSIP-19 supported chains that can have a primary name. Non-EVM coin types are intentionally absent. +""" +enum ENSIP19Chain { + ARBITRUM + BASE + ETHEREUM + LINEA + OPTIMISM + SCROLL +} + """An ENS protocol version.""" enum ENSProtocolVersion { ENSv1 @@ -482,6 +524,11 @@ type ENSv1Domain implements Domain { """ parent: Domain + """ + PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. + """ + profile: DomainProfile + """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ @@ -602,6 +649,11 @@ type ENSv2Domain implements Domain { """ permissions(after: String, before: String, first: Int, last: Int, where: DomainPermissionsWhereInput): ENSv2DomainPermissionsConnection + """ + PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. + """ + profile: DomainProfile + """ Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. """ @@ -1114,15 +1166,124 @@ type PermissionsUserEventsConnectionEdge { """PermissionsUserId represents an enssdk#PermissionsUserId.""" scalar PermissionsUserId -"""An ENSIP-19 primary name for an Account on a specific chain.""" -type PrimaryNameByChain { - """The chain on which the primary name was resolved.""" - chainId: ChainId! +""" +Select a primary name lookup target. Exactly one of `coinType` or `chain` must be provided. +""" +input PrimaryNameByInput @oneOf { + """An ENSIP-19 supported chain to resolve the primary name for.""" + chain: ENSIP19Chain + """The ENSIP-9 coin type to resolve the primary name for.""" + coinType: CoinType +} + +"""An ENSIP-19 primary name for an Account on a specific coin type.""" +type PrimaryNameRecord { """ - The validated primary name for this Account on this chain, or null if none is set. + The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`. + """ + chain: ENSIP19Chain + + """The canonical ENSIP-9 coin type for this primary name lookup.""" + coinType: CoinType! + + """ + The validated primary name for this Account on this coin type, or null if none is set. """ name: InterpretedName + + """ + PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved. + """ + profile: DomainProfile! + + """ + Forward-resolve ENS records for the validated primary name. Null when `name` is null. + """ + records: ResolvedRecords +} + +""" +Select primary name lookup targets. Exactly one of `coinTypes` or `chains` must be provided. +""" +input PrimaryNamesByInput @oneOf { + """ENSIP-19 supported chains to resolve primary names for.""" + chains: [ENSIP19Chain!] + + """Coin types to resolve primary names for.""" + coinTypes: [CoinType!] +} + +""" +PREVIEW: Interpreted address records on a Domain profile. Not yet resolved. +""" +type ProfileAddresses { + """The interpreted Base address, or null when unset.""" + base: Address + + """The interpreted Bitcoin address, or null when unset.""" + bitcoin: String + + """The interpreted Ethereum address, or null when unset.""" + ethereum: Address + + """The interpreted Solana address, or null when unset.""" + solana: String +} + +""" +PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved. +""" +type ProfileAvatar { + """The resolved avatar URL, or null when unset.""" + url: String +} + +""" +PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved. +""" +type ProfileBanner { + """The resolved banner URL, or null when unset.""" + url: String +} + +""" +PREVIEW: Interpreted name metadata on a Domain profile. Not yet resolved. +""" +type ProfileName { + """The beautified display form of the name, or null when unset.""" + beautified: String + + """The normalized form of the name, or null when unset.""" + normalized: String +} + +""" +PREVIEW: An interpreted social account on a Domain profile. Not yet resolved. +""" +type ProfileSocialAccount { + """The social handle, or null when unset.""" + handle: String + + """The social profile URL, or null when unset.""" + url: String +} + +""" +PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved. +""" +type ProfileSocials { + github: ProfileSocialAccount + telegram: ProfileSocialAccount + twitter: ProfileSocialAccount +} + +""" +PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved. +""" +type ProfileWebsite { + """The resolved website URL, or null when unset.""" + url: String } type Query { From caa8f763c46bd55de7ba2bdbc38667a13444e2e6 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 27 May 2026 16:51:42 +0300 Subject: [PATCH 16/30] fix PR comments --- .changeset/omnigraph-resolution-api.md | 6 +- .../handlers/api/omnigraph/omnigraph-api.ts | 2 +- .../lib/resolution/chain-coin-type.ts | 3 - .../lib/resolution/primary-name-input.ts | 9 +- .../resolve-primary-name-records.ts | 20 - .../schema/account.integration.test.ts | 63 +-- .../src/omnigraph-api/schema/account.ts | 25 +- .../omnigraph-api/schema/canonical-name.ts | 30 +- .../omnigraph-api/schema/domain-canonical.ts | 10 +- .../schema/domain.integration.test.ts | 3 - .../src/omnigraph-api/schema/resolution.ts | 50 +- .../react/omnigraph/_lib/cache-exchange.ts | 3 +- .../src/omnigraph-api/example-queries.ts | 3 +- .../src/omnigraph/generated/introspection.ts | 437 +++++++++++++++++- .../src/omnigraph/generated/schema.graphql | 48 +- 15 files changed, 514 insertions(+), 198 deletions(-) diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md index fd26b7490a..f5e6173d91 100644 --- a/.changeset/omnigraph-resolution-api.md +++ b/.changeset/omnigraph-resolution-api.md @@ -2,6 +2,8 @@ "ensapi": patch --- -**Omnigraph (breaking)**: replace `Account.primaryNames(chainIds:)` and `PrimaryNameByChain` with `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(by: PrimaryNamesByInput)`. Primary name lookups now accept `coinType`/`coinTypes` or `chain`/`chains` via `@oneOf` inputs; `ENSIP19Chain` covers ENSIP-19 supported chains only. `PrimaryNameRecord` exposes `coinType`, `chain`, `name`, wired `records` (chained forward resolution), and a preview `profile` field. +Changes related to **Omnigraph**: -**Omnigraph (additive)**: add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileName`, `ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null. `Domain.records` is unchanged. +- add `Domain.records` with raw records resolution (`ResolvedRawTextRecord` for text record values) +- add `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(by: PrimaryNamesByInput!)`. Primary name lookups accept `coinType`/`coinTypes` or `chain`/`chains` via `@oneOf` inputs; `PrimaryNameRecord.name` is a `CanonicalName` with `interpreted` and `beautified` +- add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null diff --git a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index 1efa90f480..3713494f58 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -11,7 +11,7 @@ import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware" import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; -const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; +const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 600; // 10 minutes const app = createApp({ middlewares: [ diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index c58628607b..89c09bd6d4 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -43,6 +43,3 @@ export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | /** Maps an `ENSIP19Chain` enum value to the EVM chain id used for reverse resolution. */ export const ensip19ChainToChainId = (chain: ENSIP19ChainValue): ChainId => coinTypeToEvmChainId(ensip19ChainToCoinType(chain)); - -/** Coin types corresponding to all values in the `ENSIP19Chain` enum. */ -export const ALL_ENSIP19_COIN_TYPES: CoinType[] = ENSIP19_CHAIN_VALUES.map(ensip19ChainToCoinType); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts index 57a302287c..2f819ab0f0 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -1,7 +1,6 @@ import type { CoinType } from "enssdk"; import { - ALL_ENSIP19_COIN_TYPES, coinTypeToEnsip19Chain, type ENSIP19ChainValue, ensip19ChainToCoinType, @@ -24,12 +23,8 @@ export const normalizePrimaryNameByInput = (by: PrimaryNameByInput): CoinType => throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); }; -/** - * Normalizes a plural `PrimaryNamesByInput` to an ordered coin-type list. - * When `by` is omitted, returns all ENSIP-19 enum coin types. - */ -export const normalizePrimaryNamesByInput = (by?: PrimaryNamesByInput | null): CoinType[] => { - if (!by) return [...ALL_ENSIP19_COIN_TYPES]; +/** Normalizes a plural `PrimaryNamesByInput` to an ordered coin-type list. */ +export const normalizePrimaryNamesByInput = (by: PrimaryNamesByInput): CoinType[] => { if (by.coinTypes != null) return by.coinTypes; if (by.chains != null) return by.chains.map(ensip19ChainToCoinType); throw new Error("PrimaryNamesByInput must specify exactly one of coinTypes or chains."); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index 98ff3bae2d..9055f2b144 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -77,23 +77,3 @@ export async function resolvePrimaryNameRecords( return record; }); } - -/** Resolves primary names for all ENSIP-19 supported coin types in the current namespace. */ -export async function resolveDefaultPrimaryNameRecords( - address: Address, - options: PrimaryNameResolutionOptions, -): Promise { - const coinTypes = getENSIP19SupportedCoinTypes(); - const { result } = await runWithTrace(() => - resolvePrimaryNamesByCoinTypes(address, coinTypes, toResolutionOptions(options)), - ); - - return coinTypes.map((coinType) => - toPrimaryNameRecord( - address, - coinType, - (result[coinType] ?? null) as InterpretedName | null, - options, - ), - ); -} 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 0e39e27ad2..8b89ebf09c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -314,17 +314,26 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { }); describe("Account.primaryName and Account.primaryNames", () => { + type CanonicalNameResult = { + interpreted: string; + beautified: string; + } | null; + type PrimaryNameRecordResult = { coinType: number; chain: string | null; - name: string | null; + name: CanonicalNameResult; records?: { addresses: Array<{ coinType: number; address: string | null }> } | null; profile?: { - name: { normalized: string | null } | null; addresses: { ethereum: string | null } | null; }; }; + const TEST_ETH_NAME: CanonicalNameResult = { + interpreted: "test.eth", + beautified: "test.eth", + }; + type AccountPrimaryNameResult = { account: { primaryName: PrimaryNameRecordResult; @@ -343,7 +352,7 @@ describe("Account.primaryName and Account.primaryNames", () => { primaryName(by: { coinType: $coinType }) { coinType chain - name + name { interpreted beautified } } } } @@ -355,7 +364,7 @@ describe("Account.primaryName and Account.primaryNames", () => { primaryName(by: { chain: ETHEREUM }) { coinType chain - name + name { interpreted beautified } } } } @@ -367,7 +376,7 @@ describe("Account.primaryName and Account.primaryNames", () => { primaryNames(by: { coinTypes: $coinTypes }) { coinType chain - name + name { interpreted beautified } } } } @@ -379,19 +388,7 @@ describe("Account.primaryName and Account.primaryNames", () => { primaryNames(by: { chains: [ETHEREUM, BASE] }) { coinType chain - name - } - } - } - `; - - const AccountPrimaryNamesAllChains = gql` - query AccountPrimaryNamesAllChains($address: Address!) { - account(by: { address: $address }) { - primaryNames { - coinType - chain - name + name { interpreted beautified } } } } @@ -403,12 +400,11 @@ describe("Account.primaryName and Account.primaryNames", () => { primaryName(by: { coinType: 0 }) { coinType chain - name + name { interpreted beautified } records { addresses(coinTypes: [60]) { address } } profile { - name { normalized } addresses { ethereum } } } @@ -420,7 +416,7 @@ describe("Account.primaryName and Account.primaryNames", () => { query AccountPrimaryNameChainedRecords($address: Address!) { account(by: { address: $address }) { primaryName(by: { coinType: 60 }) { - name + name { interpreted beautified } records { addresses(coinTypes: [60]) { coinType address } } @@ -437,7 +433,7 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }); }); @@ -449,7 +445,7 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }); }); @@ -476,7 +472,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toMatchObject({ account: { primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, { coinType: 2147492101, chain: "BASE", name: null }, ], }, @@ -491,27 +487,13 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toMatchObject({ account: { primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, + { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, { coinType: 2147492101, chain: "BASE", name: null }, ], }, }); }); - it("resolves all ENSIP-19 supported chains when by is omitted", async () => { - await expect( - request(AccountPrimaryNamesAllChains, { - address: accounts.owner.address, - }), - ).resolves.toMatchObject({ - account: { - primaryNames: expect.arrayContaining([ - { coinType: 60, chain: "ETHEREUM", name: "test.eth" }, - ]), - }, - }); - }); - it("returns null name and chain for non-ENSIP-19 coin types", async () => { await expect( request(AccountPrimaryNameNonEnsip19, { @@ -525,7 +507,6 @@ describe("Account.primaryName and Account.primaryNames", () => { name: null, records: null, profile: { - name: { normalized: null }, addresses: { ethereum: null }, }, }, @@ -541,7 +522,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toMatchObject({ account: { primaryName: { - name: "test.eth", + name: TEST_ETH_NAME, records: { addresses: [{ coinType: 60, address: accounts.owner.address }], }, diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index e5b69289ca..3f6cf98b2e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -13,10 +13,7 @@ import { normalizePrimaryNameByInput, normalizePrimaryNamesByInput, } from "@/omnigraph-api/lib/resolution/primary-name-input"; -import { - resolveDefaultPrimaryNameRecords, - resolvePrimaryNameRecords, -} from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; +import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -105,16 +102,14 @@ AccountRef.implement({ // Account.primaryNames //////////////////////// primaryNames: t.field({ - description: - "ENSIP-19 primary names for this Account. Omit `by` to resolve all ENSIP-19 supported chains in the current namespace.", + description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", type: [PrimaryNameRecordRef], nullable: false, args: { by: t.arg({ type: PrimaryNamesByInput, - required: false, - description: - "Select coin types or chains to resolve. Omit to resolve all ENSIP-19 supported chains.", + required: true, + description: "Select coin types or chains to resolve primary names for.", }), disableAcceleration: t.arg.boolean({ required: false, @@ -123,17 +118,11 @@ AccountRef.implement({ }), }, resolve: async (account, { by, disableAcceleration }, context) => { - const options = { + const coinTypes = normalizePrimaryNamesByInput(by); + return resolvePrimaryNameRecords(account.id, coinTypes, { disableAcceleration: disableAcceleration ?? false, canAccelerate: context.canAccelerate, - }; - - if (!by) { - return resolveDefaultPrimaryNameRecords(account.id, options); - } - - const coinTypes = normalizePrimaryNamesByInput(by); - return resolvePrimaryNameRecords(account.id, coinTypes, options); + }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts index 2bd765cb0f..bc9420e61c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts +++ b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts @@ -1,12 +1,16 @@ -import { beautifyInterpretedName } from "enssdk"; +import { beautifyInterpretedName, type InterpretedName } from "enssdk"; import { builder } from "@/omnigraph-api/builder"; -import type { Domain } from "@/omnigraph-api/schema/domain"; + +/** Parent object for {@link CanonicalNameRef} field resolvers. */ +export type CanonicalNameParent = { + canonicalName: InterpretedName; +}; //////////////////////////////// // CanonicalName //////////////////////////////// -export const CanonicalNameRef = builder.objectRef("CanonicalName"); +export const CanonicalNameRef = builder.objectRef("CanonicalName"); CanonicalNameRef.implement({ description: "A Canonical Name, exposed in each representation we support.", @@ -16,30 +20,14 @@ CanonicalNameRef.implement({ "The Canonical Name as an InterpretedName: each label is either a normalized literal Label or an Encoded LabelHash.", type: "InterpretedName", nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(CanonicalName.interpreted): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return domain.canonicalName; - }, + resolve: (parent) => parent.canonicalName, }), beautified: t.field({ description: "The Canonical Name as a BeautifiedName: the InterpretedName with its normalized labels beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. Encoded LabelHash labels are preserved verbatim. Display-only; use `interpreted` for navigation targets and lookup keys.", type: "BeautifiedName", nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(CanonicalName.beautified): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return beautifyInterpretedName(domain.canonicalName); - }, + resolve: (parent) => beautifyInterpretedName(parent.canonicalName), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index bb93598698..44a92eee1a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -15,7 +15,15 @@ DomainCanonicalRef.implement({ description: "The Canonical Name for this Domain.", type: CanonicalNameRef, nullable: false, - resolve: (domain) => domain, + resolve: (domain) => { + if (!domain.canonicalName) { + throw new Error( + `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, + ); + } + + return { canonicalName: domain.canonicalName }; + }, }), depth: t.field({ description: 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 e09e69545c..e43bb5dc4a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -606,7 +606,6 @@ describe("Domain.profile", () => { type DomainProfileResult = { domain: { profile: { - name: { beautified: string | null; normalized: string | null } | null; description: string | null; avatar: { url: string | null } | null; addresses: { ethereum: string | null } | null; @@ -619,7 +618,6 @@ describe("Domain.profile", () => { query DomainProfile($name: InterpretedName!) { domain(by: { name: $name }) { profile { - name { beautified normalized } description avatar { url } addresses { ethereum } @@ -635,7 +633,6 @@ describe("Domain.profile", () => { ).resolves.toEqual({ domain: { profile: { - name: { beautified: null, normalized: null }, description: null, avatar: { url: null }, addresses: { ethereum: null }, diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 2781697f9a..76fe5f276e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -17,6 +17,7 @@ import { ENSIP19_CHAIN_VALUES, type ENSIP19ChainValue, } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; ////////////////// // ENSIP19Chain @@ -141,24 +142,6 @@ ProfileAddressesRef.implement({ }), }); -export const ProfileNameRef = builder.objectRef("ProfileName"); - -ProfileNameRef.implement({ - description: "PREVIEW: Interpreted name metadata on a Domain profile. Not yet resolved.", - fields: (t) => ({ - beautified: t.string({ - description: "The beautified display form of the name, or null when unset.", - nullable: true, - resolve: () => null, - }), - normalized: t.string({ - description: "The normalized form of the name, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); ProfileAvatarRef.implement({ @@ -204,11 +187,6 @@ DomainProfileRef.implement({ description: "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", fields: (t) => ({ - name: t.field({ - type: ProfileNameRef, - nullable: true, - resolve: () => ({}), - }), avatar: t.field({ type: ProfileAvatarRef, nullable: true, @@ -242,22 +220,24 @@ DomainProfileRef.implement({ }), }); -/////////////////////// -// ResolvedTextRecord -/////////////////////// -export const ResolvedTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( - "ResolvedTextRecord", +////////////////////////// +// ResolvedRawTextRecord +////////////////////////// +export const ResolvedRawTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( + "ResolvedRawTextRecord", ); -ResolvedTextRecordRef.implement({ - description: "A resolved text record for an ENS name.", +ResolvedRawTextRecordRef.implement({ + description: + "A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.", 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.", + description: + "The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.", nullable: true, }), }), @@ -368,7 +348,7 @@ ResolvedRecordsRef.implement({ fields: (t) => ({ reverseName: t.string({ description: - "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set.", + "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", nullable: true, resolve: (r) => r.name ?? null, }), @@ -433,7 +413,7 @@ ResolvedRecordsRef.implement({ }), texts: t.field({ description: "Resolved text records for the requested keys.", - type: [ResolvedTextRecordRef], + type: [ResolvedRawTextRecordRef], nullable: false, args: { keys: t.arg.stringList({ @@ -501,9 +481,9 @@ PrimaryNameRecordRef.implement({ name: t.field({ description: "The validated primary name for this Account on this coin type, or null if none is set.", - type: "InterpretedName", + type: CanonicalNameRef, nullable: true, - resolve: (r) => r.name, + resolve: (r) => (r.name ? { canonicalName: r.name } : null), }), records: t.field({ description: diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 33f408e0d2..0183c03cc1 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -45,7 +45,6 @@ export const omnigraphCacheExchange = cacheExchange({ DomainResolver: EMBEDDED_DATA, PrimaryNameRecord: EMBEDDED_DATA, DomainProfile: EMBEDDED_DATA, - ProfileName: EMBEDDED_DATA, ProfileAvatar: EMBEDDED_DATA, ProfileBanner: EMBEDDED_DATA, ProfileWebsite: EMBEDDED_DATA, @@ -57,7 +56,7 @@ export const omnigraphCacheExchange = cacheExchange({ ResolvedInterfaceRecord: EMBEDDED_DATA, ResolvedPubkeyRecord: EMBEDDED_DATA, ResolvedRecords: EMBEDDED_DATA, - ResolvedTextRecord: EMBEDDED_DATA, + ResolvedRawTextRecord: EMBEDDED_DATA, }, resolvers: mergeResolverMaps( // produce relayPagination() local resolvers for each t.connection in the schema diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 3a321e1531..62885d3cc0 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -341,7 +341,7 @@ query AccountPrimaryNames($address: Address!) { primaryNames(by: { chains: [ETHEREUM, BASE] }) { coinType chain - name + name { interpreted beautified } records { addresses(coinTypes: [60]) { coinType @@ -367,7 +367,6 @@ query AccountPrimaryNames($address: Address!) { query DomainProfile($name: InterpretedName!) { domain(by: { name: $name }) { profile { - name { beautified normalized } description avatar { url } banner { url } diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b31578ca53..1c97a9ae76 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -200,6 +200,37 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "primaryName", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput" + } + } + }, + { + "name": "disableAcceleration", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "false" + } + ], + "isDeprecated": false + }, { "name": "primaryNames", "type": { @@ -210,23 +241,17 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "PrimaryNameByChain" + "name": "PrimaryNameRecord" } } } }, "args": [ { - "name": "chainIds", + "name": "by", "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "ChainId" - } - } + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesByInput" } }, { @@ -1224,6 +1249,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "profile", + "type": { + "kind": "OBJECT", + "name": "DomainProfile" + }, + "args": [], + "isDeprecated": false + }, { "name": "records", "type": { @@ -1594,6 +1628,76 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "DomainProfile", + "fields": [ + { + "name": "addresses", + "type": { + "kind": "OBJECT", + "name": "ProfileAddresses" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "avatar", + "type": { + "kind": "OBJECT", + "name": "ProfileAvatar" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "banner", + "type": { + "kind": "OBJECT", + "name": "ProfileBanner" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "name", + "type": { + "kind": "OBJECT", + "name": "ProfileName" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "socials", + "type": { + "kind": "OBJECT", + "name": "ProfileSocials" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "website", + "type": { + "kind": "OBJECT", + "name": "ProfileWebsite" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "DomainRegistrationsConnection", @@ -1875,6 +1979,36 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "ENUM", + "name": "ENSIP19Chain", + "enumValues": [ + { + "name": "ARBITRUM", + "isDeprecated": false + }, + { + "name": "BASE", + "isDeprecated": false + }, + { + "name": "ETHEREUM", + "isDeprecated": false + }, + { + "name": "LINEA", + "isDeprecated": false + }, + { + "name": "OPTIMISM", + "isDeprecated": false + }, + { + "name": "SCROLL", + "isDeprecated": false + } + ] + }, { "kind": "ENUM", "name": "ENSProtocolVersion", @@ -2001,6 +2135,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "profile", + "type": { + "kind": "OBJECT", + "name": "DomainProfile" + }, + "args": [], + "isDeprecated": false + }, { "name": "records", "type": { @@ -2619,6 +2762,15 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "profile", + "type": { + "kind": "OBJECT", + "name": "DomainProfile" + }, + "args": [], + "isDeprecated": false + }, { "name": "records", "type": { @@ -4670,17 +4822,47 @@ const introspection = { "kind": "SCALAR", "name": "PermissionsUserId" }, + { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput", + "inputFields": [ + { + "name": "chain", + "type": { + "kind": "ENUM", + "name": "ENSIP19Chain" + } + }, + { + "name": "coinType", + "type": { + "kind": "SCALAR", + "name": "CoinType" + } + } + ], + "isOneOf": true + }, { "kind": "OBJECT", - "name": "PrimaryNameByChain", + "name": "PrimaryNameRecord", "fields": [ { - "name": "chainId", + "name": "chain", + "type": { + "kind": "ENUM", + "name": "ENSIP19Chain" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "coinType", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "ChainId" + "name": "CoinType" } }, "args": [], @@ -4694,6 +4876,235 @@ const introspection = { }, "args": [], "isDeprecated": false + }, + { + "name": "profile", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainProfile" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesByInput", + "inputFields": [ + { + "name": "chains", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "ENSIP19Chain" + } + } + } + }, + { + "name": "coinTypes", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } + } + ], + "isOneOf": true + }, + { + "kind": "OBJECT", + "name": "ProfileAddresses", + "fields": [ + { + "name": "base", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "bitcoin", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "ethereum", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "solana", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileAvatar", + "fields": [ + { + "name": "url", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileBanner", + "fields": [ + { + "name": "url", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileName", + "fields": [ + { + "name": "beautified", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "normalized", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileSocialAccount", + "fields": [ + { + "name": "handle", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "url", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileSocials", + "fields": [ + { + "name": "github", + "type": { + "kind": "OBJECT", + "name": "ProfileSocialAccount" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "telegram", + "type": { + "kind": "OBJECT", + "name": "ProfileSocialAccount" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "twitter", + "type": { + "kind": "OBJECT", + "name": "ProfileSocialAccount" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ProfileWebsite", + "fields": [ + { + "name": "url", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false } ], "interfaces": [] diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index bcc3c052a7..9c4c62db41 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -30,13 +30,11 @@ type Account { ): PrimaryNameRecord! """ - ENSIP-19 primary names for this Account. Omit `by` to resolve all ENSIP-19 supported chains in the current namespace. + ENSIP-19 primary names for this Account on the requested coin types or chains. """ primaryNames( - """ - Select coin types or chains to resolve. Omit to resolve all ENSIP-19 supported chains. - """ - by: PrimaryNamesByInput + """Select coin types or chains to resolve primary names for.""" + by: PrimaryNamesByInput! """When true, disables protocol acceleration feature.""" disableAcceleration: Boolean = false @@ -395,7 +393,6 @@ type DomainProfile { """The profile description, or null when unset.""" description: String - name: ProfileName socials: ProfileSocials website: ProfileWebsite } @@ -1190,7 +1187,7 @@ type PrimaryNameRecord { """ The validated primary name for this Account on this coin type, or null if none is set. """ - name: InterpretedName + name: CanonicalName """ PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved. @@ -1247,17 +1244,6 @@ type ProfileBanner { url: String } -""" -PREVIEW: Interpreted name metadata on a Domain profile. Not yet resolved. -""" -type ProfileName { - """The beautified display form of the name, or null when unset.""" - beautified: String - - """The normalized form of the name, or null when unset.""" - normalized: String -} - """ PREVIEW: An interpreted social account on a Domain profile. Not yet resolved. """ @@ -1516,6 +1502,19 @@ type ResolvedPubkeyRecord { y: Hex! } +""" +A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use. +""" +type ResolvedRawTextRecord { + """The text record key.""" + key: String! + + """ + The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use. + """ + value: String +} + """Records resolved for a specific ENS name via the ENS protocol.""" type ResolvedRecords { """ @@ -1550,7 +1549,7 @@ type ResolvedRecords { pubkey: ResolvedPubkeyRecord """ - The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. + The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain. """ reverseName: String @@ -1558,21 +1557,12 @@ type ResolvedRecords { texts( """Text record keys to resolve (e.g. `avatar`, `description`).""" keys: [String!]! - ): [ResolvedTextRecord!]! + ): [ResolvedRawTextRecord!]! """The IVersionableResolver version, or null if not set or unavailable.""" version: BigInt } -"""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 { """ From ab2b84984d23879385a75cb75c60ab4b66d0ca2e Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 27 May 2026 16:52:38 +0300 Subject: [PATCH 17/30] lint + generate --- .../StaticExampleCard.astro | 3 +- .../src/omnigraph/generated/introspection.ts | 103 ++++++------------ 2 files changed, 37 insertions(+), 69 deletions(-) diff --git a/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro index 5ecebe945b..4f7975e642 100644 --- a/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro +++ b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro @@ -40,8 +40,7 @@ const { uid } = Astro.props; if (integrationTabsEl) { integrationTabsEl.addEventListener("click", (e) => { - const btn = - e.target instanceof Element ? e.target.closest("[data-integration-tab]") : null; + const btn = e.target instanceof Element ? e.target.closest("[data-integration-tab]") : null; if (btn && integrationTabsEl.contains(btn)) { activateIntegration(btn.dataset.integrationTab); } diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 1c97a9ae76..6010d52e11 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -250,8 +250,11 @@ const introspection = { { "name": "by", "type": { - "kind": "INPUT_OBJECT", - "name": "PrimaryNamesByInput" + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesByInput" + } } }, { @@ -1668,15 +1671,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "name", - "type": { - "kind": "OBJECT", - "name": "ProfileName" - }, - "args": [], - "isDeprecated": false - }, { "name": "socials", "type": { @@ -4871,8 +4865,8 @@ const introspection = { { "name": "name", "type": { - "kind": "SCALAR", - "name": "InterpretedName" + "kind": "OBJECT", + "name": "CanonicalName" }, "args": [], "isDeprecated": false @@ -5009,31 +5003,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "OBJECT", - "name": "ProfileName", - "fields": [ - { - "name": "beautified", - "type": { - "kind": "SCALAR", - "name": "String" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "normalized", - "type": { - "kind": "SCALAR", - "name": "String" - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "ProfileSocialAccount", @@ -6236,6 +6205,34 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "ResolvedRawTextRecord", + "fields": [ + { + "name": "key", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "value", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "ResolvedRecords", @@ -6376,7 +6373,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "ResolvedTextRecord" + "name": "ResolvedRawTextRecord" } } } @@ -6413,34 +6410,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "OBJECT", - "name": "ResolvedTextRecord", - "fields": [ - { - "name": "key", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "value", - "type": { - "kind": "SCALAR", - "name": "String" - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "Resolver", From 5b0b1d213bda9f016eae4b01031df63e1e9cdad9 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 27 May 2026 18:14:57 +0300 Subject: [PATCH 18/30] default chain id --- .../lib/resolution/chain-coin-type.ts | 10 +++- .../schema/account.integration.test.ts | 58 +++++++++++++++++++ .../src/omnigraph-api/schema/resolution.ts | 2 +- .../src/omnigraph/generated/introspection.ts | 4 ++ .../src/omnigraph/generated/schema.graphql | 3 +- 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index 89c09bd6d4..3f00f0b6ab 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -2,6 +2,8 @@ import { type ChainId, type CoinType, coinTypeToEvmChainId, + DEFAULT_EVM_CHAIN_ID, + DEFAULT_EVM_COIN_TYPE, ETH_COIN_TYPE, evmChainIdToCoinType, } from "enssdk"; @@ -9,6 +11,7 @@ import { arbitrum, base, linea, optimism, scroll } from "viem/chains"; /** GraphQL `ENSIP19Chain` enum values — chains that can have an ENSIP-19 primary name. */ export const ENSIP19_CHAIN_VALUES = [ + "DEFAULT", "ETHEREUM", "BASE", "OPTIMISM", @@ -20,6 +23,7 @@ export const ENSIP19_CHAIN_VALUES = [ export type ENSIP19ChainValue = (typeof ENSIP19_CHAIN_VALUES)[number]; const ENSIP19_CHAIN_TO_COIN_TYPE: Record = { + DEFAULT: DEFAULT_EVM_COIN_TYPE, ETHEREUM: ETH_COIN_TYPE, BASE: evmChainIdToCoinType(base.id), OPTIMISM: evmChainIdToCoinType(optimism.id), @@ -41,5 +45,7 @@ export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | }; /** Maps an `ENSIP19Chain` enum value to the EVM chain id used for reverse resolution. */ -export const ensip19ChainToChainId = (chain: ENSIP19ChainValue): ChainId => - coinTypeToEvmChainId(ensip19ChainToCoinType(chain)); +export const ensip19ChainToChainId = (chain: ENSIP19ChainValue): ChainId => { + if (chain === "DEFAULT") return DEFAULT_EVM_CHAIN_ID; + return coinTypeToEvmChainId(ensip19ChainToCoinType(chain)); +}; 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 8b89ebf09c..790aa6d89d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -370,6 +370,30 @@ describe("Account.primaryName and Account.primaryNames", () => { } `; + const AccountPrimaryNameByDefaultChain = gql` + query AccountPrimaryNameByDefaultChain($address: Address!) { + account(by: { address: $address }) { + primaryName(by: { chain: DEFAULT }) { + coinType + chain + name { interpreted beautified } + } + } + } + `; + + const AccountPrimaryNamesByDefaultChain = gql` + query AccountPrimaryNamesByDefaultChain($address: Address!) { + account(by: { address: $address }) { + primaryNames(by: { chains: [DEFAULT] }) { + coinType + chain + name { interpreted beautified } + } + } + } + `; + const AccountPrimaryNamesByCoinTypes = gql` query AccountPrimaryNamesByCoinTypes($address: Address!, $coinTypes: [CoinType!]!) { account(by: { address: $address }) { @@ -450,6 +474,40 @@ describe("Account.primaryName and Account.primaryNames", () => { }); }); + it("accepts DEFAULT and maps it to the ENSIP-19 default EVM coin type", async () => { + await expect( + request(AccountPrimaryNameByDefaultChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + primaryName: { + coinType: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, + }, + }); + }); + + it("resolves primary names for DEFAULT", async () => { + await expect( + request(AccountPrimaryNamesByDefaultChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + primaryNames: [ + { + coinType: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, + ], + }, + }); + }); + it("returns null for user without a primary name", async () => { await expect( request(AccountPrimaryNameByCoinType, { diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 76fe5f276e..7f7687b857 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -24,7 +24,7 @@ import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; ////////////////// export const ENSIP19Chain = builder.enumType("ENSIP19Chain", { description: - "ENSIP-19 supported chains that can have a primary name. Non-EVM coin types are intentionally absent.", + "ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. Non-EVM coin types are intentionally absent.", values: ENSIP19_CHAIN_VALUES, }); diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 6010d52e11..4507a335df 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1985,6 +1985,10 @@ const introspection = { "name": "BASE", "isDeprecated": false }, + { + "name": "DEFAULT", + "isDeprecated": false + }, { "name": "ETHEREUM", "isDeprecated": false diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 9c4c62db41..0f39d01c2b 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -475,11 +475,12 @@ input DomainsWhereInput { } """ -ENSIP-19 supported chains that can have a primary name. Non-EVM coin types are intentionally absent. +ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. Non-EVM coin types are intentionally absent. """ enum ENSIP19Chain { ARBITRUM BASE + DEFAULT ETHEREUM LINEA OPTIMISM From 4e94c06314773b5e0198474c3c3bae78d9e4e146 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 12:44:31 +0300 Subject: [PATCH 19/30] add resolve { } object and trace to resolve { } --- .changeset/omnigraph-resolution-api.md | 3 +- apps/ensapi/package.json | 1 + apps/ensapi/src/omnigraph-api/builder.ts | 4 + .../account-primary-names-selection.test.ts | 109 ++++ .../account-primary-names-selection.ts | 57 ++ .../lib/resolution/chain-coin-type.ts | 71 ++- .../lib/resolution/primary-name-input.ts | 14 +- .../lib/resolution/records-profile-model.ts | 16 + ...tion.test.ts => records-selection.test.ts} | 62 ++- ...ords-selection.ts => records-selection.ts} | 105 +++- .../resolve-primary-name-records.ts | 60 +-- .../schema/account.integration.test.ts | 195 ++++--- .../src/omnigraph-api/schema/account.ts | 165 ++++-- .../src/omnigraph-api/schema/constants.ts | 8 + .../schema/domain.integration.test.ts | 155 +++--- .../ensapi/src/omnigraph-api/schema/domain.ts | 135 +++-- .../src/omnigraph-api/schema/resolution.ts | 169 ++++-- .../src/omnigraph-api/schema/scalars.ts | 18 + .../react/omnigraph/_lib/cache-exchange.ts | 14 +- .../_lib/records-profile-cache-resolvers.ts | 63 +++ .../src/omnigraph-api/example-queries.ts | 58 +- packages/enssdk/src/lib/types/ens.ts | 5 + packages/enssdk/src/lib/types/shared.ts | 11 + .../src/omnigraph/generated/introspection.ts | 503 +++++++++++------- .../src/omnigraph/generated/schema.graphql | 219 +++++--- packages/enssdk/src/omnigraph/graphql.ts | 4 + pnpm-lock.yaml | 3 + 27 files changed, 1528 insertions(+), 699 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts rename apps/ensapi/src/omnigraph-api/lib/resolution/{build-records-selection.test.ts => records-selection.test.ts} (73%) rename apps/ensapi/src/omnigraph-api/lib/resolution/{build-records-selection.ts => records-selection.ts} (53%) create mode 100644 packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md index f5e6173d91..491fc42894 100644 --- a/.changeset/omnigraph-resolution-api.md +++ b/.changeset/omnigraph-resolution-api.md @@ -5,5 +5,6 @@ Changes related to **Omnigraph**: - add `Domain.records` with raw records resolution (`ResolvedRawTextRecord` for text record values) -- add `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(by: PrimaryNamesByInput!)`. Primary name lookups accept `coinType`/`coinTypes` or `chain`/`chains` via `@oneOf` inputs; `PrimaryNameRecord.name` is a `CanonicalName` with `interpreted` and `beautified` +- add `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(where: AccountPrimaryNamesWhereInput!)`. Primary name lookups accept `coinType` or `chain` (singular) and `coinTypes` or `chains` (plural, `@oneOf`); `ENSIP19Chain` includes `DEFAULT`; `PrimaryNameRecord.name` is a `CanonicalName` with `interpreted` and `beautified` +- add `UID` cache keys on `ResolvedRecords` (keyed by resolution `InterpretedName`) for graphcache normalization across queries - add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 806e79570c..52a90f48f9 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@ensdomains/ensjs": "^4.0.2", + "@ensdomains/address-encoder": "^1.1.2", "@ensnode/datasources": "workspace:*", "@ensnode/ensdb-sdk": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index c30b904bd4..f3f22ac75f 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -15,6 +15,7 @@ import type { InterfaceId, InterpretedLabel, InterpretedName, + JsonValue, Node, NormalizedAddress, PermissionsId, @@ -25,6 +26,7 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + UID, } from "enssdk"; import { getNamedType } from "graphql"; import superjson from "superjson"; @@ -62,6 +64,7 @@ const createSpan = createOpenTelemetryWrapper(tracer, { export type BuilderScalars = { ID: { Input: string; Output: string }; BigInt: { Input: bigint; Output: bigint }; + JSON: { Input: JsonValue; Output: JsonValue }; Address: { Input: NormalizedAddress; Output: NormalizedAddress }; Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; @@ -69,6 +72,7 @@ export type BuilderScalars = { InterfaceId: { Input: InterfaceId; Output: InterfaceId }; Node: { Input: Node; Output: Node }; InterpretedName: { Input: InterpretedName; Output: InterpretedName }; + UID: { Input: UID; Output: UID }; InterpretedLabel: { Input: InterpretedLabel; Output: InterpretedLabel }; BeautifiedName: { Input: BeautifiedName; Output: BeautifiedName }; BeautifiedLabel: { Input: BeautifiedLabel; Output: BeautifiedLabel }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts new file mode 100644 index 0000000000..edcb5f3bf3 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -0,0 +1,109 @@ +import { + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLString, + parse, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { buildAccountPrimaryNamesSelection } from "./account-primary-names-selection"; + +const PrimaryNameByInputType = new GraphQLInputObjectType({ + name: "PrimaryNameByInput", + fields: { + coinType: { type: GraphQLInt }, + chain: { type: GraphQLString }, + }, +}); + +const AccountPrimaryNamesWhereInputType = new GraphQLInputObjectType({ + name: "AccountPrimaryNamesWhereInput", + fields: { + coinTypes: { type: new GraphQLList(GraphQLInt) }, + chains: { type: new GraphQLList(GraphQLString) }, + }, +}); + +const PrimaryNameRecordType = new GraphQLObjectType({ + name: "PrimaryNameRecord", + fields: { + name: { type: GraphQLString }, + }, +}); + +const AccountResolveType = new GraphQLObjectType({ + name: "AccountResolve", + fields: { + primaryName: { + type: PrimaryNameRecordType, + args: { + by: { type: PrimaryNameByInputType }, + }, + }, + primaryNames: { + type: new GraphQLList(PrimaryNameRecordType), + args: { + where: { type: AccountPrimaryNamesWhereInputType }, + }, + }, + }, +}); + +function parseResolveFieldNode(subselection: string) { + const document = parse(`{ resolve { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const resolveField = operation.selectionSet.selections[0]; + if (resolveField.kind !== "Field") throw new Error("expected field"); + + return resolveField; +} + +function resolveInfoForAccountResolveSubselection(subselection: string): GraphQLResolveInfo { + return { + fieldNodes: [parseResolveFieldNode(subselection)], + fragments: {}, + returnType: AccountResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +describe("buildAccountPrimaryNamesSelection", () => { + it("returns null when neither primaryName nor primaryNames is selected", () => { + const info = resolveInfoForAccountResolveSubselection("trace acceleration { requested }"); + expect(buildAccountPrimaryNamesSelection(info)).toBeNull(); + }); + + it("extracts coin type from primaryName(by: { coinType: 60 })", () => { + const info = resolveInfoForAccountResolveSubselection( + "primaryName(by: { coinType: 60 }) { name }", + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + }); + + it("extracts coin types from primaryNames(where: { coinTypes: [60, 0] })", () => { + const info = resolveInfoForAccountResolveSubselection( + "primaryNames(where: { coinTypes: [60, 0] }) { name }", + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60, 0]); + }); + + it("extracts coin type from primaryName(by: { chain: ETH })", () => { + const info = resolveInfoForAccountResolveSubselection( + 'primaryName(by: { chain: "ETH" }) { name }', + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + }); + + it("prefers primaryNames over primaryName when both are selected", () => { + const info = resolveInfoForAccountResolveSubselection(` + primaryName(by: { coinType: 0 }) { name } + primaryNames(where: { coinTypes: [60] }) { name } + `); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts new file mode 100644 index 0000000000..d3e9d8697a --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts @@ -0,0 +1,57 @@ +import type { CoinType } from "enssdk"; +import { + GraphQLError, + type GraphQLResolveInfo, + getArgumentValues, + getNamedType, + isObjectType, +} from "graphql"; + +import { + type AccountPrimaryNamesWhereInput, + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, + type PrimaryNameByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { collectNamedSubFieldNodes } from "@/omnigraph-api/lib/resolution/records-selection"; + +/** + * Derives primary-name coin types from `Account.resolve { primaryName | primaryNames }`, or null + * when neither field is selected. + */ +export function buildAccountPrimaryNamesSelection(info: GraphQLResolveInfo): CoinType[] | null { + const primaryNamesFieldNodes = info.fieldNodes.flatMap((resolveField) => { + const selectionSet = resolveField.selectionSet; + if (!selectionSet) return []; + return collectNamedSubFieldNodes(selectionSet, "primaryNames", info); + }); + + const primaryNameFieldNodes = info.fieldNodes.flatMap((resolveField) => { + const selectionSet = resolveField.selectionSet; + if (!selectionSet) return []; + return collectNamedSubFieldNodes(selectionSet, "primaryName", info); + }); + + if (primaryNamesFieldNodes.length === 0 && primaryNameFieldNodes.length === 0) { + return null; + } + + const resolveReturnType = getNamedType(info.returnType); + if (!isObjectType(resolveReturnType)) { + throw new GraphQLError("Return type must be an object type."); + } + + if (primaryNamesFieldNodes.length > 0) { + const fieldDef = resolveReturnType.getFields().primaryNames; + if (!fieldDef) return null; + + const args = getArgumentValues(fieldDef, primaryNamesFieldNodes[0], info.variableValues); + return normalizeAccountPrimaryNamesWhereInput(args.where as AccountPrimaryNamesWhereInput); + } + + const fieldDef = resolveReturnType.getFields().primaryName; + if (!fieldDef) return null; + + const args = getArgumentValues(fieldDef, primaryNameFieldNodes[0], info.variableValues); + return [normalizePrimaryNameByInput(args.by as PrimaryNameByInput)]; +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index 3f00f0b6ab..7a05cec62d 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -1,51 +1,40 @@ -import { - type ChainId, - type CoinType, - coinTypeToEvmChainId, - DEFAULT_EVM_CHAIN_ID, - DEFAULT_EVM_COIN_TYPE, - ETH_COIN_TYPE, - evmChainIdToCoinType, -} from "enssdk"; -import { arbitrum, base, linea, optimism, scroll } from "viem/chains"; +import type { CoinName } from "@ensdomains/address-encoder"; +import { coinNameToTypeMap } from "@ensdomains/address-encoder"; +import type { CoinType } from "enssdk"; -/** GraphQL `ENSIP19Chain` enum values — chains that can have an ENSIP-19 primary name. */ -export const ENSIP19_CHAIN_VALUES = [ - "DEFAULT", - "ETHEREUM", - "BASE", - "OPTIMISM", - "ARBITRUM", - "LINEA", - "SCROLL", -] as const; +/** + * address-encoder coin names for ENSIP-19 primary-name chains. + */ +const ENSIP19_COIN_NAMES = [ + "default", + "eth", + "base", + "op", + "arb1", + "linea", + "scr", +] as const satisfies readonly CoinName[]; -export type ENSIP19ChainValue = (typeof ENSIP19_CHAIN_VALUES)[number]; +export type ENSIP19ChainValue = Uppercase<(typeof ENSIP19_COIN_NAMES)[number]>; -const ENSIP19_CHAIN_TO_COIN_TYPE: Record = { - DEFAULT: DEFAULT_EVM_COIN_TYPE, - ETHEREUM: ETH_COIN_TYPE, - BASE: evmChainIdToCoinType(base.id), - OPTIMISM: evmChainIdToCoinType(optimism.id), - ARBITRUM: evmChainIdToCoinType(arbitrum.id), - LINEA: evmChainIdToCoinType(linea.id), - SCROLL: evmChainIdToCoinType(scroll.id), -}; +export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map((coinName) => + coinName.toUpperCase(), +) as unknown as readonly [ENSIP19ChainValue, ...ENSIP19ChainValue[]]; + +const ensip19ChainToCoinName = Object.fromEntries( + ENSIP19_CHAIN_VALUES.map((chain) => [ + chain, + chain.toLowerCase() as (typeof ENSIP19_COIN_NAMES)[number], + ]), +) as Record; /** Maps an `ENSIP19Chain` enum value to its canonical ENSIP-9 coin type. */ export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => - ENSIP19_CHAIN_TO_COIN_TYPE[chain]; + coinNameToTypeMap[ensip19ChainToCoinName[chain]] as CoinType; /** Maps a coin type to an `ENSIP19Chain` enum value, or null when not ENSIP-19 supported. */ export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => { - for (const chain of ENSIP19_CHAIN_VALUES) { - if (ENSIP19_CHAIN_TO_COIN_TYPE[chain] === coinType) return chain; - } - return null; -}; - -/** Maps an `ENSIP19Chain` enum value to the EVM chain id used for reverse resolution. */ -export const ensip19ChainToChainId = (chain: ENSIP19ChainValue): ChainId => { - if (chain === "DEFAULT") return DEFAULT_EVM_CHAIN_ID; - return coinTypeToEvmChainId(ensip19ChainToCoinType(chain)); + const coinName = ENSIP19_COIN_NAMES.find((name) => coinNameToTypeMap[name] === coinType); + if (!coinName) return null; + return coinName.toUpperCase() as ENSIP19ChainValue; }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts index 2f819ab0f0..51cec2b214 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -11,7 +11,7 @@ export type PrimaryNameByInput = { chain?: ENSIP19ChainValue | null; }; -export type PrimaryNamesByInput = { +export type AccountPrimaryNamesWhereInput = { coinTypes?: CoinType[] | null; chains?: ENSIP19ChainValue[] | null; }; @@ -23,11 +23,13 @@ export const normalizePrimaryNameByInput = (by: PrimaryNameByInput): CoinType => throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); }; -/** Normalizes a plural `PrimaryNamesByInput` to an ordered coin-type list. */ -export const normalizePrimaryNamesByInput = (by: PrimaryNamesByInput): CoinType[] => { - if (by.coinTypes != null) return by.coinTypes; - if (by.chains != null) return by.chains.map(ensip19ChainToCoinType); - throw new Error("PrimaryNamesByInput must specify exactly one of coinTypes or chains."); +/** Normalizes `AccountPrimaryNamesWhereInput` to an ordered coin-type list. */ +export const normalizeAccountPrimaryNamesWhereInput = ( + where: AccountPrimaryNamesWhereInput, +): CoinType[] => { + if (where.coinTypes != null) return where.coinTypes; + if (where.chains != null) return where.chains.map(ensip19ChainToCoinType); + throw new Error("AccountPrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); }; /** Projects a coin type to its ENSIP19Chain enum value, if applicable. */ diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts new file mode 100644 index 0000000000..62c7c4f51b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts @@ -0,0 +1,16 @@ +import type { InterpretedName } from "enssdk"; + +import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; + +/** Cache key and resolution identity for {@link ResolvedRecordsRef}. */ +export type ResolvedRecordsModel = Partial & { + id: InterpretedName; +}; + +export const toResolvedRecordsModel = ( + name: InterpretedName, + response: ResolverRecordsResponseBase | Partial, +): ResolvedRecordsModel => ({ + id: name, + ...response, +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts similarity index 73% rename from apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index a7c68d6273..701c25abd6 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -13,9 +13,10 @@ import { import { describe, expect, it } from "vitest"; import { + buildRecordsSelectionFromResolveContainerInfo, buildRecordsSelectionFromResolveInfo, EMPTY_RECORDS_SELECTION_MESSAGE, -} from "@/omnigraph-api/lib/resolution/build-records-selection"; +} from "@/omnigraph-api/lib/resolution/records-selection"; import { RECORDS_SELECTION_PARAMETRIC_FIELDS, RECORDS_SELECTION_SIMPLE_FIELDS, @@ -61,6 +62,34 @@ function buildMockResolvedRecordsType() { const ResolvedRecordsType = buildMockResolvedRecordsType(); +const DomainResolveType = new GraphQLObjectType({ + name: "DomainResolve", + fields: { + trace: { type: GraphQLString }, + records: { type: ResolvedRecordsType }, + }, +}); + +function parseResolveFieldNode(subselection: string) { + const document = parse(`{ resolve { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const resolveField = operation.selectionSet.selections[0]; + if (resolveField.kind !== "Field") throw new Error("expected field"); + + return resolveField; +} + +function resolveInfoForDomainResolveSubselection(subselection: string): GraphQLResolveInfo { + return { + fieldNodes: [parseResolveFieldNode(subselection)], + fragments: {}, + returnType: DomainResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + function parseRecordsFieldNode(subselection: string) { const document = parse(`{ records { ${subselection} } }`); const operation = document.definitions[0]; @@ -174,3 +203,34 @@ describe("buildRecordsSelectionFromResolveInfo", () => { ); }); }); + +describe("buildRecordsSelectionFromResolveContainerInfo", () => { + it("returns null when records is not selected", () => { + const info = resolveInfoForDomainResolveSubselection("trace acceleration { requested }"); + + expect(buildRecordsSelectionFromResolveContainerInfo(info)).toBeNull(); + }); + + it("builds selection from resolve { records { ... } } regardless of sibling field order", () => { + const info = resolveInfoForDomainResolveSubselection(` + trace + records { + texts(keys: ["description"]) + addresses(coinTypes: [60]) + } + `); + + expect(buildRecordsSelectionFromResolveContainerInfo(info)).toEqual({ + texts: ["description"], + addresses: [60], + }); + }); + + it("throws when records is selected with an empty subselection", () => { + const info = resolveInfoForDomainResolveSubselection("records { __typename }"); + + expect(() => buildRecordsSelectionFromResolveContainerInfo(info)).toThrow( + EMPTY_RECORDS_SELECTION_MESSAGE, + ); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts similarity index 53% rename from apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts index 3529941664..9edcae5ad7 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/build-records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -1,6 +1,7 @@ import { type FieldNode, GraphQLError, + type GraphQLObjectType, type GraphQLResolveInfo, getArgumentValues, getNamedType, @@ -40,65 +41,121 @@ function collectFieldNodes( return fields; } -/** - * Builds a {@link ResolverRecordsSelection} from the GraphQL field selection on `Domain.records`. - * - * GraphQL clients express *what* to resolve via a field selection set (e.g. `records { texts(...) }`). - * The ENS resolution layer expects a flat {@link ResolverRecordsSelection} instead — this function - * translates between the two. - */ -export function buildRecordsSelectionFromResolveInfo( +export function collectNamedSubFieldNodes( + graphqlSelectionSet: SelectionSetNode, + fieldName: string, + info: GraphQLResolveInfo, +): FieldNode[] { + const fields: FieldNode[] = []; + + for (const graphqlSelection of graphqlSelectionSet.selections) { + if (graphqlSelection.kind === "Field") { + if (graphqlSelection.name.value === fieldName) fields.push(graphqlSelection); + } else if (graphqlSelection.kind === "InlineFragment") { + fields.push(...collectNamedSubFieldNodes(graphqlSelection.selectionSet, fieldName, info)); + } else if (graphqlSelection.kind === "FragmentSpread") { + const fragment = info.fragments[graphqlSelection.name.value]; + if (fragment) { + fields.push(...collectNamedSubFieldNodes(fragment.selectionSet, fieldName, info)); + } + } + } + + return fields; +} + +function buildRecordsSelectionFromRecordsFieldNodes( + recordsFieldNodes: readonly FieldNode[], + recordsReturnType: GraphQLObjectType, info: GraphQLResolveInfo, ): ResolverRecordsSelection { - // GraphQL may pass multiple AST field nodes for the same resolver when the client splits - // `records { ... }` across inline fragments (common on the `Domain` interface). Merge their - // GraphQL selection lists so we don't drop subselections on fieldNodes[1], fieldNodes[2], etc. - const graphqlSelections = info.fieldNodes.flatMap((node) => node.selectionSet?.selections ?? []); + const graphqlSelections = recordsFieldNodes.flatMap( + (node) => node.selectionSet?.selections ?? [], + ); if (graphqlSelections.length === 0) { throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); } - // collectFieldNodes expects a SelectionSetNode; wrap the merged GraphQL selections into one. const mergedGraphqlSelectionSet: SelectionSetNode = { kind: Kind.SELECTION_SET, selections: graphqlSelections, }; - const returnType = getNamedType(info.returnType); - if (!isObjectType(returnType)) { - throw new GraphQLError("Return type must be an object type."); - } - - // Output for resolveForward(), e.g. { texts: ["description"], addresses: [60] }. const recordsSelection: ResolverRecordsSelection = {}; - // Walk every GraphQL child field under `records` (skipping __typename, expanding fragments). for (const childField of collectFieldNodes(mergedGraphqlSelectionSet, info)) { const graphqlField = childField.name.value; - // Simple GraphQL fields (contenthash, pubkey, …) map 1:1 to a boolean in recordsSelection. const simple = getSimpleRecordsSelectionField(graphqlField); if (simple) { recordsSelection[simple.recordsSelectionKey] = true; continue; } - // Parametric GraphQL fields (texts, addresses, …) carry args we copy into recordsSelection. const parametric = getParametricRecordsSelectionField(graphqlField); if (!parametric) continue; - const fieldDef = returnType.getFields()[graphqlField]; + const fieldDef = recordsReturnType.getFields()[graphqlField]; if (!fieldDef) continue; const args = getArgumentValues(fieldDef, childField, info.variableValues); parametric.applyToRecordsSelection(recordsSelection, args); } - // GraphQL query selected only __typename or unknown fields — nothing to resolve. if (isSelectionEmpty(recordsSelection)) { throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); } return recordsSelection; } + +/** + * Builds a {@link ResolverRecordsSelection} from the GraphQL field selection on `Domain.records`. + * + * GraphQL clients express *what* to resolve via a field selection set (e.g. `records { texts(...) }`). + * The ENS resolution layer expects a flat {@link ResolverRecordsSelection} instead — this function + * translates between the two. + */ +export function buildRecordsSelectionFromResolveInfo( + info: GraphQLResolveInfo, +): ResolverRecordsSelection { + const returnType = getNamedType(info.returnType); + if (!isObjectType(returnType)) { + throw new GraphQLError("Return type must be an object type."); + } + + return buildRecordsSelectionFromRecordsFieldNodes(info.fieldNodes, returnType, info); +} + +/** + * Builds a {@link ResolverRecordsSelection} from a resolution container's `records { ... }` field + * (e.g. `Domain.resolve { records { ... } }` or `PrimaryNameRecord.resolve { records { ... } }`), + * or null when `records` is not selected. + */ +export function buildRecordsSelectionFromResolveContainerInfo( + info: GraphQLResolveInfo, +): ResolverRecordsSelection | null { + const recordsFieldNodes = info.fieldNodes.flatMap((resolveField) => { + const selectionSet = resolveField.selectionSet; + if (!selectionSet) return []; + return collectNamedSubFieldNodes(selectionSet, "records", info); + }); + + if (recordsFieldNodes.length === 0) return null; + + const resolveReturnType = getNamedType(info.returnType); + if (!isObjectType(resolveReturnType)) { + throw new GraphQLError("Return type must be an object type."); + } + + const recordsFieldDef = resolveReturnType.getFields().records; + if (!recordsFieldDef) return null; + + const recordsReturnType = getNamedType(recordsFieldDef.type); + if (!isObjectType(recordsReturnType)) { + throw new GraphQLError("ResolvedRecords return type must be an object type."); + } + + return buildRecordsSelectionFromRecordsFieldNodes(recordsFieldNodes, recordsReturnType, info); +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index 9055f2b144..e8075d5459 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -1,5 +1,7 @@ import type { Address, CoinType, InterpretedName } from "enssdk"; +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + import { getENSIP19SupportedCoinTypes, type MultichainPrimaryNameByCoinTypeResolutionResult, @@ -10,27 +12,24 @@ import { coinTypeToEnsip19Chain } from "@/omnigraph-api/lib/resolution/chain-coi import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/resolution"; type PrimaryNameResolutionOptions = { - disableAcceleration: boolean; + accelerate: boolean; canAccelerate: boolean; }; -const toResolutionOptions = (options: PrimaryNameResolutionOptions) => ({ - accelerate: !options.disableAcceleration, - canAccelerate: options.canAccelerate, -}); +export type PrimaryNameRecordsResolution = { + trace: TracingTrace | null; + records: PrimaryNameRecordModel[]; +}; const toPrimaryNameRecord = ( address: Address, coinType: CoinType, name: InterpretedName | null, - options: PrimaryNameResolutionOptions, ): PrimaryNameRecordModel => ({ address, coinType, chain: coinTypeToEnsip19Chain(coinType), name, - disableAcceleration: options.disableAcceleration, - canAccelerate: options.canAccelerate, }); /** Resolves primary names for the provided coin types, preserving input order. */ @@ -38,42 +37,21 @@ export async function resolvePrimaryNameRecords( address: Address, coinTypes: CoinType[], options: PrimaryNameResolutionOptions, -): Promise { +): Promise { const supportedCoinTypes = new Set(getENSIP19SupportedCoinTypes()); const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); - const nonResolvableCoinTypes = coinTypes.filter((coinType) => !supportedCoinTypes.has(coinType)); - let resolvedByCoinType: MultichainPrimaryNameByCoinTypeResolutionResult = {}; - if (resolvableCoinTypes.length > 0) { - const { result } = await runWithTrace(() => - resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, toResolutionOptions(options)), - ); - resolvedByCoinType = result; - } + const { trace, result: resolvedByCoinType } = + resolvableCoinTypes.length > 0 + ? await runWithTrace(() => + resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, options), + ) + : { trace: null, result: {} as MultichainPrimaryNameByCoinTypeResolutionResult }; - const recordsByCoinType = new Map(); - - for (const coinType of resolvableCoinTypes) { - recordsByCoinType.set( - coinType, - toPrimaryNameRecord( - address, - coinType, - (resolvedByCoinType[coinType] ?? null) as InterpretedName | null, - options, - ), - ); - } - - for (const coinType of nonResolvableCoinTypes) { - recordsByCoinType.set(coinType, toPrimaryNameRecord(address, coinType, null, options)); - } - - return coinTypes.map((coinType) => { - const record = recordsByCoinType.get(coinType); - if (!record) { - throw new Error(`Missing primary name record for coinType ${coinType}.`); - } - return record; + const records = coinTypes.map((coinType) => { + const name = (resolvedByCoinType[coinType] ?? null) as InterpretedName | null; + return toPrimaryNameRecord(address, coinType, name); }); + + return { trace, records }; } 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 790aa6d89d..b961dec656 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -323,10 +323,9 @@ describe("Account.primaryName and Account.primaryNames", () => { coinType: number; chain: string | null; name: CanonicalNameResult; - records?: { addresses: Array<{ coinType: number; address: string | null }> } | null; - profile?: { - addresses: { ethereum: string | null } | null; - }; + resolve?: { + records?: { addresses: Array<{ coinType: number; address: string | null }> } | null; + } | null; }; const TEST_ETH_NAME: CanonicalNameResult = { @@ -336,23 +335,29 @@ describe("Account.primaryName and Account.primaryNames", () => { type AccountPrimaryNameResult = { account: { - primaryName: PrimaryNameRecordResult; + resolve: { + primaryName: PrimaryNameRecordResult; + }; }; }; type AccountPrimaryNamesResult = { account: { - primaryNames: PrimaryNameRecordResult[]; + resolve: { + primaryNames: PrimaryNameRecordResult[]; + }; }; }; const AccountPrimaryNameByCoinType = gql` query AccountPrimaryNameByCoinType($address: Address!, $coinType: CoinType!) { account(by: { address: $address }) { - primaryName(by: { coinType: $coinType }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryName(by: { coinType: $coinType }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -361,10 +366,12 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNameByChain = gql` query AccountPrimaryNameByChain($address: Address!) { account(by: { address: $address }) { - primaryName(by: { chain: ETHEREUM }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryName(by: { chain: ETH }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -373,10 +380,12 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNameByDefaultChain = gql` query AccountPrimaryNameByDefaultChain($address: Address!) { account(by: { address: $address }) { - primaryName(by: { chain: DEFAULT }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryName(by: { chain: DEFAULT }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -385,10 +394,12 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNamesByDefaultChain = gql` query AccountPrimaryNamesByDefaultChain($address: Address!) { account(by: { address: $address }) { - primaryNames(by: { chains: [DEFAULT] }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryNames(where: { chains: [DEFAULT] }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -397,10 +408,12 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNamesByCoinTypes = gql` query AccountPrimaryNamesByCoinTypes($address: Address!, $coinTypes: [CoinType!]!) { account(by: { address: $address }) { - primaryNames(by: { coinTypes: $coinTypes }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryNames(where: { coinTypes: $coinTypes }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -409,10 +422,12 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNamesByChains = gql` query AccountPrimaryNamesByChains($address: Address!) { account(by: { address: $address }) { - primaryNames(by: { chains: [ETHEREUM, BASE] }) { - coinType - chain - name { interpreted beautified } + resolve { + primaryNames(where: { chains: [ETH, BASE] }) { + coinType + chain + name { interpreted beautified } + } } } } @@ -421,15 +436,16 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNameNonEnsip19 = gql` query AccountPrimaryNameNonEnsip19($address: Address!) { account(by: { address: $address }) { - primaryName(by: { coinType: 0 }) { - coinType - chain - name { interpreted beautified } - records { - addresses(coinTypes: [60]) { address } - } - profile { - addresses { ethereum } + resolve { + primaryName(by: { coinType: 0 }) { + coinType + chain + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { address } + } + } } } } @@ -439,10 +455,14 @@ describe("Account.primaryName and Account.primaryNames", () => { const AccountPrimaryNameChainedRecords = gql` query AccountPrimaryNameChainedRecords($address: Address!) { account(by: { address: $address }) { - primaryName(by: { coinType: 60 }) { - name { interpreted beautified } - records { - addresses(coinTypes: [60]) { coinType address } + resolve { + primaryName(by: { coinType: 60 }) { + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { coinType address } + } + } } } } @@ -457,7 +477,9 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, + resolve: { + primaryName: { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + }, }, }); }); @@ -469,7 +491,9 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, + resolve: { + primaryName: { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + }, }, }); }); @@ -481,10 +505,12 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { - coinType: 2_147_483_648, - chain: "DEFAULT", - name: null, + resolve: { + primaryName: { + coinType: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, }, }, }); @@ -497,13 +523,15 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryNames: [ - { - coinType: 2_147_483_648, - chain: "DEFAULT", - name: null, - }, - ], + resolve: { + primaryNames: [ + { + coinType: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, + ], + }, }, }); }); @@ -516,7 +544,9 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: null }, + resolve: { + primaryName: { coinType: 60, chain: "ETH", name: null }, + }, }, }); }); @@ -529,10 +559,12 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toMatchObject({ account: { - primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, - { coinType: 2147492101, chain: "BASE", name: null }, - ], + resolve: { + primaryNames: [ + { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 2147492101, chain: "BASE", name: null }, + ], + }, }, }); }); @@ -544,10 +576,12 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toMatchObject({ account: { - primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, - { coinType: 2147492101, chain: "BASE", name: null }, - ], + resolve: { + primaryNames: [ + { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 2147492101, chain: "BASE", name: null }, + ], + }, }, }); }); @@ -559,13 +593,14 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toEqual({ account: { - primaryName: { - coinType: 0, - chain: null, - name: null, - records: null, - profile: { - addresses: { ethereum: null }, + resolve: { + primaryName: { + coinType: 0, + chain: null, + name: null, + resolve: { + records: null, + }, }, }, }, @@ -579,10 +614,14 @@ describe("Account.primaryName and Account.primaryNames", () => { }), ).resolves.toMatchObject({ account: { - primaryName: { - name: TEST_ETH_NAME, - records: { - addresses: [{ coinType: 60, address: accounts.owner.address }], + resolve: { + primaryName: { + name: TEST_ETH_NAME, + resolve: { + records: { + addresses: [{ coinType: 60, address: accounts.owner.address }], + }, + }, }, }, }, @@ -604,7 +643,9 @@ describe("Account.primaryName and Account.primaryNames", () => { gql` query AccountPrimaryNamesEmptyChains($address: Address!) { account(by: { address: $address }) { - primaryNames(by: { chains: [] }) { coinType } + resolve { + primaryNames(where: { chains: [] }) { coinType } + } } } `, diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 3f6cf98b2e..4284f4b092 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,6 +1,8 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address } from "enssdk"; +import type { Address, JsonValue } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; @@ -9,13 +11,13 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain 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 { - normalizePrimaryNameByInput, - normalizePrimaryNamesByInput, -} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { buildAccountPrimaryNamesSelection } from "@/omnigraph-api/lib/resolution/account-primary-names-selection"; import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; -import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; +import { + ID_PAGINATED_CONNECTION_ARGS, + RESOLVE_ACCELERATE_ARG, +} from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; import { AccountDomainsWhereInput, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import { EventRef } from "@/omnigraph-api/schema/event"; @@ -23,9 +25,11 @@ import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; import { + AccelerationStatusRef, + AccountPrimaryNamesWhereInput, PrimaryNameByInput, + type PrimaryNameRecordModel, PrimaryNameRecordRef, - PrimaryNamesByInput, } from "@/omnigraph-api/schema/resolution"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; @@ -42,6 +46,17 @@ export const AccountRef = builder.loadableObjectRef("Account", { }); export type Account = Exclude; +type AccountPrimaryNamesResult = { + trace: TracingTrace | null; + records: PrimaryNameRecordModel[]; +}; +type AccountResolveModel = { + account: Account; + accelerate: boolean; + canAccelerate: boolean; + primaryNamesResolution: Promise | null; +}; +const AccountResolveRef = builder.objectRef("AccountResolve"); /////////// // Account @@ -69,60 +84,27 @@ AccountRef.implement({ resolve: (parent) => parent.id, }), - /////////////////////// - // Account.primaryName - /////////////////////// - primaryName: t.field({ - description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", - type: PrimaryNameRecordRef, + ////////////////// + // Account.resolve + ////////////////// + resolve: t.field({ + description: "Resolve primary names for this Account with protocol acceleration controls.", + type: AccountResolveRef, nullable: false, args: { - by: t.arg({ - type: PrimaryNameByInput, - required: true, - }), - disableAcceleration: t.arg.boolean({ - required: false, - defaultValue: false, - description: "When true, disables protocol acceleration feature.", - }), - }, - resolve: async (account, { by, disableAcceleration }, context) => { - const coinType = normalizePrimaryNameByInput(by); - const [record] = await resolvePrimaryNameRecords(account.id, [coinType], { - disableAcceleration: disableAcceleration ?? false, - canAccelerate: context.canAccelerate, - }); - // biome-ignore lint/style/noNonNullAssertion: exactly one coin type requested - return record!; + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), }, - }), + resolve: (account, { accelerate: accelerateArg }, context, info) => { + const accelerate = accelerateArg ?? true; + const { canAccelerate } = context; + const coinTypes = buildAccountPrimaryNamesSelection(info); - //////////////////////// - // Account.primaryNames - //////////////////////// - primaryNames: t.field({ - description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", - type: [PrimaryNameRecordRef], - nullable: false, - args: { - by: t.arg({ - type: PrimaryNamesByInput, - required: true, - description: "Select coin types or chains to resolve primary names for.", - }), - disableAcceleration: t.arg.boolean({ - required: false, - defaultValue: false, - description: "When true, disables protocol acceleration feature.", - }), - }, - resolve: async (account, { by, disableAcceleration }, context) => { - const coinTypes = normalizePrimaryNamesByInput(by); - return resolvePrimaryNameRecords(account.id, coinTypes, { - disableAcceleration: disableAcceleration ?? false, - canAccelerate: context.canAccelerate, - }); + const primaryNamesResolution = + coinTypes !== null + ? resolvePrimaryNameRecords(account.id, coinTypes, { accelerate, canAccelerate }) + : null; + + return { account, accelerate, canAccelerate, primaryNamesResolution }; }, }), @@ -281,6 +263,75 @@ AccountRef.implement({ }), }); +AccountResolveRef.implement({ + description: + "Nested account resolution container exposing primary-name resolution with shared acceleration settings.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ primaryNamesResolution }) => { + if (!primaryNamesResolution) return null; + const { trace } = await primaryNamesResolution; + return trace as unknown as JsonValue | null; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this Account resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + primaryName: t.field({ + description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", + type: PrimaryNameRecordRef, + nullable: false, + args: { + by: t.arg({ + type: PrimaryNameByInput, + required: true, + description: "Select a coin type or chain to resolve a primary name for.", + }), + }, + resolve: async ({ primaryNamesResolution, accelerate }) => { + if (!primaryNamesResolution) { + throw new Error("primaryName requires a primary-name resolution to be started."); + } + const { records } = await primaryNamesResolution; + const [record] = records; + if (!record) { + throw new Error("Missing primary name record for requested coin type."); + } + return { ...record, accelerate }; + }, + }), + primaryNames: t.field({ + description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", + type: [PrimaryNameRecordRef], + nullable: false, + args: { + where: t.arg({ + type: AccountPrimaryNamesWhereInput, + required: true, + description: "Select coin types or chains to resolve primary names for.", + }), + }, + resolve: async ({ primaryNamesResolution, accelerate }) => { + if (!primaryNamesResolution) { + throw new Error("primaryNames requires a primary-name resolution to be started."); + } + const { records } = await primaryNamesResolution; + return records.map((record) => ({ ...record, accelerate })); + }, + }), + }), +}); + ////////// // Inputs ////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/constants.ts b/apps/ensapi/src/omnigraph-api/schema/constants.ts index 606609b593..736db5ab38 100644 --- a/apps/ensapi/src/omnigraph-api/schema/constants.ts +++ b/apps/ensapi/src/omnigraph-api/schema/constants.ts @@ -12,3 +12,11 @@ export const ID_PAGINATED_CONNECTION_ARGS = { defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, maxSize: PAGINATION_DEFAULT_MAX_SIZE, } as const; + +/** Shared `accelerate` argument for `Domain.resolve` and `Account.resolve`. */ +export const RESOLVE_ACCELERATE_ARG = { + required: false, + defaultValue: true, + description: + "When true (default), Protocol Acceleration is used for record resolution, when supported.\n@see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration", +} as const; 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 e43bb5dc4a..0afc42ea07 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -19,6 +19,7 @@ import { DatasourceNames } from "@ensnode/datasources"; import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, @@ -490,35 +491,41 @@ describe("Domain.events filtering (EventsWhereInput)", () => { describe("Domain.records", () => { type DomainRecordsResult = { domain: { - records: { - addresses: Array<{ coinType: CoinType; address: string | null }>; - texts: Array<{ key: string; value: string | null }>; - } | null; + resolve: { + records: { + addresses: Array<{ coinType: CoinType; address: string | null }>; + texts: Array<{ key: string; value: string | null }>; + } | null; + }; }; }; type DomainAllRecordsResult = { domain: { - records: { - reverseName: string | null; - contenthash: string | null; - pubkey: { x: string; y: string } | null; - dnszonehash: string | null; - version: string | null; - abi: { contentType: string; data: string } | null; - interfaces: Array<{ interfaceId: string; implementer: string | null }>; - addresses: Array<{ coinType: CoinType; address: string | null }>; - texts: Array<{ key: string; value: string | null }>; - } | null; + resolve: { + records: { + reverseName: string | null; + contenthash: string | null; + pubkey: { x: string; y: string } | null; + dnszonehash: string | null; + version: string | null; + abi: { contentType: string; data: string } | null; + interfaces: Array<{ interfaceId: string; implementer: string | null }>; + addresses: Array<{ coinType: CoinType; 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 { - addresses(coinTypes: $addresses) { coinType address } - texts(keys: $texts) { key value } + resolve { + records { + addresses(coinTypes: $addresses) { coinType address } + texts(keys: $texts) { key value } + } } } } @@ -533,16 +540,18 @@ describe("Domain.records", () => { $interfaceIds: [InterfaceId!]! ) { domain(by: { name: $name }) { - records { - reverseName - contenthash - pubkey { x y } - dnszonehash - version - abi(contentTypeMask: $contentTypeMask) { contentType data } - interfaces(ids: $interfaceIds) { interfaceId implementer } - addresses(coinTypes: $addresses) { coinType address } - texts(keys: $texts) { key value } + resolve { + records { + reverseName + contenthash + pubkey { x y } + dnszonehash + version + abi(contentTypeMask: $contentTypeMask) { contentType data } + interfaces(ids: $interfaceIds) { interfaceId implementer } + addresses(coinTypes: $addresses) { coinType address } + texts(keys: $texts) { key value } + } } } } @@ -557,9 +566,11 @@ describe("Domain.records", () => { }), ).resolves.toMatchObject({ domain: { - records: { - texts: [{ key: "description", value: "example.eth" }], - addresses: [{ coinType: 60, address: accounts.owner.address }], + resolve: { + records: { + texts: [{ key: "description", value: "example.eth" }], + addresses: [{ coinType: 60, address: accounts.owner.address }], + }, }, }, }); @@ -576,52 +587,58 @@ describe("Domain.records", () => { }), ).resolves.toMatchObject({ domain: { - records: { - contenthash: fixtures.contenthash, - pubkey: { x: fixtures.publicKeyX, y: fixtures.publicKeyY }, - dnszonehash: null, - version: expect.any(String), - abi: { contentType: "1", data: fixtures.abiBytes }, - interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], - addresses: [ - { coinType: 60, address: accounts.owner.address }, - { coinType: 0, address: fixtures.bitcoinAddress }, - { coinType: 2, address: fixtures.litecoinAddress }, - ], - texts: [ - { key: "avatar", value: "https://example.com/avatar.png" }, - { key: "description", value: "test.eth" }, - { key: "url", value: "https://ens.domains" }, - { key: "email", value: "test@ens.domains" }, - { key: "com.twitter", value: "ensdomains" }, - { key: "com.github", value: "ensdomains" }, - ], + resolve: { + records: { + contenthash: fixtures.contenthash, + pubkey: { x: fixtures.publicKeyX, y: fixtures.publicKeyY }, + dnszonehash: null, + version: expect.any(String), + abi: { contentType: "1", data: fixtures.abiBytes }, + interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], + addresses: [ + { coinType: 60, address: accounts.owner.address }, + { coinType: 0, address: fixtures.bitcoinAddress }, + { coinType: 2, address: fixtures.litecoinAddress }, + ], + texts: [ + { key: "avatar", value: "https://example.com/avatar.png" }, + { key: "description", value: "test.eth" }, + { key: "url", value: "https://ens.domains" }, + { key: "email", value: "test@ens.domains" }, + { key: "com.twitter", value: "ensdomains" }, + { key: "com.github", value: "ensdomains" }, + ], + }, }, }, }); }); }); -describe("Domain.profile", () => { +(INCLUDE_DEV_METHODS ? describe : describe.skip)("Domain.profile", () => { type DomainProfileResult = { domain: { - profile: { - description: string | null; - avatar: { url: string | null } | null; - addresses: { ethereum: string | null } | null; - socials: { github: { handle: string | null; url: string | null } | null } | null; - } | null; + resolve: { + profile: { + description: string | null; + avatar: { url: string | null } | null; + addresses: { ethereum: string | null } | null; + socials: { github: { handle: string | null; url: string | null } | null } | null; + } | null; + }; }; }; const DomainProfile = gql` query DomainProfile($name: InterpretedName!) { domain(by: { name: $name }) { - profile { - description - avatar { url } - addresses { ethereum } - socials { github { handle url } } + resolve { + profile { + description + avatar { url } + addresses { ethereum } + socials { github { handle url } } + } } } } @@ -632,11 +649,13 @@ describe("Domain.profile", () => { request(DomainProfile, { name: "test.eth" }), ).resolves.toEqual({ domain: { - profile: { - description: null, - avatar: { url: null }, - addresses: { ethereum: null }, - socials: { github: { handle: null, url: null } }, + resolve: { + profile: { + description: null, + avatar: { url: null }, + addresses: { ethereum: null }, + socials: { github: { handle: null, url: null } }, + }, }, }, }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 50586f8f16..09d286fc4c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,13 +1,9 @@ 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 } from "enssdk"; +import type { DomainId, JsonValue } from "enssdk"; -import type { - RequiredAndNotNull, - RequiredAndNull, - ResolverRecordsResponseBase, -} from "@ensnode/ensnode-sdk"; +import type { RequiredAndNotNull, RequiredAndNull, TracingTrace } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -25,13 +21,16 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; -import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/resolution/build-records-selection"; +import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS, PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, + RESOLVE_ACCELERATE_ARG, } from "@/omnigraph-api/schema/constants"; import { DomainCanonicalRef } from "@/omnigraph-api/schema/domain-canonical"; import { @@ -46,7 +45,14 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; -import { DomainProfileRef, ResolvedRecordsRef } from "@/omnigraph-api/schema/resolution"; +import { + AccelerationStatusRef, + AccountPrimaryNamesWhereInput, + DomainProfileRef, + PrimaryNameByInput, + PrimaryNameRecordRef, + ResolvedRecordsRef, +} from "@/omnigraph-api/schema/resolution"; const tracer = trace.getTracer("schema/Domain"); @@ -70,6 +76,16 @@ export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { export type Domain = Exclude; export type DomainInterface = Omit; +type DomainRecordsResult = { + trace: TracingTrace; + records: ReturnType; +}; +type DomainResolveModel = { + domain: DomainInterface; + accelerate: boolean; + canAccelerate: boolean; + recordsResolution: Promise | null; +}; export type ENSv1Domain = RequiredAndNotNull & RequiredAndNull & { type: "ENSv1Domain" }; export type ENSv2Domain = RequiredAndNotNull & @@ -83,6 +99,7 @@ export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); +const DomainResolveRef = builder.objectRef("DomainResolve"); ////////////////////////////////// // DomainInterface Implementation @@ -175,50 +192,38 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.id, }), - /////////////////// - // Domain.records - /////////////////// - records: t.field({ + ////////////////// + // Domain.resolve + ////////////////// + resolve: t.field({ description: - "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", - type: ResolvedRecordsRef, - nullable: true, - tracing: true, + "Resolve protocol-level data for this Domain with trace and acceleration metadata.", + type: DomainResolveRef, + nullable: false, args: { - disableAcceleration: t.arg.boolean({ - required: false, - defaultValue: false, - description: "When true, disables protocol acceleration feature.", - }), + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), }, - resolve: async (domain, { disableAcceleration }, context, info) => { + resolve: (domain, { accelerate: accelerateArg }, context, info) => { + const accelerate = accelerateArg ?? true; + const { canAccelerate } = context; const name = domain.canonicalName; - if (!name) return null; - const recordsSelection = buildRecordsSelectionFromResolveInfo(info); + const recordsSelection = name ? buildRecordsSelectionFromResolveContainerInfo(info) : null; - const { result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { - accelerate: !disableAcceleration, - canAccelerate: context.canAccelerate, - }), - ); + const recordsResolution = + name && recordsSelection + ? runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ).then(({ trace, result }) => ({ + trace, + records: toResolvedRecordsModel(name, result), + })) + : null; - return result as ResolverRecordsResponseBase; + return { domain, accelerate, canAccelerate, recordsResolution }; }, }), - /////////////////// - // Domain.profile - /////////////////// - profile: t.field({ - description: - "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical.", - type: DomainProfileRef, - nullable: true, - resolve: (domain) => (domain.canonicalName ? {} : null), - }), - /////////////////////// // Domain.registration /////////////////////// @@ -311,6 +316,52 @@ DomainInterfaceRef.implement({ }), }); +DomainResolveRef.implement({ + description: + "Nested domain resolution container exposing trace/acceleration metadata and resolved data.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).trace as unknown as JsonValue; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this Domain resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + records: t.field({ + description: + "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", + type: ResolvedRecordsRef, + nullable: true, + tracing: true, + resolve: async ({ domain, recordsResolution }) => { + if (!domain.canonicalName || !recordsResolution) return null; + return (await recordsResolution).records; + }, + }), + ...(INCLUDE_DEV_METHODS && { + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical.", + type: DomainProfileRef, + nullable: true, + resolve: ({ domain }) => (domain.canonicalName ? {} : null), + }), + }), + }), +}); + ////////////////////////////// // ENSv1Domain Implementation ////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 7f7687b857..44e3d3fade 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -4,27 +4,58 @@ import type { Hex, InterfaceId, InterpretedName, + JsonValue, NormalizedAddress, } from "enssdk"; -import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; +import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; -import { buildRecordsSelectionFromResolveInfo } from "@/omnigraph-api/lib/resolution/build-records-selection"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { ENSIP19_CHAIN_VALUES, type ENSIP19ChainValue, } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { + type ResolvedRecordsModel, + toResolvedRecordsModel, +} from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { + buildRecordsSelectionFromResolveContainerInfo, + buildRecordsSelectionFromResolveInfo, +} from "@/omnigraph-api/lib/resolution/records-selection"; import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; +export type AccelerationStatusModel = { + requested: boolean; + attempted: boolean; +}; + +export const AccelerationStatusRef = + builder.objectRef("AccelerationStatus"); + +AccelerationStatusRef.implement({ + description: "Execution status metadata for a resolver strategy.", + fields: (t) => ({ + requested: t.exposeBoolean("requested", { + description: "Whether this strategy was requested by the caller.", + nullable: false, + }), + attempted: t.exposeBoolean("attempted", { + description: "Whether this strategy was attempted at runtime.", + nullable: false, + }), + }), +}); + ////////////////// // ENSIP19Chain ////////////////// export const ENSIP19Chain = builder.enumType("ENSIP19Chain", { description: - "ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. Non-EVM coin types are intentionally absent.", + "ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain.\n@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details.", values: ENSIP19_CHAIN_VALUES, }); @@ -47,9 +78,9 @@ export const PrimaryNameByInput = builder.inputType("PrimaryNameByInput", { }), }); -export const PrimaryNamesByInput = builder.inputType("PrimaryNamesByInput", { +export const AccountPrimaryNamesWhereInput = builder.inputType("AccountPrimaryNamesWhereInput", { description: - "Select primary name lookup targets. Exactly one of `coinTypes` or `chains` must be provided.", + "Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided.", isOneOf: true, fields: (t) => ({ coinTypes: t.field({ @@ -68,10 +99,10 @@ export const PrimaryNamesByInput = builder.inputType("PrimaryNamesByInput", { ////////////////////// // DomainProfile (preview — types only, no resolution wired yet) ////////////////////// -export type DomainProfileModel = Record; +type ProfileSectionModel = Record; export const ProfileSocialAccountRef = - builder.objectRef("ProfileSocialAccount"); + builder.objectRef("ProfileSocialAccount"); ProfileSocialAccountRef.implement({ description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", @@ -89,7 +120,7 @@ ProfileSocialAccountRef.implement({ }), }); -export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); +export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); ProfileSocialsRef.implement({ description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", @@ -112,7 +143,7 @@ ProfileSocialsRef.implement({ }), }); -export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); +export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); ProfileAddressesRef.implement({ description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", @@ -142,7 +173,7 @@ ProfileAddressesRef.implement({ }), }); -export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); +export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); ProfileAvatarRef.implement({ description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", @@ -155,7 +186,7 @@ ProfileAvatarRef.implement({ }), }); -export const ProfileBannerRef = builder.objectRef("ProfileBanner"); +export const ProfileBannerRef = builder.objectRef("ProfileBanner"); ProfileBannerRef.implement({ description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", @@ -168,7 +199,7 @@ ProfileBannerRef.implement({ }), }); -export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); +export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); ProfileWebsiteRef.implement({ description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", @@ -181,7 +212,7 @@ ProfileWebsiteRef.implement({ }), }); -export const DomainProfileRef = builder.objectRef("DomainProfile"); +export const DomainProfileRef = builder.objectRef("DomainProfile"); DomainProfileRef.implement({ description: @@ -340,12 +371,19 @@ ResolvedInterfaceRecordRef.implement({ //////////////////// // ResolvedRecords //////////////////// -export const ResolvedRecordsRef = - builder.objectRef>("ResolvedRecords"); +export type { ResolvedRecordsModel }; + +export const ResolvedRecordsRef = builder.objectRef("ResolvedRecords"); ResolvedRecordsRef.implement({ description: "Records resolved for a specific ENS name via the ENS protocol.", fields: (t) => ({ + id: t.field({ + description: "Stable cache key for these records: the InterpretedName used to resolve them.", + type: "UID", + nullable: false, + resolve: (parent) => parent.id, + }), reverseName: t.string({ description: "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", @@ -456,11 +494,26 @@ export type PrimaryNameRecordModel = { coinType: CoinType; chain: ENSIP19ChainValue | null; name: InterpretedName | null; - disableAcceleration: boolean; - canAccelerate: boolean; }; -export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); +/** GraphQL parent for `PrimaryNameRecord`, including `AccountResolve` acceleration settings. */ +export type PrimaryNameRecordParent = PrimaryNameRecordModel & { + accelerate: boolean; +}; + +type PrimaryNameRecordsResult = { + trace: TracingTrace; + records: ResolvedRecordsModel; +}; + +type PrimaryNameResolveModel = { + parent: PrimaryNameRecordParent; + recordsResolution: Promise | null; +}; + +export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); +export const PrimaryNameResolveRef = + builder.objectRef("PrimaryNameResolve"); PrimaryNameRecordRef.implement({ description: "An ENSIP-19 primary name for an Account on a specific coin type.", @@ -485,33 +538,75 @@ PrimaryNameRecordRef.implement({ nullable: true, resolve: (r) => (r.name ? { canonicalName: r.name } : null), }), + resolve: t.field({ + description: + "Resolve protocol-level records (and optionally profile preview) for this primary name.", + type: PrimaryNameResolveRef, + nullable: false, + resolve: (parent, _args, context, info) => { + const { name, accelerate } = parent; + const { canAccelerate } = context; + + const recordsSelection = name ? buildRecordsSelectionFromResolveContainerInfo(info) : null; + + const recordsResolution = + name && recordsSelection + ? runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ).then(({ trace, result }) => ({ + trace, + records: toResolvedRecordsModel(name, result), + })) + : null; + + return { parent, recordsResolution }; + }, + }), + }), +}); + +PrimaryNameResolveRef.implement({ + description: + "Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by resolution, represented as JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).trace as unknown as JsonValue; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this primary name resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ parent }, _args, context) => ({ + requested: parent.accelerate, + attempted: parent.accelerate && context.canAccelerate, + }), + }), records: t.field({ description: "Forward-resolve ENS records for the validated primary name. Null when `name` is null.", type: ResolvedRecordsRef, nullable: true, tracing: true, - resolve: async (parent, _args, context, info) => { - const name = parent.name; - if (!name) return null; - - const recordsSelection = buildRecordsSelectionFromResolveInfo(info); - const { result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { - accelerate: !parent.disableAcceleration, - canAccelerate: context.canAccelerate, - }), - ); - - return result as ResolverRecordsResponseBase; + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).records; }, }), - profile: t.field({ - description: - "PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved.", - type: DomainProfileRef, - nullable: false, - resolve: () => ({}), + ...(INCLUDE_DEV_METHODS && { + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved.", + type: DomainProfileRef, + nullable: true, + resolve: ({ parent }) => (parent.name ? {} : null), + }), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index bc272068fc..108aabe961 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -11,6 +11,7 @@ import { isInterfaceId, isInterpretedLabel, isInterpretedName, + type JsonValue, type Name, type Node, type NormalizedAddress, @@ -22,6 +23,7 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, + type UID, } from "enssdk"; import { isHex, size } from "viem"; import { z } from "zod/v4"; @@ -40,6 +42,12 @@ builder.scalarType("BigInt", { parseValue: (value) => z.coerce.bigint().parse(value), }); +builder.scalarType("JSON", { + description: "JSON represents arbitrary JSON-serializable data.", + serialize: (value: JsonValue) => value, + parseValue: (value) => z.unknown().parse(value) as JsonValue, +}); + builder.scalarType("Address", { description: "Address represents an EVM Address in all lowercase.", serialize: (value: NormalizedAddress) => value, @@ -116,6 +124,16 @@ builder.scalarType("Node", { .parse(value), }); +builder.scalarType("UID", { + description: "UID is a stable cache key for records/profile entities.", + serialize: (value: UID) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as UID) + .parse(value), +}); + builder.scalarType("InterpretedName", { description: "InterpretedName represents an enssdk#InterpretedName.", serialize: (value: Name) => value, diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 0183c03cc1..a38978d79a 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -7,6 +7,7 @@ import { byIdLookupResolvers } from "./by-id-lookup-resolvers"; import { localBigIntResolvers } from "./local-bigint-resolvers"; import { localConnectionResolvers } from "./local-connection-resolvers"; import { mergeResolverMaps } from "./merge-resolver-maps"; +import { recordsProfileCacheResolvers } from "./records-profile-cache-resolvers"; /** * Entities without keys are 'Embedded Data', and we tell graphcache about them to avoid warnings @@ -37,13 +38,24 @@ export const omnigraphCacheExchange = cacheExchange({ return typeof key === "string" ? key : null; }, + // ResolvedRecords are keyable by just `id` + ResolvedRecords: (data) => { + const key = data.id; + return typeof key === "string" ? key : null; + }, + // These entities are Embedded Data and don't have a relevant key Label: EMBEDDED_DATA, WrappedBaseRegistrarRegistration: EMBEDDED_DATA, CanonicalName: EMBEDDED_DATA, DomainCanonical: EMBEDDED_DATA, DomainResolver: EMBEDDED_DATA, + DomainResolve: EMBEDDED_DATA, + AccountResolve: EMBEDDED_DATA, + PrimaryNameResolve: EMBEDDED_DATA, + ResolutionStatus: EMBEDDED_DATA, PrimaryNameRecord: EMBEDDED_DATA, + // dont forget to add cache strategy when DomainProfile is wired DomainProfile: EMBEDDED_DATA, ProfileAvatar: EMBEDDED_DATA, ProfileBanner: EMBEDDED_DATA, @@ -55,7 +67,6 @@ export const omnigraphCacheExchange = cacheExchange({ ResolvedAddressRecord: EMBEDDED_DATA, ResolvedInterfaceRecord: EMBEDDED_DATA, ResolvedPubkeyRecord: EMBEDDED_DATA, - ResolvedRecords: EMBEDDED_DATA, ResolvedRawTextRecord: EMBEDDED_DATA, }, resolvers: mergeResolverMaps( @@ -67,5 +78,6 @@ export const omnigraphCacheExchange = cacheExchange({ // produce local cache resolvers for the Query.entity(by: { }) lookups byIdLookupResolvers, + recordsProfileCacheResolvers, ), }); diff --git a/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts b/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts new file mode 100644 index 0000000000..0d532db56a --- /dev/null +++ b/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts @@ -0,0 +1,63 @@ +import type { Cache, ResolveInfo, Resolver, Variables } from "@urql/exchange-graphcache"; + +/** + * Delegates to graphcache network resolution when no cached entity is found locally. + */ +const passthrough = (args: Variables, cache: Cache, info: ResolveInfo) => + cache.resolve(info.parentTypeName, info.fieldName, args); + +const asEntityKey = (value: unknown): string | null => (typeof value === "string" ? value : null); + +const lookupCachedRecordsByInterpretedName = (cache: Cache, interpretedName: string) => { + const key = cache.keyOfEntity({ __typename: "ResolvedRecords", id: interpretedName }); + if (key && cache.resolve(key, "id")) return key; + return undefined; +}; + +const resolveInterpretedNameFromCanonical = (cache: Cache, parentKey: string): string | null => { + const canonicalKey = asEntityKey(cache.resolve(parentKey, "canonical")); + if (!canonicalKey) return null; + + const nameKey = asEntityKey(cache.resolve(canonicalKey, "name")); + if (!nameKey) return null; + + const interpreted = cache.resolve(nameKey, "interpreted"); + return typeof interpreted === "string" ? interpreted : null; +}; + +const resolveInterpretedNameFromPrimaryNameRecord = ( + cache: Cache, + parentKey: string, +): string | null => { + const nameKey = asEntityKey(cache.resolve(parentKey, "name")); + if (!nameKey) return null; + + const interpreted = cache.resolve(nameKey, "interpreted"); + return typeof interpreted === "string" ? interpreted : null; +}; + +const resolveRecordsFromParentName: Resolver = (parent, args, cache, info) => { + const parentKey = asEntityKey(parent); + if (!parentKey) return passthrough(args, cache, info); + + const interpreted = + info.parentTypeName === "PrimaryNameRecord" || info.parentTypeName === "PrimaryNameResolve" + ? resolveInterpretedNameFromPrimaryNameRecord(cache, parentKey) + : resolveInterpretedNameFromCanonical(cache, parentKey); + + if (interpreted) { + const cached = lookupCachedRecordsByInterpretedName(cache, interpreted); + if (cached) return cached; + } + + return passthrough(args, cache, info); +}; + +export const recordsProfileCacheResolvers: Record> = { + DomainResolve: { + records: resolveRecordsFromParentName, + }, + PrimaryNameResolve: { + records: resolveRecordsFromParentName, + }, +}; diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 62885d3cc0..92a1e3676b 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -209,9 +209,11 @@ query DomainRecords( ) { domain(by: { name: $name }) { canonical { name { interpreted } } - records { - addresses(coinTypes: [60]) { coinType address } - texts(keys: ["description"]) { key value } + resolve { + records { + addresses(coinTypes: [60]) { coinType address } + texts(keys: ["description"]) { key value } + } } } }`, @@ -338,14 +340,18 @@ query AccountDomains( query AccountPrimaryNames($address: Address!) { account(by: { address: $address }) { address - primaryNames(by: { chains: [ETHEREUM, BASE] }) { - coinType - chain - name { interpreted beautified } - records { - addresses(coinTypes: [60]) { - coinType - address + resolve { + primaryNames(where: { chains: [ETH, BASE] }) { + coinType + chain + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { + coinType + address + } + } } } } @@ -357,36 +363,6 @@ query AccountPrimaryNames($address: Address!) { [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ACCOUNT }, }, }, - - ////////////////// - // Domain Profile - ////////////////// - { - id: "domain-profile", - query: ` -query DomainProfile($name: InterpretedName!) { - domain(by: { name: $name }) { - profile { - description - avatar { url } - banner { url } - website { url } - addresses { ethereum base bitcoin solana } - socials { - github { handle url } - telegram { handle url } - twitter { handle url } - } - } - } -}`, - variables: { - default: { name: "vitalik.eth" }, - [ENSNamespaceIds.EnsTestEnv]: { name: "test.eth" }, - [ENSNamespaceIds.SepoliaV2]: { name: "test.eth" }, - }, - }, - //////////////////// // Account Events //////////////////// diff --git a/packages/enssdk/src/lib/types/ens.ts b/packages/enssdk/src/lib/types/ens.ts index 10efea7f4f..a89377c7fa 100644 --- a/packages/enssdk/src/lib/types/ens.ts +++ b/packages/enssdk/src/lib/types/ens.ts @@ -158,6 +158,11 @@ export type LiteralName = Name & { __brand: "LiteralName" }; */ export type InterpretedName = Name & { __brand: "InterpretedName" }; +/** + * Stable cache identity for normalized GraphQL entities (e.g. records/profile). + */ +export type UID = String; + /** * A Beautified Name is a Name produced for presentation in a UI from an {@link InterpretedName}. * diff --git a/packages/enssdk/src/lib/types/shared.ts b/packages/enssdk/src/lib/types/shared.ts index 21837dad79..fb483d1156 100644 --- a/packages/enssdk/src/lib/types/shared.ts +++ b/packages/enssdk/src/lib/types/shared.ts @@ -35,6 +35,17 @@ export type DatetimeISO8601 = string; */ export type UrlString = string; +/** + * Any JSON-serializable value. + */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + /** * String representation of {@link AccountId}. * diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 4507a335df..ff9e3b999c 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -30,6 +30,37 @@ const introspection = { "mutationType": null, "subscriptionType": null, "types": [ + { + "kind": "OBJECT", + "name": "AccelerationStatus", + "fields": [ + { + "name": "attempted", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "requested", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "Account", @@ -200,74 +231,6 @@ const introspection = { ], "isDeprecated": false }, - { - "name": "primaryName", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PrimaryNameRecord" - } - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PrimaryNameByInput" - } - } - }, - { - "name": "disableAcceleration", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - } - ], - "isDeprecated": false - }, - { - "name": "primaryNames", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PrimaryNameRecord" - } - } - } - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PrimaryNamesByInput" - } - } - }, - { - "name": "disableAcceleration", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - } - ], - "isDeprecated": false - }, { "name": "registryPermissions", "type": { @@ -306,6 +269,27 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolverPermissions", "type": { @@ -736,6 +720,39 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "INPUT_OBJECT", + "name": "AccountPrimaryNamesWhereInput", + "inputFields": [ + { + "name": "chains", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "ENSIP19Chain" + } + } + } + }, + { + "name": "coinTypes", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } + } + ], + "isOneOf": true + }, { "kind": "OBJECT", "name": "AccountRegistryPermissionsConnection", @@ -816,6 +833,86 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "AccountResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "primaryName", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "primaryNames", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + } + } + }, + "args": [ + { + "name": "where", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AccountPrimaryNamesWhereInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "AccountResolverPermissionsConnection", @@ -1252,33 +1349,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "profile", - "type": { - "kind": "OBJECT", - "name": "DomainProfile" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [ - { - "name": "disableAcceleration", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - } - ], - "isDeprecated": false - }, { "name": "registration", "type": { @@ -1338,6 +1408,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -1772,6 +1863,43 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "DomainResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "DomainResolver", @@ -1978,7 +2106,7 @@ const introspection = { "name": "ENSIP19Chain", "enumValues": [ { - "name": "ARBITRUM", + "name": "ARB1", "isDeprecated": false }, { @@ -1990,7 +2118,7 @@ const introspection = { "isDeprecated": false }, { - "name": "ETHEREUM", + "name": "ETH", "isDeprecated": false }, { @@ -1998,11 +2126,11 @@ const introspection = { "isDeprecated": false }, { - "name": "OPTIMISM", + "name": "OP", "isDeprecated": false }, { - "name": "SCROLL", + "name": "SCR", "isDeprecated": false } ] @@ -2133,33 +2261,6 @@ const introspection = { "args": [], "isDeprecated": false }, - { - "name": "profile", - "type": { - "kind": "OBJECT", - "name": "DomainProfile" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [ - { - "name": "disableAcceleration", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - } - ], - "isDeprecated": false - }, { "name": "registration", "type": { @@ -2219,6 +2320,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -2760,33 +2882,6 @@ const introspection = { ], "isDeprecated": false }, - { - "name": "profile", - "type": { - "kind": "OBJECT", - "name": "DomainProfile" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [ - { - "name": "disableAcceleration", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - } - ], - "isDeprecated": false - }, { "name": "registration", "type": { @@ -2846,6 +2941,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -3835,6 +3951,10 @@ const introspection = { "kind": "SCALAR", "name": "InterpretedName" }, + { + "kind": "SCALAR", + "name": "JSON" + }, { "kind": "OBJECT", "name": "Label", @@ -4876,61 +4996,56 @@ const introspection = { "isDeprecated": false }, { - "name": "profile", + "name": "resolve", "type": { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "DomainProfile" + "name": "PrimaryNameResolve" } }, "args": [], "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [], - "isDeprecated": false } ], "interfaces": [] }, { - "kind": "INPUT_OBJECT", - "name": "PrimaryNamesByInput", - "inputFields": [ + "kind": "OBJECT", + "name": "PrimaryNameResolve", + "fields": [ { - "name": "chains", + "name": "acceleration", "type": { - "kind": "LIST", + "kind": "NON_NULL", "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "ENUM", - "name": "ENSIP19Chain" - } + "kind": "OBJECT", + "name": "AccelerationStatus" } - } + }, + "args": [], + "isDeprecated": false }, { - "name": "coinTypes", + "name": "records", "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "CoinType" - } - } - } + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false } ], - "isOneOf": true + "interfaces": [] }, { "kind": "OBJECT", @@ -6314,6 +6429,18 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "UID" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "interfaces", "type": { @@ -7074,6 +7201,10 @@ const introspection = { } ] }, + { + "kind": "SCALAR", + "name": "UID" + }, { "kind": "OBJECT", "name": "WrappedBaseRegistrarRegistration", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 0f39d01c2b..9ac1da7c44 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -1,3 +1,12 @@ +"""Execution status metadata for a resolver strategy.""" +type AccelerationStatus { + """Whether this strategy was attempted at runtime.""" + attempted: Boolean! + + """Whether this strategy was requested by the caller.""" + requested: Boolean! +} + """Represents an individual Account, keyed by its Address.""" type Account { """An EVM Address that uniquely identifies this Account on-chain.""" @@ -19,29 +28,19 @@ type Account { """ permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection - """ - The ENSIP-19 primary name for this Account on a specific coin type or chain. - """ - primaryName( - by: PrimaryNameByInput! - - """When true, disables protocol acceleration feature.""" - disableAcceleration: Boolean = false - ): PrimaryNameRecord! + """The Permissions on Registries granted to this Account.""" + registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection """ - ENSIP-19 primary names for this Account on the requested coin types or chains. + Resolve primary names for this Account with protocol acceleration controls. """ - primaryNames( - """Select coin types or chains to resolve primary names for.""" - by: PrimaryNamesByInput! - - """When true, disables protocol acceleration feature.""" - disableAcceleration: Boolean = false - ): [PrimaryNameRecord!]! - - """The Permissions on Registries granted to this Account.""" - registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): AccountResolve! """The Permissions on Resolvers granted to this Account.""" resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection @@ -140,6 +139,17 @@ input AccountPermissionsWhereInput { contract: AccountIdInput } +""" +Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. +""" +input AccountPrimaryNamesWhereInput @oneOf { + """ENSIP-19 supported chains to resolve primary names for.""" + chains: [ENSIP19Chain!] + + """Coin types to resolve primary names for.""" + coinTypes: [CoinType!] +} + type AccountRegistryPermissionsConnection { edges: [AccountRegistryPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -151,6 +161,35 @@ type AccountRegistryPermissionsConnectionEdge { node: RegistryPermissionsUser! } +""" +Nested account resolution container exposing primary-name resolution with shared acceleration settings. +""" +type AccountResolve { + """Protocol acceleration strategy status for this Account resolution.""" + acceleration: AccelerationStatus! + + """ + The ENSIP-19 primary name for this Account on a specific coin type or chain. + """ + primaryName( + """Select a coin type or chain to resolve a primary name for.""" + by: PrimaryNameByInput! + ): PrimaryNameRecord! + + """ + ENSIP-19 primary names for this Account on the requested coin types or chains. + """ + primaryNames( + """Select coin types or chains to resolve primary names for.""" + where: AccountPrimaryNamesWhereInput! + ): [PrimaryNameRecord!]! + + """ + Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability. + """ + trace: JSON +} + type AccountResolverPermissionsConnection { edges: [AccountResolverPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -287,19 +326,6 @@ interface Domain { """ parent: Domain - """ - PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. - """ - profile: DomainProfile - - """ - Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. - """ - records( - """When true, disables protocol acceleration feature.""" - disableAcceleration: Boolean = false - ): ResolvedRecords - """The latest Registration for this Domain, if exists.""" registration: Registration @@ -309,6 +335,17 @@ interface Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -408,6 +445,24 @@ type DomainRegistrationsConnectionEdge { node: Registration! } +""" +Nested domain resolution container exposing trace/acceleration metadata and resolved data. +""" +type DomainResolve { + """Protocol acceleration strategy status for this Domain resolution.""" + acceleration: AccelerationStatus! + + """ + Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. + """ + trace: JSON +} + """Metadata describing this Domain's relationship to its Resolver(s).""" type DomainResolver { """ @@ -475,16 +530,17 @@ input DomainsWhereInput { } """ -ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. Non-EVM coin types are intentionally absent. +ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. +@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. """ enum ENSIP19Chain { - ARBITRUM + ARB1 BASE DEFAULT - ETHEREUM + ETH LINEA - OPTIMISM - SCROLL + OP + SCR } """An ENS protocol version.""" @@ -522,19 +578,6 @@ type ENSv1Domain implements Domain { """ parent: Domain - """ - PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. - """ - profile: DomainProfile - - """ - Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. - """ - records( - """When true, disables protocol acceleration feature.""" - disableAcceleration: Boolean = false - ): ResolvedRecords - """The latest Registration for this Domain, if exists.""" registration: Registration @@ -544,6 +587,17 @@ type ENSv1Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -647,19 +701,6 @@ type ENSv2Domain implements Domain { """ permissions(after: String, before: String, first: Int, last: Int, where: DomainPermissionsWhereInput): ENSv2DomainPermissionsConnection - """ - PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical. - """ - profile: DomainProfile - - """ - Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. - """ - records( - """When true, disables protocol acceleration feature.""" - disableAcceleration: Boolean = false - ): ResolvedRecords - """The latest Registration for this Domain, if exists.""" registration: Registration @@ -669,6 +710,17 @@ type ENSv2Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -952,6 +1004,9 @@ scalar InterpretedLabel """InterpretedName represents an enssdk#InterpretedName.""" scalar InterpretedName +"""JSON represents arbitrary JSON-serializable data.""" +scalar JSON + """ Represents a Label within ENS, providing its hash and interpreted representation. """ @@ -1191,25 +1246,29 @@ type PrimaryNameRecord { name: CanonicalName """ - PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved. + Resolve protocol-level records (and optionally profile preview) for this primary name. + """ + resolve: PrimaryNameResolve! +} + +""" +Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data. +""" +type PrimaryNameResolve { + """ + Protocol acceleration strategy status for this primary name resolution. """ - profile: DomainProfile! + acceleration: AccelerationStatus! """ Forward-resolve ENS records for the validated primary name. Null when `name` is null. """ records: ResolvedRecords -} - -""" -Select primary name lookup targets. Exactly one of `coinTypes` or `chains` must be provided. -""" -input PrimaryNamesByInput @oneOf { - """ENSIP-19 supported chains to resolve primary names for.""" - chains: [ENSIP19Chain!] - """Coin types to resolve primary names for.""" - coinTypes: [CoinType!] + """ + Protocol trace tree emitted by resolution, represented as JSON for schema stability. + """ + trace: JSON } """ @@ -1540,6 +1599,11 @@ type ResolvedRecords { """The IDNSZoneResolver zonehash raw bytes, or null if not set.""" dnszonehash: Hex + """ + Stable cache key for these records: the InterpretedName used to resolve them. + """ + id: UID! + """Resolved ERC-165 interface implementer records for the requested ids.""" interfaces( """ERC-165 interface ids to resolve (4-byte hex selectors).""" @@ -1711,6 +1775,9 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } +"""UID is a stable cache key for records/profile entities.""" +scalar UID + """ Additional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name) """ diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index 31b7ba80bf..e3d6edfac7 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -10,6 +10,7 @@ import type { InterfaceId, InterpretedLabel, InterpretedName, + JsonValue, Node, NormalizedAddress, PermissionsId, @@ -20,6 +21,7 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + UID, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -39,12 +41,14 @@ export type OmnigraphScalars = { // the omnigraph returns serialized bigint values from the api; further deserialization is // handled by enskit's graphcache local resolvers (see cache-exchange.ts) BigInt: `${bigint}`; + JSON: JsonValue; Address: NormalizedAddress; Hex: Hex; ChainId: ChainId; CoinType: CoinType; InterfaceId: InterfaceId; InterpretedName: InterpretedName; + UID: UID; InterpretedLabel: InterpretedLabel; BeautifiedName: BeautifiedName; BeautifiedLabel: BeautifiedLabel; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ccc2b135..f3917c5020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: apps/ensapi: dependencies: + '@ensdomains/address-encoder': + specifier: ^1.1.2 + version: 1.1.4 '@ensdomains/ensjs': specifier: ^4.0.2 version: 4.0.2(typescript@5.9.3)(viem@2.50.3(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6) From f5bacdd3ae188644c5dd34bdca3d46e455ec7791 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 13:28:55 +0300 Subject: [PATCH 20/30] self review --- .../handlers/api/omnigraph/omnigraph-api.ts | 15 ++++- .../account-primary-names-selection.test.ts | 4 +- .../account-primary-names-selection.ts | 64 +++++++++++-------- .../resolution/records-selection-config.ts | 24 +++++-- .../lib/resolution/records-selection.test.ts | 14 ++++ .../lib/resolution/records-selection.ts | 14 ++++ .../src/omnigraph-api/schema/account.ts | 24 +++++-- .../ensapi/src/omnigraph-api/schema/domain.ts | 3 - .../src/omnigraph-api/schema/resolution.ts | 5 +- .../src/omnigraph-api/schema/scalars.ts | 2 +- .../src/omnigraph/generated/schema.graphql | 2 +- 11 files changed, 122 insertions(+), 49 deletions(-) diff --git a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index 3713494f58..8f9f984195 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -6,11 +6,16 @@ import { } from "@ensnode/ensnode-sdk"; import di from "@/di"; +import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; +/** + * The maximum distance (in seconds) from the current time to the latest indexed block + * for a chain to be considered "realtime" and thus eligible for protocol acceleration. + */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 600; // 10 minutes const app = createApp({ @@ -25,12 +30,16 @@ app.use(async (c, next) => { const configPrerequisite = hasOmnigraphApiConfigSupport(di.context.stackInfo.ensIndexer); // 503 if Omnigraph API is not available due to config prerequisites not met if (!configPrerequisite.supported) { - return c.text(`Service Unavailable: ${configPrerequisite.reason}`, 503); + return errorResponse(c, `Service Unavailable: ${configPrerequisite.reason}`, 503); } // 503 if indexing status snapshot is not available yet if (c.var.indexingStatus instanceof Error) { - return c.text(`Service Unavailable: Indexing Status Snapshot is not available yet`, 503); + return errorResponse( + c, + "Service Unavailable: Indexing Status Snapshot is not available yet", + 503, + ); } // 503 if omnigraph API not available due to indexing status prerequisites not met @@ -39,7 +48,7 @@ app.use(async (c, next) => { ); if (!indexingStatusPrerequisite.supported) { - return c.text(`Service Unavailable: ${indexingStatusPrerequisite.reason}`, 503); + return errorResponse(c, `Service Unavailable: ${indexingStatusPrerequisite.reason}`, 503); } await next(); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts index edcb5f3bf3..037a6030a9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -99,11 +99,11 @@ describe("buildAccountPrimaryNamesSelection", () => { expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); }); - it("prefers primaryNames over primaryName when both are selected", () => { + it("merges coin types from primaryName and primaryNames when both are selected", () => { const info = resolveInfoForAccountResolveSubselection(` primaryName(by: { coinType: 0 }) { name } primaryNames(where: { coinTypes: [60] }) { name } `); - expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60, 0]); }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts index d3e9d8697a..a3c520d8b6 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts @@ -18,40 +18,52 @@ import { collectNamedSubFieldNodes } from "@/omnigraph-api/lib/resolution/record /** * Derives primary-name coin types from `Account.resolve { primaryName | primaryNames }`, or null * when neither field is selected. + * + * This function merges all requested coin types across multiple field nodes (e.g. from fragments + * or aliases) to ensure the resolver resolves everything needed by the client. */ export function buildAccountPrimaryNamesSelection(info: GraphQLResolveInfo): CoinType[] | null { - const primaryNamesFieldNodes = info.fieldNodes.flatMap((resolveField) => { - const selectionSet = resolveField.selectionSet; - if (!selectionSet) return []; - return collectNamedSubFieldNodes(selectionSet, "primaryNames", info); - }); - - const primaryNameFieldNodes = info.fieldNodes.flatMap((resolveField) => { - const selectionSet = resolveField.selectionSet; - if (!selectionSet) return []; - return collectNamedSubFieldNodes(selectionSet, "primaryName", info); - }); - - if (primaryNamesFieldNodes.length === 0 && primaryNameFieldNodes.length === 0) { - return null; - } - const resolveReturnType = getNamedType(info.returnType); if (!isObjectType(resolveReturnType)) { throw new GraphQLError("Return type must be an object type."); } - if (primaryNamesFieldNodes.length > 0) { - const fieldDef = resolveReturnType.getFields().primaryNames; - if (!fieldDef) return null; + // Use a Set to collect and deduplicate all requested coin types across all field nodes + const coinTypes = new Set(); - const args = getArgumentValues(fieldDef, primaryNamesFieldNodes[0], info.variableValues); - return normalizeAccountPrimaryNamesWhereInput(args.where as AccountPrimaryNamesWhereInput); - } + // Iterate over all 'resolve' field nodes in the query (there might be multiple due to fragments) + for (const resolveField of info.fieldNodes) { + const selectionSet = resolveField.selectionSet; + if (!selectionSet) continue; - const fieldDef = resolveReturnType.getFields().primaryName; - if (!fieldDef) return null; + // 1. Process all 'primaryNames(where: { ... })' field selections + const primaryNamesFieldNodes = collectNamedSubFieldNodes(selectionSet, "primaryNames", info); + const primaryNamesFieldDef = resolveReturnType.getFields().primaryNames; + if (primaryNamesFieldDef) { + for (const node of primaryNamesFieldNodes) { + // Extract arguments from this specific field node (handles variables and aliases) + const args = getArgumentValues(primaryNamesFieldDef, node, info.variableValues); + const normalized = normalizeAccountPrimaryNamesWhereInput( + args.where as AccountPrimaryNamesWhereInput, + ); + // Add all requested coin types from this 'primaryNames' call to our set + for (const coinType of normalized) coinTypes.add(coinType); + } + } + + // 2. Process all 'primaryName(by: { ... })' field selections + const primaryNameFieldNodes = collectNamedSubFieldNodes(selectionSet, "primaryName", info); + const primaryNameFieldDef = resolveReturnType.getFields().primaryName; + if (primaryNameFieldDef) { + for (const node of primaryNameFieldNodes) { + // Extract arguments from this specific field node + const args = getArgumentValues(primaryNameFieldDef, node, info.variableValues); + // Add the single requested coin type from this 'primaryName' call to our set + coinTypes.add(normalizePrimaryNameByInput(args.by as PrimaryNameByInput)); + } + } + } - const args = getArgumentValues(fieldDef, primaryNameFieldNodes[0], info.variableValues); - return [normalizePrimaryNameByInput(args.by as PrimaryNameByInput)]; + // Return the merged list of unique coin types, or null if no primary name fields were selected + return coinTypes.size > 0 ? [...coinTypes] : null; } diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts index d03600576a..a33c8a110f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts @@ -48,7 +48,10 @@ export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ argName: "keys", recordsSelectionKey: "texts", applyToRecordsSelection: (recordsSelection, args) => { - recordsSelection.texts = args.keys as string[]; + const keys = args.keys as string[] | undefined; + if (keys && keys.length > 0) { + recordsSelection.texts = [...new Set([...(recordsSelection.texts ?? []), ...keys])]; + } }, }, { @@ -56,7 +59,12 @@ export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ argName: "coinTypes", recordsSelectionKey: "addresses", applyToRecordsSelection: (recordsSelection, args) => { - recordsSelection.addresses = args.coinTypes as CoinType[]; + const coinTypes = args.coinTypes as CoinType[] | undefined; + if (coinTypes && coinTypes.length > 0) { + recordsSelection.addresses = [ + ...new Set([...(recordsSelection.addresses ?? []), ...coinTypes]), + ]; + } }, }, { @@ -64,7 +72,10 @@ export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ argName: "contentTypeMask", recordsSelectionKey: "abi", applyToRecordsSelection: (recordsSelection, args) => { - recordsSelection.abi = args.contentTypeMask as ContentType; + const contentTypeMask = args.contentTypeMask as ContentType | undefined; + if (contentTypeMask !== undefined) { + recordsSelection.abi = (recordsSelection.abi ?? 0n) | contentTypeMask; + } }, }, { @@ -72,7 +83,12 @@ export const RECORDS_SELECTION_PARAMETRIC_FIELDS = [ argName: "ids", recordsSelectionKey: "interfaces", applyToRecordsSelection: (recordsSelection, args) => { - recordsSelection.interfaces = args.ids as InterfaceId[]; + const ids = args.ids as InterfaceId[] | undefined; + if (ids && ids.length > 0) { + recordsSelection.interfaces = [ + ...new Set([...(recordsSelection.interfaces ?? []), ...ids]), + ]; + } }, }, ] as const satisfies readonly RecordsSelectionParametricField[]; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index 701c25abd6..aa87a9f6c9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -188,6 +188,20 @@ describe("buildRecordsSelectionFromResolveInfo", () => { }); }); + it("merges parametric fields with different arguments (aliases)", () => { + const info = resolveInfoForRecordsSubselection(` + avatar: texts(keys: ["avatar"]) + description: texts(keys: ["description"]) + eth: addresses(coinTypes: [60]) + btc: addresses(coinTypes: [0]) + `); + + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ + texts: ["avatar", "description"], + addresses: [60, 0], + }); + }); + it("throws when selection is empty", () => { const info = resolveInfoForRecordsSubselection("__typename"); expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts index 9edcae5ad7..9f70c23e01 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -64,11 +64,18 @@ export function collectNamedSubFieldNodes( return fields; } +/** + * Translates a GraphQL selection set on a 'records' field into a flat {@link ResolverRecordsSelection}. + * + * This function handles merging selections from multiple field nodes (e.g. from fragments or aliases) + * and correctly maps both simple (boolean) and parametric (keyed-args) record types. + */ function buildRecordsSelectionFromRecordsFieldNodes( recordsFieldNodes: readonly FieldNode[], recordsReturnType: GraphQLObjectType, info: GraphQLResolveInfo, ): ResolverRecordsSelection { + // 1. Collect all selections from all 'records' field nodes (merging fragments and aliases) const graphqlSelections = recordsFieldNodes.flatMap( (node) => node.selectionSet?.selections ?? [], ); @@ -77,6 +84,7 @@ function buildRecordsSelectionFromRecordsFieldNodes( throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); } + // Create a virtual selection set to process all collected selections together const mergedGraphqlSelectionSet: SelectionSetNode = { kind: Kind.SELECTION_SET, selections: graphqlSelections, @@ -84,22 +92,28 @@ function buildRecordsSelectionFromRecordsFieldNodes( const recordsSelection: ResolverRecordsSelection = {}; + // 2. Iterate over each selected field to build the ResolverRecordsSelection object for (const childField of collectFieldNodes(mergedGraphqlSelectionSet, info)) { const graphqlField = childField.name.value; + // A. Handle 'simple' fields (e.g. contenthash, pubkey) which map to boolean flags const simple = getSimpleRecordsSelectionField(graphqlField); if (simple) { recordsSelection[simple.recordsSelectionKey] = true; continue; } + // B. Handle 'parametric' fields (e.g. texts, addresses) which require arguments const parametric = getParametricRecordsSelectionField(graphqlField); if (!parametric) continue; const fieldDef = recordsReturnType.getFields()[graphqlField]; if (!fieldDef) continue; + // Extract arguments for this specific field node (handles variables and aliases) const args = getArgumentValues(fieldDef, childField, info.variableValues); + + // Apply the arguments to the recordsSelection object (merging with any existing values) parametric.applyToRecordsSelection(recordsSelection, args); } diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 4284f4b092..71e3aced5c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -12,6 +12,10 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r 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 { + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { @@ -298,14 +302,15 @@ AccountResolveRef.implement({ description: "Select a coin type or chain to resolve a primary name for.", }), }, - resolve: async ({ primaryNamesResolution, accelerate }) => { + resolve: async ({ primaryNamesResolution, accelerate }, { by }) => { if (!primaryNamesResolution) { throw new Error("primaryName requires a primary-name resolution to be started."); } + const coinType = normalizePrimaryNameByInput(by); const { records } = await primaryNamesResolution; - const [record] = records; + const record = records.find((r) => r.coinType === coinType); if (!record) { - throw new Error("Missing primary name record for requested coin type."); + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); } return { ...record, accelerate }; }, @@ -321,12 +326,21 @@ AccountResolveRef.implement({ description: "Select coin types or chains to resolve primary names for.", }), }, - resolve: async ({ primaryNamesResolution, accelerate }) => { + resolve: async ({ primaryNamesResolution, accelerate }, { where }) => { if (!primaryNamesResolution) { throw new Error("primaryNames requires a primary-name resolution to be started."); } + const coinTypes = normalizeAccountPrimaryNamesWhereInput(where); const { records } = await primaryNamesResolution; - return records.map((record) => ({ ...record, accelerate })); + + // return records in the order of requested coinTypes + return coinTypes.map((coinType) => { + const record = records.find((r) => r.coinType === coinType); + if (!record) { + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); + } + return { ...record, accelerate }; + }); }, }), }), diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 09d286fc4c..13f8d9dacd 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -47,10 +47,7 @@ import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; import { AccelerationStatusRef, - AccountPrimaryNamesWhereInput, DomainProfileRef, - PrimaryNameByInput, - PrimaryNameRecordRef, ResolvedRecordsRef, } from "@/omnigraph-api/schema/resolution"; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 44e3d3fade..68bcdf7d5c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -22,10 +22,7 @@ import { type ResolvedRecordsModel, toResolvedRecordsModel, } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { - buildRecordsSelectionFromResolveContainerInfo, - buildRecordsSelectionFromResolveInfo, -} from "@/omnigraph-api/lib/resolution/records-selection"; +import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; export type AccelerationStatusModel = { diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 108aabe961..e2e6e8814a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -86,7 +86,7 @@ builder.scalarType("CoinType", { }); builder.scalarType("InterfaceId", { - description: "InterfaceId represents a ERC-165 interface id (4-byte hex selector).", + description: "InterfaceId represents an ERC-165 interface id (4-byte hex selector).", serialize: (value: InterfaceId) => value, parseValue: (value) => z.coerce diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 9ac1da7c44..b0fab814ce 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -995,7 +995,7 @@ input EventsWhereInput { """Hex represents viem#Hex.""" scalar Hex -"""InterfaceId represents a ERC-165 interface id (4-byte hex selector).""" +"""InterfaceId represents an ERC-165 interface id (4-byte hex selector).""" scalar InterfaceId """InterpretedLabel represents an enssdk#InterpretedLabel.""" From c5330f0707d566770bb62b9b193cf35de6259ab4 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 13:58:43 +0300 Subject: [PATCH 21/30] fix for greptile review --- .../lib/resolution/records-selection.test.ts | 6 ++ .../schema/domain.integration.test.ts | 59 +++++++++++++++++++ .../ensapi/src/omnigraph-api/schema/domain.ts | 7 ++- .../src/omnigraph-api/schema/resolution.ts | 40 +++++++++---- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index aa87a9f6c9..13605ff20c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -194,11 +194,17 @@ describe("buildRecordsSelectionFromResolveInfo", () => { description: texts(keys: ["description"]) eth: addresses(coinTypes: [60]) btc: addresses(coinTypes: [0]) + abi1: abi(contentTypeMask: "1") + abi2: abi(contentTypeMask: "2") + i1: interfaces(ids: ["0x01020304"]) + i2: interfaces(ids: ["0x05060708"]) `); expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ texts: ["avatar", "description"], addresses: [60, 0], + abi: 3n, + interfaces: ["0x01020304", "0x05060708"], }); }); 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 0afc42ea07..fe40cad85a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -613,6 +613,65 @@ describe("Domain.records", () => { }, }); }); + + it("returns null for an unnormalized canonical name (e.g. with labelhash)", async () => { + // A name with a labelhash is an InterpretedName but not a normalized name. + // Even if it exists in the DB, resolve should return null. + const unnormalizedName = + "[0000000000000000000000000000000000000000000000000000000000000000].eth"; + await expect( + request(DomainRecords, { + name: unnormalizedName, + addresses: [60], + texts: ["description"], + }), + ).resolves.toMatchObject({ + domain: null, + }); + }); + + it("returns null for an ABI alias that does not match the returned content type", async () => { + // test.eth has ABI with contentType 1 (JSON) + // If we ask for contentType 2 (zlib-JSON), it should return null + const DomainRecordsAbi = gql` + query DomainRecordsAbi($name: InterpretedName!, $mask1: BigInt!, $mask2: BigInt!) { + domain(by: { name: $name }) { + resolve { + records { + abi1: abi(contentTypeMask: $mask1) { contentType data } + abi2: abi(contentTypeMask: $mask2) { contentType data } + } + } + } + } + `; + + await expect( + request<{ + domain: { + resolve: { + records: { + abi1: { contentType: string; data: string } | null; + abi2: { contentType: string; data: string } | null; + }; + }; + }; + }>(DomainRecordsAbi, { + name: "test.eth", + mask1: "1", // JSON + mask2: "2", // zlib-JSON + }), + ).resolves.toMatchObject({ + domain: { + resolve: { + records: { + abi1: { contentType: "1", data: fixtures.abiBytes }, + abi2: null, + }, + }, + }, + }); + }); }); (INCLUDE_DEV_METHODS ? describe : describe.skip)("Domain.profile", () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 13f8d9dacd..422eaf76a4 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, JsonValue } from "enssdk"; +import { type DomainId, isNormalizedName, type JsonValue } from "enssdk"; import type { RequiredAndNotNull, RequiredAndNull, TracingTrace } from "@ensnode/ensnode-sdk"; @@ -205,7 +205,10 @@ DomainInterfaceRef.implement({ const { canAccelerate } = context; const name = domain.canonicalName; - const recordsSelection = name ? buildRecordsSelectionFromResolveContainerInfo(info) : null; + const recordsSelection = + name && isNormalizedName(name) + ? buildRecordsSelectionFromResolveContainerInfo(info) + : null; const recordsResolution = name && recordsSelection diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 68bcdf7d5c..37a9eb039b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,11 +1,12 @@ -import type { - Address, - CoinType, - Hex, - InterfaceId, - InterpretedName, - JsonValue, - NormalizedAddress, +import { + type Address, + type CoinType, + type Hex, + type InterfaceId, + type InterpretedName, + isNormalizedName, + type JsonValue, + type NormalizedAddress, } from "enssdk"; import type { TracingTrace } from "@ensnode/ensnode-sdk"; @@ -424,7 +425,23 @@ ResolvedRecordsRef.implement({ "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", }), }, - resolve: (r) => r.abi ?? null, + resolve: (r, { contentTypeMask }) => { + /* + ENSIP-4 ABIs are stored with a single-bit contentType (1=JSON, 2=zlib-JSON, etc). + The selection-building layer merges all requested contentTypeMasks from all 'abi' + field aliases into a single aggregate mask for the underlying resolution call. + At this resolver layer, we must verify that the specific ABI returned by the + protocol (which is the first one found matching the aggregate mask) actually + matches the specific bitmask requested by *this* GraphQL field alias. + + @see https://docs.ens.domains/ensip/4/ + */ + if (!r.abi) return null; + // check if the found contentType matches the requested contentTypeMask + const foundContentType = r.abi.contentType & contentTypeMask; + if (foundContentType === 0n) return null; + return r.abi; + }, }), interfaces: t.field({ description: "Resolved ERC-165 interface implementer records for the requested ids.", @@ -544,7 +561,10 @@ PrimaryNameRecordRef.implement({ const { name, accelerate } = parent; const { canAccelerate } = context; - const recordsSelection = name ? buildRecordsSelectionFromResolveContainerInfo(info) : null; + const recordsSelection = + name && isNormalizedName(name) + ? buildRecordsSelectionFromResolveContainerInfo(info) + : null; const recordsResolution = name && recordsSelection From 6f50a9d3da83ec36dc0fe18363499633f174f9d6 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 14:21:43 +0300 Subject: [PATCH 22/30] add EMBEDDED_DATA for AccelerationStatus --- packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index a38978d79a..e8b3f4b653 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -55,6 +55,7 @@ export const omnigraphCacheExchange = cacheExchange({ PrimaryNameResolve: EMBEDDED_DATA, ResolutionStatus: EMBEDDED_DATA, PrimaryNameRecord: EMBEDDED_DATA, + AccelerationStatus: EMBEDDED_DATA, // dont forget to add cache strategy when DomainProfile is wired DomainProfile: EMBEDDED_DATA, ProfileAvatar: EMBEDDED_DATA, From a0008eaa0760ead4967122fa8a8aeb906e4de111 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 14:54:41 +0300 Subject: [PATCH 23/30] self review again --- .../multichain-primary-name-resolution.ts | 6 +- .../lib/resolution/chain-coin-type.ts | 7 +- .../lib/resolution/primary-name-input.ts | 5 - .../lib/resolution/records-profile-model.ts | 8 +- .../resolve-primary-name-records.ts | 8 +- .../schema/resolution.integration.test.ts | 172 ++++++++++++++++++ 6 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index 9d2017adad..9a961c291a 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -1,5 +1,5 @@ import { trace } from "@opentelemetry/api"; -import { type Address, type CoinType, evmChainIdToCoinType } from "enssdk"; +import type { Address, CoinType } from "enssdk"; import { mainnet } from "viem/chains"; import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; @@ -36,10 +36,6 @@ export const getENSIP19SupportedChainIds = () => { ]); }; -/** Coin types corresponding to {@link getENSIP19SupportedChainIds} in the current namespace. */ -export const getENSIP19SupportedCoinTypes = (): CoinType[] => - uniq(getENSIP19SupportedChainIds().map(evmChainIdToCoinType)); - export type MultichainPrimaryNameByCoinTypeResolutionResult = Partial< Record >; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index 7a05cec62d..1b5f981fa9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -5,7 +5,7 @@ import type { CoinType } from "enssdk"; /** * address-encoder coin names for ENSIP-19 primary-name chains. */ -const ENSIP19_COIN_NAMES = [ +export const ENSIP19_COIN_NAMES = [ "default", "eth", "base", @@ -15,6 +15,11 @@ const ENSIP19_COIN_NAMES = [ "scr", ] as const satisfies readonly CoinName[]; +/** Canonical ENSIP-9 coin types for ENSIP-19 primary-name chains. */ +export const ENSIP19_COIN_TYPES = ENSIP19_COIN_NAMES.map( + (name) => coinNameToTypeMap[name] as CoinType, +); + export type ENSIP19ChainValue = Uppercase<(typeof ENSIP19_COIN_NAMES)[number]>; export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map((coinName) => diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts index 51cec2b214..a274c2b52c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -1,7 +1,6 @@ import type { CoinType } from "enssdk"; import { - coinTypeToEnsip19Chain, type ENSIP19ChainValue, ensip19ChainToCoinType, } from "@/omnigraph-api/lib/resolution/chain-coin-type"; @@ -31,7 +30,3 @@ export const normalizeAccountPrimaryNamesWhereInput = ( if (where.chains != null) return where.chains.map(ensip19ChainToCoinType); throw new Error("AccountPrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); }; - -/** Projects a coin type to its ENSIP19Chain enum value, if applicable. */ -export const projectCoinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => - coinTypeToEnsip19Chain(coinType); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts index 62c7c4f51b..303c28757b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts @@ -1,16 +1,16 @@ -import type { InterpretedName } from "enssdk"; +import type { InterpretedName, UID } from "enssdk"; import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; /** Cache key and resolution identity for {@link ResolvedRecordsRef}. */ export type ResolvedRecordsModel = Partial & { - id: InterpretedName; + id: UID; }; export const toResolvedRecordsModel = ( name: InterpretedName, - response: ResolverRecordsResponseBase | Partial, + response: Partial, ): ResolvedRecordsModel => ({ - id: name, + id: name.toString() satisfies UID, ...response, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index e8075d5459..0078668650 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -3,12 +3,14 @@ import type { Address, CoinType, InterpretedName } from "enssdk"; import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { - getENSIP19SupportedCoinTypes, type MultichainPrimaryNameByCoinTypeResolutionResult, resolvePrimaryNamesByCoinTypes, } from "@/lib/resolution/multichain-primary-name-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; -import { coinTypeToEnsip19Chain } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { + coinTypeToEnsip19Chain, + ENSIP19_COIN_TYPES, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/resolution"; type PrimaryNameResolutionOptions = { @@ -38,7 +40,7 @@ export async function resolvePrimaryNameRecords( coinTypes: CoinType[], options: PrimaryNameResolutionOptions, ): Promise { - const supportedCoinTypes = new Set(getENSIP19SupportedCoinTypes()); + const supportedCoinTypes = new Set(ENSIP19_COIN_TYPES); const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); const { trace, result: resolvedByCoinType } = diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts new file mode 100644 index 0000000000..7fcced2884 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { accounts } from "@ensnode/datasources/devnet"; + +import { request } from "@/test/integration/graphql-utils"; +import { gql } from "@/test/integration/omnigraph-api-client"; + +describe("Resolution Trace and Acceleration", () => { + const DomainResolution = gql` + query DomainResolution($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve { + trace + acceleration { + requested + attempted + } + records { + texts(keys: ["description"]) { + key + value + } + } + } + } + } + `; + + const AccountResolution = gql` + query AccountResolution($address: Address!) { + account(by: { address: $address }) { + resolve { + trace + acceleration { + requested + attempted + } + primaryName(by: { coinType: 60 }) { + name { interpreted } + resolve { + trace + acceleration { + requested + attempted + } + records { + addresses(coinTypes: [60]) { + coinType + address + } + } + } + } + } + } + } + `; + + it("returns trace and acceleration for Domain.resolve", async () => { + const result = await request(DomainResolution, { name: "example.eth" }); + const resolve = result.domain.resolve; + + expect(resolve.trace).toBeDefined(); + expect(Array.isArray(resolve.trace)).toBe(true); + expect(resolve.trace.length).toBeGreaterThan(0); + + expect(resolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + expect(resolve.records.texts).toContainEqual({ + key: "description", + value: "example.eth", + }); + }); + + it("returns trace and acceleration for Account.resolve and primaryName.resolve", async () => { + const result = await request(AccountResolution, { address: accounts.owner.address }); + const accountResolve = result.account.resolve; + + // Account.resolve.trace + expect(accountResolve.trace).toBeDefined(); + expect(Array.isArray(accountResolve.trace)).toBe(true); + expect(accountResolve.trace.length).toBeGreaterThan(0); + + // Account.resolve.acceleration + expect(accountResolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + const primaryName = accountResolve.primaryName; + expect(primaryName.name.interpreted).toBeDefined(); + + // primaryName.resolve.trace + expect(primaryName.resolve.trace).toBeDefined(); + expect(Array.isArray(primaryName.resolve.trace)).toBe(true); + expect(primaryName.resolve.trace.length).toBeGreaterThan(0); + + // primaryName.resolve.acceleration + expect(primaryName.resolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + expect(primaryName.resolve.records.addresses).toContainEqual({ + coinType: 60, + address: accounts.owner.address, + }); + }); + + it("respects accelerate: false in Domain.resolve", async () => { + const result = await request( + gql` + query DomainNoAccelerate($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve(accelerate: false) { + acceleration { + requested + attempted + } + } + } + } + `, + { name: "example.eth" }, + ); + + expect(result.domain.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + }); + + it("respects accelerate: false in Account.resolve", async () => { + const result = await request( + gql` + query AccountNoAccelerate($address: Address!) { + account(by: { address: $address }) { + resolve(accelerate: false) { + acceleration { + requested + attempted + } + primaryName(by: { coinType: 60 }) { + resolve { + acceleration { + requested + attempted + } + } + } + } + } + } + `, + { address: accounts.owner.address }, + ); + + expect(result.account.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + + // PrimaryNameResolve should inherit accelerate: false from Account.resolve + expect(result.account.resolve.primaryName.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + }); +}); From c5d7818b477d0f7bc65c2ac42e8219a38616d98e Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 28 May 2026 15:08:09 +0300 Subject: [PATCH 24/30] fix no selection bug --- .../lib/resolution/records-selection.test.ts | 6 ++---- .../lib/resolution/records-selection.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index 13605ff20c..81c6ef22fc 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -246,11 +246,9 @@ describe("buildRecordsSelectionFromResolveContainerInfo", () => { }); }); - it("throws when records is selected with an empty subselection", () => { + it("returns null when records is selected with an empty subselection", () => { const info = resolveInfoForDomainResolveSubselection("records { __typename }"); - expect(() => buildRecordsSelectionFromResolveContainerInfo(info)).toThrow( - EMPTY_RECORDS_SELECTION_MESSAGE, - ); + expect(buildRecordsSelectionFromResolveContainerInfo(info)).toBeNull(); }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts index 9f70c23e01..343678c162 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -74,14 +74,16 @@ function buildRecordsSelectionFromRecordsFieldNodes( recordsFieldNodes: readonly FieldNode[], recordsReturnType: GraphQLObjectType, info: GraphQLResolveInfo, -): ResolverRecordsSelection { +): ResolverRecordsSelection | null { // 1. Collect all selections from all 'records' field nodes (merging fragments and aliases) const graphqlSelections = recordsFieldNodes.flatMap( (node) => node.selectionSet?.selections ?? [], ); if (graphqlSelections.length === 0) { - throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + // If the 'records' field is selected but has no sub-fields (e.g. only '__typename'), + // we return null to indicate that no resolution is required. + return null; } // Create a virtual selection set to process all collected selections together @@ -118,7 +120,9 @@ function buildRecordsSelectionFromRecordsFieldNodes( } if (isSelectionEmpty(recordsSelection)) { - throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + // If the selection is empty after filtering out unknown fields or '__typename', + // we return null to indicate that no resolution is required. + return null; } return recordsSelection; @@ -139,7 +143,12 @@ export function buildRecordsSelectionFromResolveInfo( throw new GraphQLError("Return type must be an object type."); } - return buildRecordsSelectionFromRecordsFieldNodes(info.fieldNodes, returnType, info); + const selection = buildRecordsSelectionFromRecordsFieldNodes(info.fieldNodes, returnType, info); + if (!selection) { + throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + } + + return selection; } /** From e0d7f4dd7d7cec68a70a8f3afc40d55aaaf7139b Mon Sep 17 00:00:00 2001 From: sevenzing Date: Fri, 29 May 2026 13:19:34 +0300 Subject: [PATCH 25/30] rename constants --- .../account-primary-names-selection.test.ts | 4 +- .../lib/resolution/chain-coin-type.ts | 47 +++++++++---------- .../schema/account.integration.test.ts | 14 +++--- .../src/omnigraph-api/example-queries.ts | 2 +- .../src/omnigraph/generated/schema.graphql | 8 ++-- 5 files changed, 37 insertions(+), 38 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts index 037a6030a9..b4f23922ca 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -92,9 +92,9 @@ describe("buildAccountPrimaryNamesSelection", () => { expect(buildAccountPrimaryNamesSelection(info)).toEqual([60, 0]); }); - it("extracts coin type from primaryName(by: { chain: ETH })", () => { + it("extracts coin type from primaryName(by: { chain: ETHEREUM })", () => { const info = resolveInfoForAccountResolveSubselection( - 'primaryName(by: { chain: "ETH" }) { name }', + 'primaryName(by: { chain: "ETHEREUM" }) { name }', ); expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index 1b5f981fa9..b6f06232fe 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -3,35 +3,34 @@ import { coinNameToTypeMap } from "@ensdomains/address-encoder"; import type { CoinType } from "enssdk"; /** - * address-encoder coin names for ENSIP-19 primary-name chains. + * address-encoder coin names for ENSIP-19 primary-name chains, paired with their canonical + * GraphQL `ENSIP19Chain` enum values. */ export const ENSIP19_COIN_NAMES = [ - "default", - "eth", - "base", - "op", - "arb1", - "linea", - "scr", -] as const satisfies readonly CoinName[]; + ["default", "DEFAULT"], + ["eth", "ETHEREUM"], + ["base", "BASE"], + ["op", "OPTIMISM"], + ["arb1", "ARBITRUM"], + ["linea", "LINEA"], + ["scr", "SCROLL"], +] as const satisfies readonly (readonly [CoinName, string])[]; + +export type ENSIP19CoinName = (typeof ENSIP19_COIN_NAMES)[number][0]; +export type ENSIP19ChainValue = (typeof ENSIP19_COIN_NAMES)[number][1]; + +export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map( + ([, chain]) => chain, +) as unknown as readonly [ENSIP19ChainValue, ...ENSIP19ChainValue[]]; /** Canonical ENSIP-9 coin types for ENSIP-19 primary-name chains. */ export const ENSIP19_COIN_TYPES = ENSIP19_COIN_NAMES.map( - (name) => coinNameToTypeMap[name] as CoinType, + ([coinName]) => coinNameToTypeMap[coinName] as CoinType, ); -export type ENSIP19ChainValue = Uppercase<(typeof ENSIP19_COIN_NAMES)[number]>; - -export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map((coinName) => - coinName.toUpperCase(), -) as unknown as readonly [ENSIP19ChainValue, ...ENSIP19ChainValue[]]; - const ensip19ChainToCoinName = Object.fromEntries( - ENSIP19_CHAIN_VALUES.map((chain) => [ - chain, - chain.toLowerCase() as (typeof ENSIP19_COIN_NAMES)[number], - ]), -) as Record; + ENSIP19_COIN_NAMES.map(([coinName, chain]) => [chain, coinName]), +) as Record; /** Maps an `ENSIP19Chain` enum value to its canonical ENSIP-9 coin type. */ export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => @@ -39,7 +38,7 @@ export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => /** Maps a coin type to an `ENSIP19Chain` enum value, or null when not ENSIP-19 supported. */ export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => { - const coinName = ENSIP19_COIN_NAMES.find((name) => coinNameToTypeMap[name] === coinType); - if (!coinName) return null; - return coinName.toUpperCase() as ENSIP19ChainValue; + const entry = ENSIP19_COIN_NAMES.find(([coinName]) => coinNameToTypeMap[coinName] === coinType); + if (!entry) return null; + return entry[1]; }; 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 b961dec656..2ace0ef018 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -367,7 +367,7 @@ describe("Account.primaryName and Account.primaryNames", () => { query AccountPrimaryNameByChain($address: Address!) { account(by: { address: $address }) { resolve { - primaryName(by: { chain: ETH }) { + primaryName(by: { chain: ETHEREUM }) { coinType chain name { interpreted beautified } @@ -423,7 +423,7 @@ describe("Account.primaryName and Account.primaryNames", () => { query AccountPrimaryNamesByChains($address: Address!) { account(by: { address: $address }) { resolve { - primaryNames(where: { chains: [ETH, BASE] }) { + primaryNames(where: { chains: [ETHEREUM, BASE] }) { coinType chain name { interpreted beautified } @@ -478,7 +478,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }, }); @@ -492,7 +492,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }, }); @@ -545,7 +545,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETH", name: null }, + primaryName: { coinType: 60, chain: "ETHEREUM", name: null }, }, }, }); @@ -561,7 +561,7 @@ describe("Account.primaryName and Account.primaryNames", () => { account: { resolve: { primaryNames: [ - { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, { coinType: 2147492101, chain: "BASE", name: null }, ], }, @@ -578,7 +578,7 @@ describe("Account.primaryName and Account.primaryNames", () => { account: { resolve: { primaryNames: [ - { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, { coinType: 2147492101, chain: "BASE", name: null }, ], }, diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 92a1e3676b..ca8f741064 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -341,7 +341,7 @@ query AccountPrimaryNames($address: Address!) { account(by: { address: $address }) { address resolve { - primaryNames(where: { chains: [ETH, BASE] }) { + primaryNames(where: { chains: [ETHEREUM, BASE] }) { coinType chain name { interpreted beautified } diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index b0fab814ce..7f0a28ed76 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -534,13 +534,13 @@ ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the EN @see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. """ enum ENSIP19Chain { - ARB1 + ARBITRUM BASE DEFAULT - ETH + ETHEREUM LINEA - OP - SCR + OPTIMISM + SCROLL } """An ENS protocol version.""" From fdd1b729523348ee2593b395238a1cfdbbb5e883 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Fri, 29 May 2026 15:14:14 +0300 Subject: [PATCH 26/30] fix for comments on PR review from @shrugs --- .../handlers/api/resolution/resolution-api.ts | 8 +- .../multichain-primary-name-resolution.ts | 23 +- .../src/lib/resolution/reverse-resolution.ts | 23 +- apps/ensapi/src/omnigraph-api/builder.ts | 2 - .../account-primary-names-selection.test.ts | 52 +- .../account-primary-names-selection.ts | 10 +- .../lib/resolution/primary-name-input.ts | 35 +- .../lib/resolution/records-profile-model.ts | 6 +- .../lib/resolution/records-selection.test.ts | 42 +- .../resolve-primary-name-records.ts | 21 +- .../lib/resolution/test-helpers.ts | 17 + apps/ensapi/src/omnigraph-api/schema.ts | 1 + .../omnigraph-api/schema/account-resolve.ts | 92 +++ .../schema/account.integration.test.ts | 36 +- .../src/omnigraph-api/schema/account.ts | 135 +--- .../omnigraph-api/schema/canonical-name.ts | 11 +- .../omnigraph-api/schema/domain-canonical.ts | 12 +- .../schema/domain.integration.test.ts | 16 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 112 +--- .../schema/primary-name-record.ts | 81 +++ .../src/omnigraph-api/schema/profile.ts | 153 +++++ .../src/omnigraph-api/schema/records.ts | 255 ++++++++ .../schema/resolution.integration.test.ts | 5 +- .../src/omnigraph-api/schema/resolution.ts | 576 +----------------- .../src/omnigraph-api/schema/resolve.ts | 61 ++ .../src/omnigraph-api/schema/scalars.ts | 11 - .../react/omnigraph/_lib/cache-exchange.ts | 5 +- .../_lib/records-profile-cache-resolvers.ts | 63 -- packages/enssdk/src/lib/types/ens.ts | 5 - .../src/omnigraph/generated/introspection.ts | 186 +++--- .../src/omnigraph/generated/schema.graphql | 89 +-- packages/enssdk/src/omnigraph/graphql.ts | 2 - 32 files changed, 978 insertions(+), 1168 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/account-resolve.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/profile.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/records.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/resolve.ts delete mode 100644 packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index d10ffc66ac..cab86676f7 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -9,8 +9,8 @@ import { import { createApp } from "@/lib/hono-factory"; import { resolveForward } from "@/lib/resolution/forward-resolution"; -import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; -import { resolveReverse } from "@/lib/resolution/reverse-resolution"; +import { resolvePrimaryNamesByChainIds } from "@/lib/resolution/multichain-primary-name-resolution"; +import { resolveReverseByChainId } from "@/lib/resolution/reverse-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; @@ -90,7 +90,7 @@ app.openapi(resolvePrimaryNameRoute, async (c) => { const canAccelerate = c.var.canAccelerate; const { result, trace } = await runWithTrace(() => - resolveReverse(address, chainId, { accelerate, canAccelerate }), + resolveReverseByChainId(address, chainId, { accelerate, canAccelerate }), ); const response = { @@ -118,7 +118,7 @@ app.openapi(resolvePrimaryNamesRoute, async (c) => { const { chainIds, trace: showTrace, accelerate } = c.req.valid("query"); const canAccelerate = c.var.canAccelerate; const { result, trace } = await runWithTrace(() => - resolvePrimaryNames(address, chainIds, { accelerate, canAccelerate }), + resolvePrimaryNamesByChainIds(address, chainIds, { accelerate, canAccelerate }), ); const response = { diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index 9a961c291a..ba1234f40a 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -12,7 +12,7 @@ import { import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { resolveReverse, resolveReverseByCoinType } from "@/lib/resolution/reverse-resolution"; +import { resolveReverse, resolveReverseByChainId } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); @@ -40,7 +40,7 @@ export type MultichainPrimaryNameByCoinTypeResolutionResult = Partial< Record >; -type PrimaryNameResolutionOptions = Parameters[2]; +type PrimaryNameResolutionOptions = Parameters[2]; /** * Batch-resolves an address' primary name for each requested coin type. @@ -56,10 +56,7 @@ export async function resolvePrimaryNamesByCoinTypes( tracer, "resolvePrimaryNamesByCoinTypes", { address }, - () => - Promise.all( - coinTypes.map((coinType) => resolveReverseByCoinType(address, coinType, options)), - ), + () => Promise.all(coinTypes.map((coinType) => resolveReverse(address, coinType, options))), ); return coinTypes.reduce((memo, coinType, i) => { @@ -81,17 +78,19 @@ export async function resolvePrimaryNamesByCoinTypes( * @param options.accelerate Whether acceleration is requested (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) */ -export async function resolvePrimaryNames( +export async function resolvePrimaryNamesByChainIds( address: MultichainPrimaryNameResolutionArgs["address"], chainIds: MultichainPrimaryNameResolutionArgs["chainIds"] = getENSIP19SupportedChainIds(), - options: Parameters[2], + options: Parameters[2], ): Promise { - // parallel reverseResolve - const names = await withActiveSpanAsync(tracer, "resolvePrimaryNames", { address }, () => - Promise.all(chainIds.map((chainId) => resolveReverse(address, chainId, options))), + const names = await withActiveSpanAsync( + tracer, + "resolvePrimaryNamesByChainIds", + { address }, + () => + Promise.all(chainIds.map((chainId) => resolveReverseByChainId(address, chainId, options))), ); - // key results by chainId return chainIds.reduce((memo, chainId, i) => { // biome-ignore lint/style/noNonNullAssertion: names[i] guaranteed to be defined memo[chainId] = names[i]!; diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index cf7468dfa0..6a732cfa77 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -37,14 +37,13 @@ type ReverseResolutionOptions = Parameters[2]; * * @see https://docs.ens.domains/ensip/19/#algorithm * - * * @param address the adddress whose Primary Name to resolve - * @param coinType the coinType within which to resolve the address' Primary Name + * @param coinType the coinType within which to resolve the address' Primary Name * @param options Optional settings * @param options.accelerate Whether to accelerate resolution (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) */ -export async function resolveReverseByCoinType( +export async function resolveReverse( address: Address, coinType: CoinType, options: ReverseResolutionOptions, @@ -180,23 +179,11 @@ export async function resolveReverseByCoinType( ); } -/** - * Implements ENS Reverse Resolution, including support for ENSIP-19 L2 Primary Names. - * - * @see https://docs.ens.domains/ensip/19/#algorithm - * - * The DEFAULT_EVM_CHAIN_ID (0) is a valid chainId in this context. - * - * @param address the adddress whose Primary Name to resolve - * @param chainId the chainId within which to resolve the address' Primary Name - * @param options Optional settings - * @param options.accelerate Whether to accelerate resolution (default: true) - * @param options.canAccelerate Whether acceleration is currently possible (default: false) - */ -export async function resolveReverse( +/** Thin chainId wrapper around {@link resolveReverse} for callers at the REST API boundary. */ +export async function resolveReverseByChainId( address: Address, chainId: ChainId, options: ReverseResolutionOptions, ): Promise { - return resolveReverseByCoinType(address, evmChainIdToCoinType(chainId), options); + return resolveReverse(address, evmChainIdToCoinType(chainId), options); } diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index f3f22ac75f..fe1b81b556 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -26,7 +26,6 @@ import type { RenewalId, ResolverId, ResolverRecordsId, - UID, } from "enssdk"; import { getNamedType } from "graphql"; import superjson from "superjson"; @@ -72,7 +71,6 @@ export type BuilderScalars = { InterfaceId: { Input: InterfaceId; Output: InterfaceId }; Node: { Input: Node; Output: Node }; InterpretedName: { Input: InterpretedName; Output: InterpretedName }; - UID: { Input: UID; Output: UID }; InterpretedLabel: { Input: InterpretedLabel; Output: InterpretedLabel }; BeautifiedName: { Input: BeautifiedName; Output: BeautifiedName }; BeautifiedLabel: { Input: BeautifiedLabel; Output: BeautifiedLabel }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts index b4f23922ca..e2829e20e7 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -1,3 +1,4 @@ +import { coinNameToTypeMap } from "@ensdomains/address-encoder"; import { GraphQLInputObjectType, GraphQLInt, @@ -5,12 +6,19 @@ import { GraphQLObjectType, type GraphQLResolveInfo, GraphQLString, - parse, } from "graphql"; import { describe, expect, it } from "vitest"; +import { parseFieldNode } from "@/omnigraph-api/lib/resolution/test-helpers"; + import { buildAccountPrimaryNamesSelection } from "./account-primary-names-selection"; +// These mock types mirror the real Pothos-generated schema types. They cannot be imported +// from `@/omnigraph-api/schema` directly because doing so loads the full Pothos schema into +// the test process, which creates a second in-memory instance of `graphql`. graphql-js's +// `instanceOf` checks then fail when comparing types across those two instances (the "Duplicate +// graphql modules" error). Keeping the mocks here avoids that issue; ensure names stay in sync +// with the real schema when those types change. const PrimaryNameByInputType = new GraphQLInputObjectType({ name: "PrimaryNameByInput", fields: { @@ -19,8 +27,8 @@ const PrimaryNameByInputType = new GraphQLInputObjectType({ }, }); -const AccountPrimaryNamesWhereInputType = new GraphQLInputObjectType({ - name: "AccountPrimaryNamesWhereInput", +const PrimaryNamesWhereInputType = new GraphQLInputObjectType({ + name: "PrimaryNamesWhereInput", fields: { coinTypes: { type: new GraphQLList(GraphQLInt) }, chains: { type: new GraphQLList(GraphQLString) }, @@ -46,26 +54,15 @@ const AccountResolveType = new GraphQLObjectType({ primaryNames: { type: new GraphQLList(PrimaryNameRecordType), args: { - where: { type: AccountPrimaryNamesWhereInputType }, + where: { type: PrimaryNamesWhereInputType }, }, }, }, }); -function parseResolveFieldNode(subselection: string) { - const document = parse(`{ resolve { ${subselection} } }`); - const operation = document.definitions[0]; - if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); - - const resolveField = operation.selectionSet.selections[0]; - if (resolveField.kind !== "Field") throw new Error("expected field"); - - return resolveField; -} - function resolveInfoForAccountResolveSubselection(subselection: string): GraphQLResolveInfo { return { - fieldNodes: [parseResolveFieldNode(subselection)], + fieldNodes: [parseFieldNode("resolve", subselection)], fragments: {}, returnType: AccountResolveType, variableValues: {}, @@ -96,7 +93,7 @@ describe("buildAccountPrimaryNamesSelection", () => { const info = resolveInfoForAccountResolveSubselection( 'primaryName(by: { chain: "ETHEREUM" }) { name }', ); - expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([coinNameToTypeMap.eth]); }); it("merges coin types from primaryName and primaryNames when both are selected", () => { @@ -106,4 +103,25 @@ describe("buildAccountPrimaryNamesSelection", () => { `); expect(buildAccountPrimaryNamesSelection(info)).toEqual([60, 0]); }); + + it("merges coin types from multiple aliased primaryName and primaryNames fields", () => { + const info = resolveInfoForAccountResolveSubselection(` + one: primaryName(by: { coinType: ${coinNameToTypeMap.btc} }) { name } + two: primaryName(by: { coinType: ${coinNameToTypeMap.ltc} }) { name } + three: primaryNames(where: { coinTypes: [${coinNameToTypeMap.doge}, ${coinNameToTypeMap.sol}] }) { name } + four: primaryNames(where: { chains: ["DEFAULT", "ETHEREUM", "ARBITRUM"] }) { name } + five: primaryName(by: { chain: "BASE" }) { name } + `); + + expect(buildAccountPrimaryNamesSelection(info)).toEqual([ + coinNameToTypeMap.doge, + coinNameToTypeMap.sol, + coinNameToTypeMap.default, + coinNameToTypeMap.eth, + coinNameToTypeMap.arb1, + coinNameToTypeMap.btc, + coinNameToTypeMap.ltc, + coinNameToTypeMap.base, + ]); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts index a3c520d8b6..42cc09b5c4 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts @@ -8,12 +8,14 @@ import { } from "graphql"; import { - type AccountPrimaryNamesWhereInput, normalizeAccountPrimaryNamesWhereInput, normalizePrimaryNameByInput, - type PrimaryNameByInput, } from "@/omnigraph-api/lib/resolution/primary-name-input"; import { collectNamedSubFieldNodes } from "@/omnigraph-api/lib/resolution/records-selection"; +import type { + PrimaryNameByInputValue, + PrimaryNamesWhereInputValue, +} from "@/omnigraph-api/schema/resolution"; /** * Derives primary-name coin types from `Account.resolve { primaryName | primaryNames }`, or null @@ -44,7 +46,7 @@ export function buildAccountPrimaryNamesSelection(info: GraphQLResolveInfo): Coi // Extract arguments from this specific field node (handles variables and aliases) const args = getArgumentValues(primaryNamesFieldDef, node, info.variableValues); const normalized = normalizeAccountPrimaryNamesWhereInput( - args.where as AccountPrimaryNamesWhereInput, + args.where as PrimaryNamesWhereInputValue, ); // Add all requested coin types from this 'primaryNames' call to our set for (const coinType of normalized) coinTypes.add(coinType); @@ -59,7 +61,7 @@ export function buildAccountPrimaryNamesSelection(info: GraphQLResolveInfo): Coi // Extract arguments from this specific field node const args = getArgumentValues(primaryNameFieldDef, node, info.variableValues); // Add the single requested coin type from this 'primaryName' call to our set - coinTypes.add(normalizePrimaryNameByInput(args.by as PrimaryNameByInput)); + coinTypes.add(normalizePrimaryNameByInput(args.by as PrimaryNameByInputValue)); } } } diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts index a274c2b52c..22afc51422 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -1,32 +1,29 @@ import type { CoinType } from "enssdk"; -import { - type ENSIP19ChainValue, - ensip19ChainToCoinType, -} from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { ensip19ChainToCoinType } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { + PrimaryNameByInputValue, + PrimaryNamesWhereInputValue, +} from "@/omnigraph-api/schema/resolution"; -export type PrimaryNameByInput = { - coinType?: CoinType | null; - chain?: ENSIP19ChainValue | null; -}; - -export type AccountPrimaryNamesWhereInput = { - coinTypes?: CoinType[] | null; - chains?: ENSIP19ChainValue[] | null; -}; - -/** Normalizes a singular `PrimaryNameByInput` to a coin type. */ -export const normalizePrimaryNameByInput = (by: PrimaryNameByInput): CoinType => { +/** + * Normalizes a singular `PrimaryNameByInput` to a coin type. + */ +export const normalizePrimaryNameByInput = (by: PrimaryNameByInputValue): CoinType => { if (by.coinType != null) return by.coinType; if (by.chain != null) return ensip19ChainToCoinType(by.chain); + // this should never happen as the schema with `@oneOf` prevents it throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); }; -/** Normalizes `AccountPrimaryNamesWhereInput` to an ordered coin-type list. */ +/** + * Normalizes `PrimaryNamesWhereInput` to an ordered coin-type list. + */ export const normalizeAccountPrimaryNamesWhereInput = ( - where: AccountPrimaryNamesWhereInput, + where: PrimaryNamesWhereInputValue, ): CoinType[] => { if (where.coinTypes != null) return where.coinTypes; if (where.chains != null) return where.chains.map(ensip19ChainToCoinType); - throw new Error("AccountPrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); + // this should never happen as the schema with `@oneOf` prevents it + throw new Error("PrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts index 303c28757b..b0c491ad05 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts @@ -1,16 +1,16 @@ -import type { InterpretedName, UID } from "enssdk"; +import type { InterpretedName } from "enssdk"; import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; /** Cache key and resolution identity for {@link ResolvedRecordsRef}. */ export type ResolvedRecordsModel = Partial & { - id: UID; + id: InterpretedName; }; export const toResolvedRecordsModel = ( name: InterpretedName, response: Partial, ): ResolvedRecordsModel => ({ - id: name.toString() satisfies UID, + id: name, ...response, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index 81c6ef22fc..b37f8c803f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -8,7 +8,6 @@ import { GraphQLScalarType, GraphQLString, Kind, - parse, } from "graphql"; import { describe, expect, it } from "vitest"; @@ -21,7 +20,14 @@ import { RECORDS_SELECTION_PARAMETRIC_FIELDS, RECORDS_SELECTION_SIMPLE_FIELDS, } from "@/omnigraph-api/lib/resolution/records-selection-config"; - +import { parseFieldNode } from "@/omnigraph-api/lib/resolution/test-helpers"; + +// These mock types mirror the real Pothos-generated schema types. They cannot be imported +// from `@/omnigraph-api/schema` directly because doing so loads the full Pothos schema into +// the test process, which creates a second in-memory instance of `graphql`. graphql-js's +// `instanceOf` checks then fail when comparing types across those two instances (the "Duplicate +// graphql modules" error). Keeping the mocks here avoids that issue; ensure names stay in sync +// with the real schema when those types change. const stringListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))); const intListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLInt))); @@ -63,46 +69,24 @@ function buildMockResolvedRecordsType() { const ResolvedRecordsType = buildMockResolvedRecordsType(); const DomainResolveType = new GraphQLObjectType({ - name: "DomainResolve", + name: "Resolve", fields: { trace: { type: GraphQLString }, records: { type: ResolvedRecordsType }, }, }); -function parseResolveFieldNode(subselection: string) { - const document = parse(`{ resolve { ${subselection} } }`); - const operation = document.definitions[0]; - if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); - - const resolveField = operation.selectionSet.selections[0]; - if (resolveField.kind !== "Field") throw new Error("expected field"); - - return resolveField; -} - function resolveInfoForDomainResolveSubselection(subselection: string): GraphQLResolveInfo { return { - fieldNodes: [parseResolveFieldNode(subselection)], + fieldNodes: [parseFieldNode("resolve", subselection)], fragments: {}, returnType: DomainResolveType, variableValues: {}, } as unknown as GraphQLResolveInfo; } -function parseRecordsFieldNode(subselection: string) { - const document = parse(`{ records { ${subselection} } }`); - const operation = document.definitions[0]; - if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); - - const recordsField = operation.selectionSet.selections[0]; - if (recordsField.kind !== "Field") throw new Error("expected field"); - - return recordsField; -} - function mockResolveInfo( - fieldNodes: ReturnType[], + fieldNodes: ReturnType[], variableValues: Record = {}, ): GraphQLResolveInfo { return { @@ -114,12 +98,12 @@ function mockResolveInfo( } function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { - return mockResolveInfo([parseRecordsFieldNode(subselection)]); + return mockResolveInfo([parseFieldNode("records", subselection)]); } /** Simulates GraphQL passing multiple AST field nodes for the same `records` resolver. */ function resolveInfoForMultipleRecordsFieldNodes(...subselections: string[]): GraphQLResolveInfo { - return mockResolveInfo(subselections.map(parseRecordsFieldNode)); + return mockResolveInfo(subselections.map((s) => parseFieldNode("records", s))); } describe("buildRecordsSelectionFromResolveInfo", () => { diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index 0078668650..182de1528d 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -2,10 +2,7 @@ import type { Address, CoinType, InterpretedName } from "enssdk"; import type { TracingTrace } from "@ensnode/ensnode-sdk"; -import { - type MultichainPrimaryNameByCoinTypeResolutionResult, - resolvePrimaryNamesByCoinTypes, -} from "@/lib/resolution/multichain-primary-name-resolution"; +import { resolvePrimaryNamesByCoinTypes } from "@/lib/resolution/multichain-primary-name-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { coinTypeToEnsip19Chain, @@ -43,12 +40,16 @@ export async function resolvePrimaryNameRecords( const supportedCoinTypes = new Set(ENSIP19_COIN_TYPES); const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); - const { trace, result: resolvedByCoinType } = - resolvableCoinTypes.length > 0 - ? await runWithTrace(() => - resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, options), - ) - : { trace: null, result: {} as MultichainPrimaryNameByCoinTypeResolutionResult }; + if (resolvableCoinTypes.length === 0) { + return { + trace: null, + records: coinTypes.map((coinType) => toPrimaryNameRecord(address, coinType, null)), + }; + } + + const { trace, result: resolvedByCoinType } = await runWithTrace(() => + resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, options), + ); const records = coinTypes.map((coinType) => { const name = (resolvedByCoinType[coinType] ?? null) as InterpretedName | null; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts new file mode 100644 index 0000000000..37f357c891 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts @@ -0,0 +1,17 @@ +import { type FieldNode, type OperationDefinitionNode, parse } from "graphql"; + +/** + * Parses a GraphQL document of the form `{ { } }` and returns + * the AST FieldNode for the outer field. Used in unit tests that build mock GraphQLResolveInfo + * objects from inline query strings. + */ +export function parseFieldNode(fieldName: string, subselection: string): FieldNode { + const document = parse(`{ ${fieldName} { ${subselection} } }`); + const operation = document.definitions[0] as OperationDefinitionNode; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const field = operation.selectionSet.selections[0]; + if (!field || field.kind !== "Field") throw new Error("expected field"); + + return field; +} diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 0ded718140..3c5061f07e 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -1,6 +1,7 @@ import { builder } from "@/omnigraph-api/builder"; import "./schema/account-id"; +import "./schema/account-resolve"; import "./schema/connection"; import "./schema/domain"; import "./schema/domain-canonical"; diff --git a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts new file mode 100644 index 0000000000..650f398d71 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts @@ -0,0 +1,92 @@ +import type { Address, CoinType, JsonValue } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/omnigraph-api/builder"; +import { + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { + AccelerationStatusRef, + PrimaryNameByInput, + type PrimaryNameRecordModel, + PrimaryNameRecordRef, + PrimaryNamesWhereInput, +} from "@/omnigraph-api/schema/resolution"; + +export type AccountResolveModel = { + address: Address; + coinTypes: CoinType[]; + accelerate: boolean; + canAccelerate: boolean; + trace: TracingTrace | null; + records: PrimaryNameRecordModel[]; +}; + +export const AccountResolveRef = builder.objectRef("AccountResolve"); + +AccountResolveRef.implement({ + description: + "Nested account resolution container exposing primary-name resolution with shared acceleration settings.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: (parent) => parent.trace as unknown as JsonValue | null, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this Account resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + primaryName: t.field({ + description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", + type: PrimaryNameRecordRef, + nullable: false, + args: { + by: t.arg({ + type: PrimaryNameByInput, + required: true, + description: "Select a coin type or chain to resolve a primary name for.", + }), + }, + resolve: ({ records, accelerate }, { by }) => { + const coinType = normalizePrimaryNameByInput(by); + const record = records.find((r) => r.coinType === coinType); + if (!record) { + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); + } + return { ...record, accelerate }; + }, + }), + primaryNames: t.field({ + description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", + type: [PrimaryNameRecordRef], + nullable: false, + args: { + where: t.arg({ + type: PrimaryNamesWhereInput, + required: true, + description: "Select coin types or chains to resolve primary names for.", + }), + }, + resolve: ({ records, accelerate }, { where }) => { + const coinTypes = normalizeAccountPrimaryNamesWhereInput(where); + return coinTypes.map((coinType) => { + const record = records.find((r) => r.coinType === coinType); + if (!record) { + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); + } + return { ...record, accelerate }; + }); + }, + }), + }), +}); 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 2ace0ef018..66cb511e70 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -1,4 +1,10 @@ -import type { InterpretedName } from "enssdk"; +import { + DEFAULT_EVM_COIN_TYPE, + ETH_COIN_TYPE, + evmChainIdToCoinType, + type InterpretedName, +} from "enssdk"; +import { base } from "viem/chains"; import { beforeAll, describe, expect, it } from "vitest"; import { accounts } from "@ensnode/datasources/devnet"; @@ -314,6 +320,8 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { }); describe("Account.primaryName and Account.primaryNames", () => { + const BASE_COIN_TYPE = evmChainIdToCoinType(base.id); + type CanonicalNameResult = { interpreted: string; beautified: string; @@ -473,12 +481,12 @@ describe("Account.primaryName and Account.primaryNames", () => { await expect( request(AccountPrimaryNameByCoinType, { address: accounts.owner.address, - coinType: 60, + coinType: ETH_COIN_TYPE, }), ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }, }); @@ -492,7 +500,7 @@ describe("Account.primaryName and Account.primaryNames", () => { ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, }, }, }); @@ -507,7 +515,7 @@ describe("Account.primaryName and Account.primaryNames", () => { account: { resolve: { primaryName: { - coinType: 2_147_483_648, + coinType: DEFAULT_EVM_COIN_TYPE, chain: "DEFAULT", name: null, }, @@ -526,7 +534,7 @@ describe("Account.primaryName and Account.primaryNames", () => { resolve: { primaryNames: [ { - coinType: 2_147_483_648, + coinType: DEFAULT_EVM_COIN_TYPE, chain: "DEFAULT", name: null, }, @@ -540,12 +548,12 @@ describe("Account.primaryName and Account.primaryNames", () => { await expect( request(AccountPrimaryNameByCoinType, { address: accounts.user.address, - coinType: 60, + coinType: ETH_COIN_TYPE, }), ).resolves.toEqual({ account: { resolve: { - primaryName: { coinType: 60, chain: "ETHEREUM", name: null }, + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: null }, }, }, }); @@ -555,14 +563,14 @@ describe("Account.primaryName and Account.primaryNames", () => { await expect( request(AccountPrimaryNamesByCoinTypes, { address: accounts.owner.address, - coinTypes: [60, 2147492101], + coinTypes: [ETH_COIN_TYPE, BASE_COIN_TYPE], }), ).resolves.toMatchObject({ account: { resolve: { primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, - { coinType: 2147492101, chain: "BASE", name: null }, + { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + { coinType: BASE_COIN_TYPE, chain: "BASE", name: null }, ], }, }, @@ -578,8 +586,8 @@ describe("Account.primaryName and Account.primaryNames", () => { account: { resolve: { primaryNames: [ - { coinType: 60, chain: "ETHEREUM", name: TEST_ETH_NAME }, - { coinType: 2147492101, chain: "BASE", name: null }, + { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + { coinType: BASE_COIN_TYPE, chain: "BASE", name: null }, ], }, }, @@ -619,7 +627,7 @@ describe("Account.primaryName and Account.primaryNames", () => { name: TEST_ETH_NAME, resolve: { records: { - addresses: [{ coinType: 60, address: accounts.owner.address }], + addresses: [{ coinType: ETH_COIN_TYPE, address: accounts.owner.address }], }, }, }, diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 71e3aced5c..4c9888a90d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,8 +1,7 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address, JsonValue } from "enssdk"; - -import type { TracingTrace } from "@ensnode/ensnode-sdk"; +import type { Address } from "enssdk"; +import { GraphQLError } from "graphql"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; @@ -12,12 +11,12 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r 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 { - normalizeAccountPrimaryNamesWhereInput, - normalizePrimaryNameByInput, -} from "@/omnigraph-api/lib/resolution/primary-name-input"; import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; +import { + type AccountResolveModel, + AccountResolveRef, +} from "@/omnigraph-api/schema/account-resolve"; import { ID_PAGINATED_CONNECTION_ARGS, RESOLVE_ACCELERATE_ARG, @@ -28,13 +27,6 @@ import { EventRef } from "@/omnigraph-api/schema/event"; import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; -import { - AccelerationStatusRef, - AccountPrimaryNamesWhereInput, - PrimaryNameByInput, - type PrimaryNameRecordModel, - PrimaryNameRecordRef, -} from "@/omnigraph-api/schema/resolution"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; export const AccountRef = builder.loadableObjectRef("Account", { @@ -50,17 +42,6 @@ export const AccountRef = builder.loadableObjectRef("Account", { }); export type Account = Exclude; -type AccountPrimaryNamesResult = { - trace: TracingTrace | null; - records: PrimaryNameRecordModel[]; -}; -type AccountResolveModel = { - account: Account; - accelerate: boolean; - canAccelerate: boolean; - primaryNamesResolution: Promise | null; -}; -const AccountResolveRef = builder.objectRef("AccountResolve"); /////////// // Account @@ -92,23 +73,34 @@ AccountRef.implement({ // Account.resolve ////////////////// resolve: t.field({ - description: "Resolve primary names for this Account with protocol acceleration controls.", + description: "Resolve Primary Names for this Account.", type: AccountResolveRef, nullable: false, args: { accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), }, - resolve: (account, { accelerate: accelerateArg }, context, info) => { + resolve: async ( + account, + { accelerate: accelerateArg }, + context, + info, + ): Promise => { const accelerate = accelerateArg ?? true; const { canAccelerate } = context; const coinTypes = buildAccountPrimaryNamesSelection(info); - const primaryNamesResolution = - coinTypes !== null - ? resolvePrimaryNameRecords(account.id, coinTypes, { accelerate, canAccelerate }) - : null; + if (coinTypes === null) { + throw new GraphQLError( + "Account.resolve requires at least one `primaryName(by: ...)` or `primaryNames(where: ...)` field to be selected.", + ); + } - return { account, accelerate, canAccelerate, primaryNamesResolution }; + const { trace, records } = await resolvePrimaryNameRecords(account.id, coinTypes, { + accelerate, + canAccelerate, + }); + + return { address: account.id, coinTypes, accelerate, canAccelerate, trace, records }; }, }), @@ -267,85 +259,6 @@ AccountRef.implement({ }), }); -AccountResolveRef.implement({ - description: - "Nested account resolution container exposing primary-name resolution with shared acceleration settings.", - fields: (t) => ({ - trace: t.field({ - description: - "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", - type: "JSON", - nullable: true, - resolve: async ({ primaryNamesResolution }) => { - if (!primaryNamesResolution) return null; - const { trace } = await primaryNamesResolution; - return trace as unknown as JsonValue | null; - }, - }), - acceleration: t.field({ - description: "Protocol acceleration strategy status for this Account resolution.", - type: AccelerationStatusRef, - nullable: false, - resolve: ({ accelerate, canAccelerate }) => ({ - requested: accelerate, - attempted: accelerate && canAccelerate, - }), - }), - primaryName: t.field({ - description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", - type: PrimaryNameRecordRef, - nullable: false, - args: { - by: t.arg({ - type: PrimaryNameByInput, - required: true, - description: "Select a coin type or chain to resolve a primary name for.", - }), - }, - resolve: async ({ primaryNamesResolution, accelerate }, { by }) => { - if (!primaryNamesResolution) { - throw new Error("primaryName requires a primary-name resolution to be started."); - } - const coinType = normalizePrimaryNameByInput(by); - const { records } = await primaryNamesResolution; - const record = records.find((r) => r.coinType === coinType); - if (!record) { - throw new Error(`Missing primary name record for requested coin type: ${coinType}`); - } - return { ...record, accelerate }; - }, - }), - primaryNames: t.field({ - description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", - type: [PrimaryNameRecordRef], - nullable: false, - args: { - where: t.arg({ - type: AccountPrimaryNamesWhereInput, - required: true, - description: "Select coin types or chains to resolve primary names for.", - }), - }, - resolve: async ({ primaryNamesResolution, accelerate }, { where }) => { - if (!primaryNamesResolution) { - throw new Error("primaryNames requires a primary-name resolution to be started."); - } - const coinTypes = normalizeAccountPrimaryNamesWhereInput(where); - const { records } = await primaryNamesResolution; - - // return records in the order of requested coinTypes - return coinTypes.map((coinType) => { - const record = records.find((r) => r.coinType === coinType); - if (!record) { - throw new Error(`Missing primary name record for requested coin type: ${coinType}`); - } - return { ...record, accelerate }; - }); - }, - }), - }), -}); - ////////// // Inputs ////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts index bc9420e61c..5d9ebfe0bb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts +++ b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts @@ -2,15 +2,10 @@ import { beautifyInterpretedName, type InterpretedName } from "enssdk"; import { builder } from "@/omnigraph-api/builder"; -/** Parent object for {@link CanonicalNameRef} field resolvers. */ -export type CanonicalNameParent = { - canonicalName: InterpretedName; -}; - //////////////////////////////// // CanonicalName //////////////////////////////// -export const CanonicalNameRef = builder.objectRef("CanonicalName"); +export const CanonicalNameRef = builder.objectRef("CanonicalName"); CanonicalNameRef.implement({ description: "A Canonical Name, exposed in each representation we support.", @@ -20,14 +15,14 @@ CanonicalNameRef.implement({ "The Canonical Name as an InterpretedName: each label is either a normalized literal Label or an Encoded LabelHash.", type: "InterpretedName", nullable: false, - resolve: (parent) => parent.canonicalName, + resolve: (parent) => parent, }), beautified: t.field({ description: "The Canonical Name as a BeautifiedName: the InterpretedName with its normalized labels beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. Encoded LabelHash labels are preserved verbatim. Display-only; use `interpreted` for navigation targets and lookup keys.", type: "BeautifiedName", nullable: false, - resolve: (parent) => beautifyInterpretedName(parent.canonicalName), + resolve: (parent) => beautifyInterpretedName(parent), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index 44a92eee1a..f52193ca82 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -14,16 +14,8 @@ DomainCanonicalRef.implement({ name: t.field({ description: "The Canonical Name for this Domain.", type: CanonicalNameRef, - nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return { canonicalName: domain.canonicalName }; - }, + nullable: true, + resolve: (domain) => domain.canonicalName ?? null, }), depth: t.field({ description: 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 fe40cad85a..d4785f25e4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -3,6 +3,7 @@ import { asInterpretedLabel, type CoinType, type DomainId, + ETH_COIN_TYPE, ETH_NODE, type InterpretedLabel, type InterpretedName, @@ -557,11 +558,14 @@ describe("Domain.records", () => { } `; + const BITCOIN_COIN_TYPE = 0; + const LITECOIN_COIN_TYPE = 2; + it("resolves address and text records for example.eth", async () => { await expect( request(DomainRecords, { name: "example.eth", - addresses: [60], + addresses: [ETH_COIN_TYPE], texts: ["description"], }), ).resolves.toMatchObject({ @@ -569,7 +573,7 @@ describe("Domain.records", () => { resolve: { records: { texts: [{ key: "description", value: "example.eth" }], - addresses: [{ coinType: 60, address: accounts.owner.address }], + addresses: [{ coinType: ETH_COIN_TYPE, address: accounts.owner.address }], }, }, }, @@ -580,7 +584,7 @@ describe("Domain.records", () => { await expect( request(DomainRecordsAll, { name: "test.eth", - addresses: [60, 0, 2], + addresses: [ETH_COIN_TYPE, 0, 2], texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], contentTypeMask: "1", interfaceIds: [fixtures.fourBytesInterface], @@ -596,9 +600,9 @@ describe("Domain.records", () => { abi: { contentType: "1", data: fixtures.abiBytes }, interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], addresses: [ - { coinType: 60, address: accounts.owner.address }, - { coinType: 0, address: fixtures.bitcoinAddress }, - { coinType: 2, address: fixtures.litecoinAddress }, + { coinType: ETH_COIN_TYPE, address: accounts.owner.address }, + { coinType: BITCOIN_COIN_TYPE, address: fixtures.bitcoinAddress }, + { coinType: LITECOIN_COIN_TYPE, address: fixtures.litecoinAddress }, ], texts: [ { key: "avatar", value: "https://example.com/avatar.png" }, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 422eaf76a4..4eaf12e1d1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,9 +1,9 @@ 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, type JsonValue } from "enssdk"; +import { type DomainId, isNormalizedName } from "enssdk"; -import type { RequiredAndNotNull, RequiredAndNull, TracingTrace } from "@ensnode/ensnode-sdk"; +import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -21,7 +21,6 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; -import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; @@ -45,11 +44,7 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; -import { - AccelerationStatusRef, - DomainProfileRef, - ResolvedRecordsRef, -} from "@/omnigraph-api/schema/resolution"; +import { type ResolveModel, ResolveRef } from "@/omnigraph-api/schema/resolve"; const tracer = trace.getTracer("schema/Domain"); @@ -73,16 +68,6 @@ export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { export type Domain = Exclude; export type DomainInterface = Omit; -type DomainRecordsResult = { - trace: TracingTrace; - records: ReturnType; -}; -type DomainResolveModel = { - domain: DomainInterface; - accelerate: boolean; - canAccelerate: boolean; - recordsResolution: Promise | null; -}; export type ENSv1Domain = RequiredAndNotNull & RequiredAndNull & { type: "ENSv1Domain" }; export type ENSv2Domain = RequiredAndNotNull & @@ -96,7 +81,6 @@ export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); -const DomainResolveRef = builder.objectRef("DomainResolve"); ////////////////////////////////// // DomainInterface Implementation @@ -195,32 +179,40 @@ DomainInterfaceRef.implement({ resolve: t.field({ description: "Resolve protocol-level data for this Domain with trace and acceleration metadata.", - type: DomainResolveRef, + type: ResolveRef, nullable: false, args: { accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), }, - resolve: (domain, { accelerate: accelerateArg }, context, info) => { + resolve: async ( + domain, + { accelerate: accelerateArg }, + context, + info, + ): Promise => { const accelerate = accelerateArg ?? true; const { canAccelerate } = context; const name = domain.canonicalName; - const recordsSelection = - name && isNormalizedName(name) - ? buildRecordsSelectionFromResolveContainerInfo(info) - : null; - - const recordsResolution = - name && recordsSelection - ? runWithTrace(() => - resolveForward(name, recordsSelection, { accelerate, canAccelerate }), - ).then(({ trace, result }) => ({ - trace, - records: toResolvedRecordsModel(name, result), - })) - : null; - - return { domain, accelerate, canAccelerate, recordsResolution }; + if (!name || !isNormalizedName(name)) { + return { accelerate, canAccelerate, trace: null, records: null }; + } + + const recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); + if (!recordsSelection) { + return { accelerate, canAccelerate, trace: null, records: null }; + } + + const { trace, result } = await runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ); + + return { + accelerate, + canAccelerate, + trace, + records: toResolvedRecordsModel(name, result), + }; }, }), @@ -316,52 +308,6 @@ DomainInterfaceRef.implement({ }), }); -DomainResolveRef.implement({ - description: - "Nested domain resolution container exposing trace/acceleration metadata and resolved data.", - fields: (t) => ({ - trace: t.field({ - description: - "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability.", - type: "JSON", - nullable: true, - resolve: async ({ recordsResolution }) => { - if (!recordsResolution) return null; - return (await recordsResolution).trace as unknown as JsonValue; - }, - }), - acceleration: t.field({ - description: "Protocol acceleration strategy status for this Domain resolution.", - type: AccelerationStatusRef, - nullable: false, - resolve: ({ accelerate, canAccelerate }) => ({ - requested: accelerate, - attempted: accelerate && canAccelerate, - }), - }), - records: t.field({ - description: - "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", - type: ResolvedRecordsRef, - nullable: true, - tracing: true, - resolve: async ({ domain, recordsResolution }) => { - if (!domain.canonicalName || !recordsResolution) return null; - return (await recordsResolution).records; - }, - }), - ...(INCLUDE_DEV_METHODS && { - profile: t.field({ - description: - "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical.", - type: DomainProfileRef, - nullable: true, - resolve: ({ domain }) => (domain.canonicalName ? {} : null), - }), - }), - }), -}); - ////////////////////////////// // ENSv1Domain Implementation ////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts new file mode 100644 index 0000000000..7bfa930dae --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -0,0 +1,81 @@ +import { type Address, type CoinType, type InterpretedName, isNormalizedName } from "enssdk"; + +import { resolveForward } from "@/lib/resolution/forward-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; +import { builder } from "@/omnigraph-api/builder"; +import type { ENSIP19ChainValue } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; +import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; +import { ENSIP19Chain } from "@/omnigraph-api/schema/resolution"; +import { type ResolveModel, ResolveRef } from "@/omnigraph-api/schema/resolve"; + +export type PrimaryNameRecordModel = { + address: Address; + coinType: CoinType; + chain: ENSIP19ChainValue | null; + name: InterpretedName | null; +}; + +/** GraphQL parent for `PrimaryNameRecord`, including `AccountResolve` acceleration settings. */ +export type PrimaryNameRecordParent = PrimaryNameRecordModel & { + accelerate: boolean; +}; + +export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); + +PrimaryNameRecordRef.implement({ + description: "An ENSIP-19 primary name for an Account on a specific coin type.", + fields: (t) => ({ + coinType: t.field({ + description: "The canonical ENSIP-9 coin type for this primary name lookup.", + type: "CoinType", + nullable: false, + resolve: (r) => r.coinType, + }), + chain: t.field({ + description: + "The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`.", + type: ENSIP19Chain, + nullable: true, + resolve: (r) => r.chain, + }), + name: t.field({ + description: + "The validated primary name for this Account on this coin type, or null if none is set.", + type: CanonicalNameRef, + nullable: true, + resolve: (r) => r.name ?? null, + }), + resolve: t.field({ + description: + "Resolve protocol-level records (and optionally profile preview) for this primary name.", + type: ResolveRef, + nullable: false, + resolve: async (parent, _args, context, info): Promise => { + const { name, accelerate } = parent; + const { canAccelerate } = context; + + if (!name || !isNormalizedName(name)) { + return { accelerate, canAccelerate, trace: null, records: null }; + } + + const recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); + if (!recordsSelection) { + return { accelerate, canAccelerate, trace: null, records: null }; + } + + const { trace, result } = await runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ); + + return { + accelerate, + canAccelerate, + trace, + records: toResolvedRecordsModel(name, result), + }; + }, + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts new file mode 100644 index 0000000000..1803adf379 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -0,0 +1,153 @@ +import { builder } from "@/omnigraph-api/builder"; + +type ProfileSectionModel = Record; + +export const ProfileSocialAccountRef = + builder.objectRef("ProfileSocialAccount"); + +ProfileSocialAccountRef.implement({ + description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", + fields: (t) => ({ + handle: t.string({ + description: "The social handle, or null when unset.", + nullable: true, + resolve: () => null, + }), + url: t.string({ + description: "The social profile URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); + +ProfileSocialsRef.implement({ + description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", + fields: (t) => ({ + github: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + telegram: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + twitter: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + }), +}); + +export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); + +ProfileAddressesRef.implement({ + description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", + fields: (t) => ({ + ethereum: t.field({ + description: "The interpreted Ethereum address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + base: t.field({ + description: "The interpreted Base address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + bitcoin: t.string({ + description: "The interpreted Bitcoin address, or null when unset.", + nullable: true, + resolve: () => null, + }), + solana: t.string({ + description: "The interpreted Solana address, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); + +ProfileAvatarRef.implement({ + description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved avatar URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileBannerRef = builder.objectRef("ProfileBanner"); + +ProfileBannerRef.implement({ + description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved banner URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); + +ProfileWebsiteRef.implement({ + description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved website URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const DomainProfileRef = builder.objectRef("DomainProfile"); + +DomainProfileRef.implement({ + description: + "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", + fields: (t) => ({ + avatar: t.field({ + type: ProfileAvatarRef, + nullable: true, + resolve: () => ({}), + }), + banner: t.field({ + type: ProfileBannerRef, + nullable: true, + resolve: () => ({}), + }), + website: t.field({ + type: ProfileWebsiteRef, + nullable: true, + resolve: () => ({}), + }), + description: t.string({ + description: "The profile description, or null when unset.", + nullable: true, + resolve: () => null, + }), + addresses: t.field({ + type: ProfileAddressesRef, + nullable: true, + resolve: () => ({}), + }), + socials: t.field({ + type: ProfileSocialsRef, + nullable: true, + resolve: () => ({}), + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/records.ts b/apps/ensapi/src/omnigraph-api/schema/records.ts new file mode 100644 index 0000000000..ec7d571b51 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/records.ts @@ -0,0 +1,255 @@ +import type { CoinType, Hex, InterfaceId, NormalizedAddress } from "enssdk"; + +import { builder } from "@/omnigraph-api/builder"; +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +////////////////////////// +// ResolvedRawTextRecord +////////////////////////// +export const ResolvedRawTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( + "ResolvedRawTextRecord", +); + +ResolvedRawTextRecordRef.implement({ + description: + "A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.", + fields: (t) => ({ + key: t.exposeString("key", { + description: "The text record key.", + nullable: false, + }), + value: t.exposeString("value", { + description: + "The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.", + nullable: true, + }), + }), +}); + +/////////////////////////// +// ResolvedAddressRecord +/////////////////////////// +export const ResolvedAddressRecordRef = builder.objectRef<{ + coinType: CoinType; + address: string | null; +}>("ResolvedAddressRecord"); + +ResolvedAddressRecordRef.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, + }), + }), +}); + +//////////////////////// +// ResolvedPubkeyRecord +//////////////////////// +export const ResolvedPubkeyRecordRef = builder.objectRef<{ x: Hex; y: Hex }>( + "ResolvedPubkeyRecord", +); + +ResolvedPubkeyRecordRef.implement({ + description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", + fields: (t) => ({ + x: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.x, + }), + y: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.y, + }), + }), +}); + +/////////////////////// +// ResolvedAbiRecord +/////////////////////// +export const ResolvedAbiRecordRef = builder.objectRef<{ contentType: bigint; data: Hex }>( + "ResolvedAbiRecord", +); + +ResolvedAbiRecordRef.implement({ + description: "A resolved ABI record for an ENS name.", + fields: (t) => ({ + contentType: t.field({ + type: "BigInt", + nullable: false, + resolve: (r) => r.contentType, + }), + data: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.data, + }), + }), +}); + +//////////////////////////// +// ResolvedInterfaceRecord +//////////////////////////// +export const ResolvedInterfaceRecordRef = builder.objectRef<{ + interfaceId: InterfaceId; + implementer: NormalizedAddress | null; +}>("ResolvedInterfaceRecord"); + +ResolvedInterfaceRecordRef.implement({ + description: "A resolved ERC-165 interface implementer record for an ENS name.", + fields: (t) => ({ + interfaceId: t.field({ + type: "InterfaceId", + nullable: false, + resolve: (r) => r.interfaceId, + }), + implementer: t.field({ + type: "Address", + nullable: true, + resolve: (r) => r.implementer, + }), + }), +}); + +//////////////////// +// ResolvedRecords +//////////////////// +export type { ResolvedRecordsModel }; + +export const ResolvedRecordsRef = builder.objectRef("ResolvedRecords"); + +ResolvedRecordsRef.implement({ + description: "Records resolved for a specific ENS name via the ENS protocol.", + fields: (t) => ({ + id: t.field({ + description: "Stable cache key for these records: the InterpretedName used to resolve them.", + type: "InterpretedName", + nullable: false, + resolve: (parent) => parent.id, + }), + reverseName: t.string({ + description: + "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", + nullable: true, + resolve: (r) => r.name ?? null, + }), + contenthash: t.field({ + description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.contenthash ?? null, + }), + pubkey: t.field({ + description: "The PubkeyResolver (x, y) pair, or null if not set.", + type: ResolvedPubkeyRecordRef, + nullable: true, + resolve: (r) => r.pubkey ?? null, + }), + dnszonehash: t.field({ + description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.dnszonehash ?? null, + }), + version: t.field({ + description: "The IVersionableResolver version, or null if not set or unavailable.", + type: "BigInt", + nullable: true, + resolve: (r) => r.version ?? null, + }), + abi: t.field({ + description: + "The first stored ABI matching the requested content-type bitmask, or null if not set.", + type: ResolvedAbiRecordRef, + nullable: true, + args: { + contentTypeMask: t.arg({ + type: "BigInt", + required: true, + description: + "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", + }), + }, + resolve: (r, { contentTypeMask }) => { + /* + ENSIP-4 ABIs are stored with a single-bit contentType (1=JSON, 2=zlib-JSON, etc). + The selection-building layer merges all requested contentTypeMasks from all 'abi' + field aliases into a single aggregate mask for the underlying resolution call. + At this resolver layer, we must verify that the specific ABI returned by the + protocol (which is the first one found matching the aggregate mask) actually + matches the specific bitmask requested by *this* GraphQL field alias. + + @see https://docs.ens.domains/ensip/4/ + */ + if (!r.abi) return null; + // check if the found contentType matches the requested contentTypeMask + const foundContentType = r.abi.contentType & contentTypeMask; + if (foundContentType === 0n) return null; + return r.abi; + }, + }), + interfaces: t.field({ + description: "Resolved ERC-165 interface implementer records for the requested ids.", + type: [ResolvedInterfaceRecordRef], + nullable: false, + args: { + ids: t.arg({ + type: ["InterfaceId"], + required: true, + description: "ERC-165 interface ids to resolve (4-byte hex selectors).", + }), + }, + resolve: (r, { ids }) => + // preserve the order of requested interface ids + r.interfaces + ? ids.map((interfaceId) => ({ + interfaceId, + implementer: r.interfaces?.[interfaceId] ?? null, + })) + : [], + }), + texts: t.field({ + description: "Resolved text records for the requested keys.", + type: [ResolvedRawTextRecordRef], + nullable: false, + args: { + keys: t.arg.stringList({ + required: true, + description: "Text record keys to resolve (e.g. `avatar`, `description`).", + }), + }, + resolve: (r, { keys }) => + // preserve the order of requested text keys + r.texts ? keys.map((key) => ({ key, value: r.texts?.[key] ?? null })) : [], + }), + addresses: t.field({ + description: "Resolved address records for the requested coin types.", + type: [ResolvedAddressRecordRef], + nullable: false, + args: { + coinTypes: t.arg({ + type: ["CoinType"], + required: true, + description: "Coin types to resolve (e.g. `60` for ETH).", + }), + }, + resolve: (r, { coinTypes }) => + r.addresses + ? // preserve the order of requested coin types + coinTypes.map((coinType) => ({ + coinType, + address: r.addresses?.[coinType] ?? null, + })) + : [], + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts index 7fcced2884..1fb3ddf75b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts @@ -1,3 +1,4 @@ +import { ETH_COIN_TYPE } from "enssdk"; import { describe, expect, it } from "vitest"; import { accounts } from "@ensnode/datasources/devnet"; @@ -105,7 +106,7 @@ describe("Resolution Trace and Acceleration", () => { }); expect(primaryName.resolve.records.addresses).toContainEqual({ - coinType: 60, + coinType: ETH_COIN_TYPE, address: accounts.owner.address, }); }); @@ -163,7 +164,7 @@ describe("Resolution Trace and Acceleration", () => { attempted: false, }); - // PrimaryNameResolve should inherit accelerate: false from Account.resolve + // PrimaryNameRecord.resolve should inherit accelerate: false from Account.resolve expect(result.account.resolve.primaryName.resolve.acceleration).toEqual({ requested: false, attempted: false, diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 37a9eb039b..676791c992 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,31 +1,9 @@ -import { - type Address, - type CoinType, - type Hex, - type InterfaceId, - type InterpretedName, - isNormalizedName, - type JsonValue, - type NormalizedAddress, -} from "enssdk"; - -import type { TracingTrace } from "@ensnode/ensnode-sdk"; - -import { resolveForward } from "@/lib/resolution/forward-resolution"; -import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; -import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; -import { - ENSIP19_CHAIN_VALUES, - type ENSIP19ChainValue, -} from "@/omnigraph-api/lib/resolution/chain-coin-type"; -import { - type ResolvedRecordsModel, - toResolvedRecordsModel, -} from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; -import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; +import { ENSIP19_CHAIN_VALUES } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +////////////////////// +// AccelerationStatus +////////////////////// export type AccelerationStatusModel = { requested: boolean; attempted: boolean; @@ -76,7 +54,9 @@ export const PrimaryNameByInput = builder.inputType("PrimaryNameByInput", { }), }); -export const AccountPrimaryNamesWhereInput = builder.inputType("AccountPrimaryNamesWhereInput", { +export type PrimaryNameByInputValue = typeof PrimaryNameByInput.$inferInput; + +export const PrimaryNamesWhereInput = builder.inputType("PrimaryNamesWhereInput", { description: "Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided.", isOneOf: true, @@ -94,536 +74,14 @@ export const AccountPrimaryNamesWhereInput = builder.inputType("AccountPrimaryNa }), }); -////////////////////// -// DomainProfile (preview — types only, no resolution wired yet) -////////////////////// -type ProfileSectionModel = Record; - -export const ProfileSocialAccountRef = - builder.objectRef("ProfileSocialAccount"); - -ProfileSocialAccountRef.implement({ - description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", - fields: (t) => ({ - handle: t.string({ - description: "The social handle, or null when unset.", - nullable: true, - resolve: () => null, - }), - url: t.string({ - description: "The social profile URL, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - -export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); - -ProfileSocialsRef.implement({ - description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", - fields: (t) => ({ - github: t.field({ - type: ProfileSocialAccountRef, - nullable: true, - resolve: () => ({}), - }), - telegram: t.field({ - type: ProfileSocialAccountRef, - nullable: true, - resolve: () => ({}), - }), - twitter: t.field({ - type: ProfileSocialAccountRef, - nullable: true, - resolve: () => ({}), - }), - }), -}); - -export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); - -ProfileAddressesRef.implement({ - description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", - fields: (t) => ({ - ethereum: t.field({ - description: "The interpreted Ethereum address, or null when unset.", - type: "Address", - nullable: true, - resolve: () => null, - }), - base: t.field({ - description: "The interpreted Base address, or null when unset.", - type: "Address", - nullable: true, - resolve: () => null, - }), - bitcoin: t.string({ - description: "The interpreted Bitcoin address, or null when unset.", - nullable: true, - resolve: () => null, - }), - solana: t.string({ - description: "The interpreted Solana address, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - -export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); - -ProfileAvatarRef.implement({ - description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", - fields: (t) => ({ - url: t.string({ - description: "The resolved avatar URL, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - -export const ProfileBannerRef = builder.objectRef("ProfileBanner"); - -ProfileBannerRef.implement({ - description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", - fields: (t) => ({ - url: t.string({ - description: "The resolved banner URL, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - -export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); - -ProfileWebsiteRef.implement({ - description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", - fields: (t) => ({ - url: t.string({ - description: "The resolved website URL, or null when unset.", - nullable: true, - resolve: () => null, - }), - }), -}); - -export const DomainProfileRef = builder.objectRef("DomainProfile"); - -DomainProfileRef.implement({ - description: - "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", - fields: (t) => ({ - avatar: t.field({ - type: ProfileAvatarRef, - nullable: true, - resolve: () => ({}), - }), - banner: t.field({ - type: ProfileBannerRef, - nullable: true, - resolve: () => ({}), - }), - website: t.field({ - type: ProfileWebsiteRef, - nullable: true, - resolve: () => ({}), - }), - description: t.string({ - description: "The profile description, or null when unset.", - nullable: true, - resolve: () => null, - }), - addresses: t.field({ - type: ProfileAddressesRef, - nullable: true, - resolve: () => ({}), - }), - socials: t.field({ - type: ProfileSocialsRef, - nullable: true, - resolve: () => ({}), - }), - }), -}); - -////////////////////////// -// ResolvedRawTextRecord -////////////////////////// -export const ResolvedRawTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( - "ResolvedRawTextRecord", -); - -ResolvedRawTextRecordRef.implement({ - description: - "A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.", - fields: (t) => ({ - key: t.exposeString("key", { - description: "The text record key.", - nullable: false, - }), - value: t.exposeString("value", { - description: - "The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.", - nullable: true, - }), - }), -}); - -/////////////////////////// -// ResolvedAddressRecord -/////////////////////////// -export const ResolvedAddressRecordRef = builder.objectRef<{ - coinType: CoinType; - address: string | null; -}>("ResolvedAddressRecord"); - -ResolvedAddressRecordRef.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, - }), - }), -}); - -//////////////////////// -// ResolvedPubkeyRecord -//////////////////////// -export const ResolvedPubkeyRecordRef = builder.objectRef<{ x: Hex; y: Hex }>( - "ResolvedPubkeyRecord", -); - -ResolvedPubkeyRecordRef.implement({ - description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", - fields: (t) => ({ - x: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.x, - }), - y: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.y, - }), - }), -}); - -/////////////////////// -// ResolvedAbiRecord -/////////////////////// -export const ResolvedAbiRecordRef = builder.objectRef<{ contentType: bigint; data: Hex }>( - "ResolvedAbiRecord", -); - -ResolvedAbiRecordRef.implement({ - description: "A resolved ABI record for an ENS name.", - fields: (t) => ({ - contentType: t.field({ - type: "BigInt", - nullable: false, - resolve: (r) => r.contentType, - }), - data: t.field({ - type: "Hex", - nullable: false, - resolve: (r) => r.data, - }), - }), -}); - -//////////////////////////// -// ResolvedInterfaceRecord -//////////////////////////// -export const ResolvedInterfaceRecordRef = builder.objectRef<{ - interfaceId: InterfaceId; - implementer: NormalizedAddress | null; -}>("ResolvedInterfaceRecord"); - -ResolvedInterfaceRecordRef.implement({ - description: "A resolved ERC-165 interface implementer record for an ENS name.", - fields: (t) => ({ - interfaceId: t.field({ - type: "InterfaceId", - nullable: false, - resolve: (r) => r.interfaceId, - }), - implementer: t.field({ - type: "Address", - nullable: true, - resolve: (r) => r.implementer, - }), - }), -}); - -//////////////////// -// ResolvedRecords -//////////////////// -export type { ResolvedRecordsModel }; - -export const ResolvedRecordsRef = builder.objectRef("ResolvedRecords"); +export type PrimaryNamesWhereInputValue = typeof PrimaryNamesWhereInput.$inferInput; -ResolvedRecordsRef.implement({ - description: "Records resolved for a specific ENS name via the ENS protocol.", - fields: (t) => ({ - id: t.field({ - description: "Stable cache key for these records: the InterpretedName used to resolve them.", - type: "UID", - nullable: false, - resolve: (parent) => parent.id, - }), - reverseName: t.string({ - description: - "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", - nullable: true, - resolve: (r) => r.name ?? null, - }), - contenthash: t.field({ - description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", - type: "Hex", - nullable: true, - resolve: (r) => r.contenthash ?? null, - }), - pubkey: t.field({ - description: "The PubkeyResolver (x, y) pair, or null if not set.", - type: ResolvedPubkeyRecordRef, - nullable: true, - resolve: (r) => r.pubkey ?? null, - }), - dnszonehash: t.field({ - description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", - type: "Hex", - nullable: true, - resolve: (r) => r.dnszonehash ?? null, - }), - version: t.field({ - description: "The IVersionableResolver version, or null if not set or unavailable.", - type: "BigInt", - nullable: true, - resolve: (r) => r.version ?? null, - }), - abi: t.field({ - description: - "The first stored ABI matching the requested content-type bitmask, or null if not set.", - type: ResolvedAbiRecordRef, - nullable: true, - args: { - contentTypeMask: t.arg({ - type: "BigInt", - required: true, - description: - "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", - }), - }, - resolve: (r, { contentTypeMask }) => { - /* - ENSIP-4 ABIs are stored with a single-bit contentType (1=JSON, 2=zlib-JSON, etc). - The selection-building layer merges all requested contentTypeMasks from all 'abi' - field aliases into a single aggregate mask for the underlying resolution call. - At this resolver layer, we must verify that the specific ABI returned by the - protocol (which is the first one found matching the aggregate mask) actually - matches the specific bitmask requested by *this* GraphQL field alias. - - @see https://docs.ens.domains/ensip/4/ - */ - if (!r.abi) return null; - // check if the found contentType matches the requested contentTypeMask - const foundContentType = r.abi.contentType & contentTypeMask; - if (foundContentType === 0n) return null; - return r.abi; - }, - }), - interfaces: t.field({ - description: "Resolved ERC-165 interface implementer records for the requested ids.", - type: [ResolvedInterfaceRecordRef], - nullable: false, - args: { - ids: t.arg({ - type: ["InterfaceId"], - required: true, - description: "ERC-165 interface ids to resolve (4-byte hex selectors).", - }), - }, - resolve: (r, { ids }) => - // preserve the order of requested interface ids - r.interfaces - ? ids.map((interfaceId) => ({ - interfaceId, - implementer: r.interfaces?.[interfaceId] ?? null, - })) - : [], - }), - texts: t.field({ - description: "Resolved text records for the requested keys.", - type: [ResolvedRawTextRecordRef], - nullable: false, - args: { - keys: t.arg.stringList({ - required: true, - description: "Text record keys to resolve (e.g. `avatar`, `description`).", - }), - }, - resolve: (r, { keys }) => - // preserve the order of requested text keys - r.texts ? keys.map((key) => ({ key, value: r.texts?.[key] ?? null })) : [], - }), - addresses: t.field({ - description: "Resolved address records for the requested coin types.", - type: [ResolvedAddressRecordRef], - nullable: false, - args: { - coinTypes: t.arg({ - type: ["CoinType"], - required: true, - description: "Coin types to resolve (e.g. `60` for ETH).", - }), - }, - resolve: (r, { coinTypes }) => - r.addresses - ? // preserve the order of requested coin types - coinTypes.map((coinType) => ({ - coinType, - address: r.addresses?.[coinType] ?? null, - })) - : [], - }), - }), -}); - -////////////////////// -// PrimaryNameRecord -////////////////////// -export type PrimaryNameRecordModel = { - address: Address; - coinType: CoinType; - chain: ENSIP19ChainValue | null; - name: InterpretedName | null; -}; - -/** GraphQL parent for `PrimaryNameRecord`, including `AccountResolve` acceleration settings. */ -export type PrimaryNameRecordParent = PrimaryNameRecordModel & { - accelerate: boolean; -}; - -type PrimaryNameRecordsResult = { - trace: TracingTrace; - records: ResolvedRecordsModel; -}; - -type PrimaryNameResolveModel = { - parent: PrimaryNameRecordParent; - recordsResolution: Promise | null; -}; - -export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); -export const PrimaryNameResolveRef = - builder.objectRef("PrimaryNameResolve"); - -PrimaryNameRecordRef.implement({ - description: "An ENSIP-19 primary name for an Account on a specific coin type.", - fields: (t) => ({ - coinType: t.field({ - description: "The canonical ENSIP-9 coin type for this primary name lookup.", - type: "CoinType", - nullable: false, - resolve: (r) => r.coinType, - }), - chain: t.field({ - description: - "The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`.", - type: ENSIP19Chain, - nullable: true, - resolve: (r) => r.chain, - }), - name: t.field({ - description: - "The validated primary name for this Account on this coin type, or null if none is set.", - type: CanonicalNameRef, - nullable: true, - resolve: (r) => (r.name ? { canonicalName: r.name } : null), - }), - resolve: t.field({ - description: - "Resolve protocol-level records (and optionally profile preview) for this primary name.", - type: PrimaryNameResolveRef, - nullable: false, - resolve: (parent, _args, context, info) => { - const { name, accelerate } = parent; - const { canAccelerate } = context; - - const recordsSelection = - name && isNormalizedName(name) - ? buildRecordsSelectionFromResolveContainerInfo(info) - : null; - - const recordsResolution = - name && recordsSelection - ? runWithTrace(() => - resolveForward(name, recordsSelection, { accelerate, canAccelerate }), - ).then(({ trace, result }) => ({ - trace, - records: toResolvedRecordsModel(name, result), - })) - : null; - - return { parent, recordsResolution }; - }, - }), - }), -}); - -PrimaryNameResolveRef.implement({ - description: - "Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data.", - fields: (t) => ({ - trace: t.field({ - description: - "Protocol trace tree emitted by resolution, represented as JSON for schema stability.", - type: "JSON", - nullable: true, - resolve: async ({ recordsResolution }) => { - if (!recordsResolution) return null; - return (await recordsResolution).trace as unknown as JsonValue; - }, - }), - acceleration: t.field({ - description: "Protocol acceleration strategy status for this primary name resolution.", - type: AccelerationStatusRef, - nullable: false, - resolve: ({ parent }, _args, context) => ({ - requested: parent.accelerate, - attempted: parent.accelerate && context.canAccelerate, - }), - }), - records: t.field({ - description: - "Forward-resolve ENS records for the validated primary name. Null when `name` is null.", - type: ResolvedRecordsRef, - nullable: true, - tracing: true, - resolve: async ({ recordsResolution }) => { - if (!recordsResolution) return null; - return (await recordsResolution).records; - }, - }), - ...(INCLUDE_DEV_METHODS && { - profile: t.field({ - description: - "PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved.", - type: DomainProfileRef, - nullable: true, - resolve: ({ parent }) => (parent.name ? {} : null), - }), - }), - }), -}); +export { + type PrimaryNameRecordModel, + type PrimaryNameRecordParent, + PrimaryNameRecordRef, +} from "@/omnigraph-api/schema/primary-name-record"; +// Re-exports so that consumers of this module don't need to know about the file split. +export { DomainProfileRef } from "@/omnigraph-api/schema/profile"; +export type { ResolvedRecordsModel } from "@/omnigraph-api/schema/records"; +export { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolve.ts b/apps/ensapi/src/omnigraph-api/schema/resolve.ts new file mode 100644 index 0000000000..0330f804a4 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolve.ts @@ -0,0 +1,61 @@ +import type { JsonValue } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/omnigraph-api/builder"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { + AccelerationStatusRef, + DomainProfileRef, + ResolvedRecordsRef, +} from "@/omnigraph-api/schema/resolution"; + +export type ResolveModel = { + accelerate: boolean; + canAccelerate: boolean; + trace: TracingTrace | null; + records: ResolvedRecordsModel | null; +}; + +export const ResolveRef = builder.objectRef("Resolve"); + +ResolveRef.implement({ + description: + "Resolution container exposing trace and acceleration metadata alongside resolved ENS records.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: (parent) => parent.trace as unknown as JsonValue | null, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + records: t.field({ + description: + "Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected).", + type: ResolvedRecordsRef, + nullable: true, + tracing: true, + resolve: (parent) => parent.records, + }), + ...(INCLUDE_DEV_METHODS && { + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for this name. Types are defined for query ergonomics; resolution is not yet wired. Returns null when no records resolution is available.", + type: DomainProfileRef, + nullable: true, + resolve: (parent) => (parent.records ? {} : null), + }), + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index e2e6e8814a..2b41126c32 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -23,7 +23,6 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, - type UID, } from "enssdk"; import { isHex, size } from "viem"; import { z } from "zod/v4"; @@ -124,16 +123,6 @@ builder.scalarType("Node", { .parse(value), }); -builder.scalarType("UID", { - description: "UID is a stable cache key for records/profile entities.", - serialize: (value: UID) => value, - parseValue: (value) => - z.coerce - .string() - .transform((val) => val as UID) - .parse(value), -}); - builder.scalarType("InterpretedName", { description: "InterpretedName represents an enssdk#InterpretedName.", serialize: (value: Name) => value, diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index e8b3f4b653..51c2eb8460 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -7,7 +7,6 @@ import { byIdLookupResolvers } from "./by-id-lookup-resolvers"; import { localBigIntResolvers } from "./local-bigint-resolvers"; import { localConnectionResolvers } from "./local-connection-resolvers"; import { mergeResolverMaps } from "./merge-resolver-maps"; -import { recordsProfileCacheResolvers } from "./records-profile-cache-resolvers"; /** * Entities without keys are 'Embedded Data', and we tell graphcache about them to avoid warnings @@ -50,9 +49,8 @@ export const omnigraphCacheExchange = cacheExchange({ CanonicalName: EMBEDDED_DATA, DomainCanonical: EMBEDDED_DATA, DomainResolver: EMBEDDED_DATA, - DomainResolve: EMBEDDED_DATA, + Resolve: EMBEDDED_DATA, AccountResolve: EMBEDDED_DATA, - PrimaryNameResolve: EMBEDDED_DATA, ResolutionStatus: EMBEDDED_DATA, PrimaryNameRecord: EMBEDDED_DATA, AccelerationStatus: EMBEDDED_DATA, @@ -79,6 +77,5 @@ export const omnigraphCacheExchange = cacheExchange({ // produce local cache resolvers for the Query.entity(by: { }) lookups byIdLookupResolvers, - recordsProfileCacheResolvers, ), }); diff --git a/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts b/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts deleted file mode 100644 index 0d532db56a..0000000000 --- a/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Cache, ResolveInfo, Resolver, Variables } from "@urql/exchange-graphcache"; - -/** - * Delegates to graphcache network resolution when no cached entity is found locally. - */ -const passthrough = (args: Variables, cache: Cache, info: ResolveInfo) => - cache.resolve(info.parentTypeName, info.fieldName, args); - -const asEntityKey = (value: unknown): string | null => (typeof value === "string" ? value : null); - -const lookupCachedRecordsByInterpretedName = (cache: Cache, interpretedName: string) => { - const key = cache.keyOfEntity({ __typename: "ResolvedRecords", id: interpretedName }); - if (key && cache.resolve(key, "id")) return key; - return undefined; -}; - -const resolveInterpretedNameFromCanonical = (cache: Cache, parentKey: string): string | null => { - const canonicalKey = asEntityKey(cache.resolve(parentKey, "canonical")); - if (!canonicalKey) return null; - - const nameKey = asEntityKey(cache.resolve(canonicalKey, "name")); - if (!nameKey) return null; - - const interpreted = cache.resolve(nameKey, "interpreted"); - return typeof interpreted === "string" ? interpreted : null; -}; - -const resolveInterpretedNameFromPrimaryNameRecord = ( - cache: Cache, - parentKey: string, -): string | null => { - const nameKey = asEntityKey(cache.resolve(parentKey, "name")); - if (!nameKey) return null; - - const interpreted = cache.resolve(nameKey, "interpreted"); - return typeof interpreted === "string" ? interpreted : null; -}; - -const resolveRecordsFromParentName: Resolver = (parent, args, cache, info) => { - const parentKey = asEntityKey(parent); - if (!parentKey) return passthrough(args, cache, info); - - const interpreted = - info.parentTypeName === "PrimaryNameRecord" || info.parentTypeName === "PrimaryNameResolve" - ? resolveInterpretedNameFromPrimaryNameRecord(cache, parentKey) - : resolveInterpretedNameFromCanonical(cache, parentKey); - - if (interpreted) { - const cached = lookupCachedRecordsByInterpretedName(cache, interpreted); - if (cached) return cached; - } - - return passthrough(args, cache, info); -}; - -export const recordsProfileCacheResolvers: Record> = { - DomainResolve: { - records: resolveRecordsFromParentName, - }, - PrimaryNameResolve: { - records: resolveRecordsFromParentName, - }, -}; diff --git a/packages/enssdk/src/lib/types/ens.ts b/packages/enssdk/src/lib/types/ens.ts index a89377c7fa..10efea7f4f 100644 --- a/packages/enssdk/src/lib/types/ens.ts +++ b/packages/enssdk/src/lib/types/ens.ts @@ -158,11 +158,6 @@ export type LiteralName = Name & { __brand: "LiteralName" }; */ export type InterpretedName = Name & { __brand: "InterpretedName" }; -/** - * Stable cache identity for normalized GraphQL entities (e.g. records/profile). - */ -export type UID = String; - /** * A Beautified Name is a Name produced for presentation in a UI from an {@link InterpretedName}. * diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index ff9e3b999c..abaf279ae5 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -720,39 +720,6 @@ const introspection = { ], "isOneOf": false }, - { - "kind": "INPUT_OBJECT", - "name": "AccountPrimaryNamesWhereInput", - "inputFields": [ - { - "name": "chains", - "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "ENUM", - "name": "ENSIP19Chain" - } - } - } - }, - { - "name": "coinTypes", - "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "CoinType" - } - } - } - } - ], - "isOneOf": true - }, { "kind": "OBJECT", "name": "AccountRegistryPermissionsConnection", @@ -894,7 +861,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "INPUT_OBJECT", - "name": "AccountPrimaryNamesWhereInput" + "name": "PrimaryNamesWhereInput" } } } @@ -1414,7 +1381,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "DomainResolve" + "name": "Resolve" } }, "args": [ @@ -1534,11 +1501,8 @@ const introspection = { { "name": "name", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "CanonicalName" - } + "kind": "OBJECT", + "name": "CanonicalName" }, "args": [], "isDeprecated": false @@ -1863,43 +1827,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "OBJECT", - "name": "DomainResolve", - "fields": [ - { - "name": "acceleration", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "AccelerationStatus" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "trace", - "type": { - "kind": "SCALAR", - "name": "JSON" - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "DomainResolver", @@ -2106,7 +2033,7 @@ const introspection = { "name": "ENSIP19Chain", "enumValues": [ { - "name": "ARB1", + "name": "ARBITRUM", "isDeprecated": false }, { @@ -2118,7 +2045,7 @@ const introspection = { "isDeprecated": false }, { - "name": "ETH", + "name": "ETHEREUM", "isDeprecated": false }, { @@ -2126,11 +2053,11 @@ const introspection = { "isDeprecated": false }, { - "name": "OP", + "name": "OPTIMISM", "isDeprecated": false }, { - "name": "SCR", + "name": "SCROLL", "isDeprecated": false } ] @@ -2326,7 +2253,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "DomainResolve" + "name": "Resolve" } }, "args": [ @@ -2947,7 +2874,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "DomainResolve" + "name": "Resolve" } }, "args": [ @@ -5001,7 +4928,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "PrimaryNameResolve" + "name": "Resolve" } }, "args": [], @@ -5011,41 +4938,37 @@ const introspection = { "interfaces": [] }, { - "kind": "OBJECT", - "name": "PrimaryNameResolve", - "fields": [ + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesWhereInput", + "inputFields": [ { - "name": "acceleration", + "name": "chains", "type": { - "kind": "NON_NULL", + "kind": "LIST", "ofType": { - "kind": "OBJECT", - "name": "AccelerationStatus" + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "ENSIP19Chain" + } } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [], - "isDeprecated": false + } }, { - "name": "trace", + "name": "coinTypes", "type": { - "kind": "SCALAR", - "name": "JSON" - }, - "args": [], - "isDeprecated": false + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } } ], - "interfaces": [] + "isOneOf": true }, { "kind": "OBJECT", @@ -6206,6 +6129,43 @@ const introspection = { "kind": "SCALAR", "name": "RenewalId" }, + { + "kind": "OBJECT", + "name": "Resolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "ResolvedAbiRecord", @@ -6435,7 +6395,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "UID" + "name": "InterpretedName" } }, "args": [], @@ -7201,10 +7161,6 @@ const introspection = { } ] }, - { - "kind": "SCALAR", - "name": "UID" - }, { "kind": "OBJECT", "name": "WrappedBaseRegistrarRegistration", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 7f0a28ed76..88b3e0e0d5 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -31,9 +31,7 @@ type Account { """The Permissions on Registries granted to this Account.""" registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection - """ - Resolve primary names for this Account with protocol acceleration controls. - """ + """Resolve Primary Names for this Account.""" resolve( """ When true (default), Protocol Acceleration is used for record resolution, when supported. @@ -139,17 +137,6 @@ input AccountPermissionsWhereInput { contract: AccountIdInput } -""" -Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. -""" -input AccountPrimaryNamesWhereInput @oneOf { - """ENSIP-19 supported chains to resolve primary names for.""" - chains: [ENSIP19Chain!] - - """Coin types to resolve primary names for.""" - coinTypes: [CoinType!] -} - type AccountRegistryPermissionsConnection { edges: [AccountRegistryPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -181,7 +168,7 @@ type AccountResolve { """ primaryNames( """Select coin types or chains to resolve primary names for.""" - where: AccountPrimaryNamesWhereInput! + where: PrimaryNamesWhereInput! ): [PrimaryNameRecord!]! """ @@ -344,7 +331,7 @@ interface Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): DomainResolve! + ): Resolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -368,7 +355,7 @@ type DomainCanonical { depth: Int! """The Canonical Name for this Domain.""" - name: CanonicalName! + name: CanonicalName """ The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`. @@ -445,24 +432,6 @@ type DomainRegistrationsConnectionEdge { node: Registration! } -""" -Nested domain resolution container exposing trace/acceleration metadata and resolved data. -""" -type DomainResolve { - """Protocol acceleration strategy status for this Domain resolution.""" - acceleration: AccelerationStatus! - - """ - Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. - """ - records: ResolvedRecords - - """ - Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. - """ - trace: JSON -} - """Metadata describing this Domain's relationship to its Resolver(s).""" type DomainResolver { """ @@ -596,7 +565,7 @@ type ENSv1Domain implements Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): DomainResolve! + ): Resolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -719,7 +688,7 @@ type ENSv2Domain implements Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): DomainResolve! + ): Resolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -1248,27 +1217,18 @@ type PrimaryNameRecord { """ Resolve protocol-level records (and optionally profile preview) for this primary name. """ - resolve: PrimaryNameResolve! + resolve: Resolve! } """ -Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data. +Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. """ -type PrimaryNameResolve { - """ - Protocol acceleration strategy status for this primary name resolution. - """ - acceleration: AccelerationStatus! - - """ - Forward-resolve ENS records for the validated primary name. Null when `name` is null. - """ - records: ResolvedRecords +input PrimaryNamesWhereInput @oneOf { + """ENSIP-19 supported chains to resolve primary names for.""" + chains: [ENSIP19Chain!] - """ - Protocol trace tree emitted by resolution, represented as JSON for schema stability. - """ - trace: JSON + """Coin types to resolve primary names for.""" + coinTypes: [CoinType!] } """ @@ -1535,6 +1495,24 @@ type Renewal { """RenewalId represents an enssdk#RenewalId.""" scalar RenewalId +""" +Resolution container exposing trace and acceleration metadata alongside resolved ENS records. +""" +type Resolve { + """Protocol acceleration strategy status for this resolution.""" + acceleration: AccelerationStatus! + + """ + Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. + """ + trace: JSON +} + """A resolved ABI record for an ENS name.""" type ResolvedAbiRecord { contentType: BigInt! @@ -1602,7 +1580,7 @@ type ResolvedRecords { """ Stable cache key for these records: the InterpretedName used to resolve them. """ - id: UID! + id: InterpretedName! """Resolved ERC-165 interface implementer records for the requested ids.""" interfaces( @@ -1775,9 +1753,6 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } -"""UID is a stable cache key for records/profile entities.""" -scalar UID - """ Additional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name) """ diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index e3d6edfac7..0788574005 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -21,7 +21,6 @@ import type { RenewalId, ResolverId, ResolverRecordsId, - UID, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -48,7 +47,6 @@ export type OmnigraphScalars = { CoinType: CoinType; InterfaceId: InterfaceId; InterpretedName: InterpretedName; - UID: UID; InterpretedLabel: InterpretedLabel; BeautifiedName: BeautifiedName; BeautifiedLabel: BeautifiedLabel; From c0bbdb894b3a8cc5b7d4d0fe7090d44d34f99187 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 30 May 2026 15:25:07 +0300 Subject: [PATCH 27/30] apply more PR comment fixes --- .../account-primary-names-selection.test.ts | 2 +- .../lib/resolution/chain-coin-type.ts | 2 +- .../lib/resolution/resolve-primary-name-records.ts | 6 +++--- .../src/omnigraph-api/schema/account-resolve.ts | 12 +++++++----- .../src/omnigraph-api/schema/domain-canonical.ts | 4 ++-- apps/ensapi/src/omnigraph-api/schema/resolution.ts | 14 ++------------ apps/ensapi/src/omnigraph-api/schema/resolve.ts | 8 +++----- .../src/omnigraph/generated/introspection.ts | 9 ++++++--- .../enssdk/src/omnigraph/generated/schema.graphql | 10 +++++----- 9 files changed, 30 insertions(+), 37 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts index e2829e20e7..ca3720c85a 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -109,7 +109,7 @@ describe("buildAccountPrimaryNamesSelection", () => { one: primaryName(by: { coinType: ${coinNameToTypeMap.btc} }) { name } two: primaryName(by: { coinType: ${coinNameToTypeMap.ltc} }) { name } three: primaryNames(where: { coinTypes: [${coinNameToTypeMap.doge}, ${coinNameToTypeMap.sol}] }) { name } - four: primaryNames(where: { chains: ["DEFAULT", "ETHEREUM", "ARBITRUM"] }) { name } + four: primaryNames(where: { chains: ["DEFAULT", "ETHEREUM", "ARBITRUM_ONE"] }) { name } five: primaryName(by: { chain: "BASE" }) { name } `); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index b6f06232fe..5a321a4ce1 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -11,7 +11,7 @@ export const ENSIP19_COIN_NAMES = [ ["eth", "ETHEREUM"], ["base", "BASE"], ["op", "OPTIMISM"], - ["arb1", "ARBITRUM"], + ["arb1", "ARBITRUM_ONE"], ["linea", "LINEA"], ["scr", "SCROLL"], ] as const satisfies readonly (readonly [CoinName, string])[]; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index 182de1528d..2effb792c2 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -8,7 +8,7 @@ import { coinTypeToEnsip19Chain, ENSIP19_COIN_TYPES, } from "@/omnigraph-api/lib/resolution/chain-coin-type"; -import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/resolution"; +import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/primary-name-record"; type PrimaryNameResolutionOptions = { accelerate: boolean; @@ -16,7 +16,7 @@ type PrimaryNameResolutionOptions = { }; export type PrimaryNameRecordsResolution = { - trace: TracingTrace | null; + trace: TracingTrace; records: PrimaryNameRecordModel[]; }; @@ -42,7 +42,7 @@ export async function resolvePrimaryNameRecords( if (resolvableCoinTypes.length === 0) { return { - trace: null, + trace: [], records: coinTypes.map((coinType) => toPrimaryNameRecord(address, coinType, null)), }; } diff --git a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts index 650f398d71..1ff9c063db 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts @@ -8,10 +8,12 @@ import { normalizePrimaryNameByInput, } from "@/omnigraph-api/lib/resolution/primary-name-input"; import { - AccelerationStatusRef, - PrimaryNameByInput, type PrimaryNameRecordModel, PrimaryNameRecordRef, +} from "@/omnigraph-api/schema/primary-name-record"; +import { + AccelerationStatusRef, + PrimaryNameByInput, PrimaryNamesWhereInput, } from "@/omnigraph-api/schema/resolution"; @@ -20,7 +22,7 @@ export type AccountResolveModel = { coinTypes: CoinType[]; accelerate: boolean; canAccelerate: boolean; - trace: TracingTrace | null; + trace: TracingTrace; records: PrimaryNameRecordModel[]; }; @@ -34,8 +36,8 @@ AccountResolveRef.implement({ description: "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", type: "JSON", - nullable: true, - resolve: (parent) => parent.trace as unknown as JsonValue | null, + nullable: false, + resolve: (parent) => parent.trace as unknown as JsonValue, }), acceleration: t.field({ description: "Protocol acceleration strategy status for this Account resolution.", diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index f52193ca82..6864ac68c2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -14,8 +14,8 @@ DomainCanonicalRef.implement({ name: t.field({ description: "The Canonical Name for this Domain.", type: CanonicalNameRef, - nullable: true, - resolve: (domain) => domain.canonicalName ?? null, + nullable: false, + resolve: (domain) => domain.canonicalName!, }), depth: t.field({ description: diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 676791c992..859b2ebdfc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -16,11 +16,11 @@ AccelerationStatusRef.implement({ description: "Execution status metadata for a resolver strategy.", fields: (t) => ({ requested: t.exposeBoolean("requested", { - description: "Whether this strategy was requested by the caller.", + description: "Whether protocol acceleration was requested by the caller.", nullable: false, }), attempted: t.exposeBoolean("attempted", { - description: "Whether this strategy was attempted at runtime.", + description: "Whether protocol acceleration was attempted at runtime.", nullable: false, }), }), @@ -75,13 +75,3 @@ export const PrimaryNamesWhereInput = builder.inputType("PrimaryNamesWhereInput" }); export type PrimaryNamesWhereInputValue = typeof PrimaryNamesWhereInput.$inferInput; - -export { - type PrimaryNameRecordModel, - type PrimaryNameRecordParent, - PrimaryNameRecordRef, -} from "@/omnigraph-api/schema/primary-name-record"; -// Re-exports so that consumers of this module don't need to know about the file split. -export { DomainProfileRef } from "@/omnigraph-api/schema/profile"; -export type { ResolvedRecordsModel } from "@/omnigraph-api/schema/records"; -export { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolve.ts b/apps/ensapi/src/omnigraph-api/schema/resolve.ts index 0330f804a4..d94323462c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolve.ts @@ -5,11 +5,9 @@ import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { builder } from "@/omnigraph-api/builder"; import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { - AccelerationStatusRef, - DomainProfileRef, - ResolvedRecordsRef, -} from "@/omnigraph-api/schema/resolution"; +import { DomainProfileRef } from "@/omnigraph-api/schema/profile"; +import { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; +import { AccelerationStatusRef } from "@/omnigraph-api/schema/resolution"; export type ResolveModel = { accelerate: boolean; diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index abaf279ae5..5253c2943b 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1501,8 +1501,11 @@ const introspection = { { "name": "name", "type": { - "kind": "OBJECT", - "name": "CanonicalName" + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "CanonicalName" + } }, "args": [], "isDeprecated": false @@ -2033,7 +2036,7 @@ const introspection = { "name": "ENSIP19Chain", "enumValues": [ { - "name": "ARBITRUM", + "name": "ARBITRUM_ONE", "isDeprecated": false }, { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 88b3e0e0d5..2a50f127ec 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -1,9 +1,9 @@ """Execution status metadata for a resolver strategy.""" type AccelerationStatus { - """Whether this strategy was attempted at runtime.""" + """Whether protocol acceleration was attempted at runtime.""" attempted: Boolean! - """Whether this strategy was requested by the caller.""" + """Whether protocol acceleration was requested by the caller.""" requested: Boolean! } @@ -174,7 +174,7 @@ type AccountResolve { """ Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability. """ - trace: JSON + trace: JSON! } type AccountResolverPermissionsConnection { @@ -355,7 +355,7 @@ type DomainCanonical { depth: Int! """The Canonical Name for this Domain.""" - name: CanonicalName + name: CanonicalName! """ The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`. @@ -503,7 +503,7 @@ ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the EN @see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. """ enum ENSIP19Chain { - ARBITRUM + ARBITRUM_ONE BASE DEFAULT ETHEREUM From e6f7b84910b6f4896a8a7c47c83d17e4f09a290f Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 30 May 2026 15:46:34 +0300 Subject: [PATCH 28/30] apply easy comments --- .changeset/omnigraph-resolution-api.md | 7 ++- .../src/lib/resolution/reverse-resolution.ts | 14 ++++-- .../omnigraph-api/schema/account-resolve.ts | 6 +-- .../src/omnigraph-api/schema/account.ts | 4 +- .../src/omnigraph-api/schema/constants.ts | 2 +- .../schema/domain.integration.test.ts | 2 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 3 +- .../schema/primary-name-record.ts | 3 +- .../src/omnigraph-api/schema/records.ts | 3 +- .../src/omnigraph-api/schema/resolve.ts | 8 ++-- .../src/omnigraph/generated/introspection.ts | 7 ++- .../src/omnigraph/generated/schema.graphql | 48 +++++++++---------- 12 files changed, 55 insertions(+), 52 deletions(-) diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md index 491fc42894..8c6f14748d 100644 --- a/.changeset/omnigraph-resolution-api.md +++ b/.changeset/omnigraph-resolution-api.md @@ -4,7 +4,6 @@ Changes related to **Omnigraph**: -- add `Domain.records` with raw records resolution (`ResolvedRawTextRecord` for text record values) -- add `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(where: AccountPrimaryNamesWhereInput!)`. Primary name lookups accept `coinType` or `chain` (singular) and `coinTypes` or `chains` (plural, `@oneOf`); `ENSIP19Chain` includes `DEFAULT`; `PrimaryNameRecord.name` is a `CanonicalName` with `interpreted` and `beautified` -- add `UID` cache keys on `ResolvedRecords` (keyed by resolution `InterpretedName`) for graphcache normalization across queries -- add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null +- add `Domain.resolve { records, trace, acceleration, profile? }` for forward resolution driven by the GraphQL selection set +- add `Account.resolve { primaryName(by: ...), primaryNames(where: ...) }` for reverse (ENSIP-19 primary name) resolution with `@oneOf` inputs (`coinType`/`chain`, `coinTypes`/`chains`) +- add `PrimaryNameRecord.resolve { records, ... }` for forward resolution of the resolved primary name diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index 6a732cfa77..8b07d29e9f 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -1,8 +1,8 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; import { type Address, - type ChainId, type CoinType, + type DefaultableChainId, coinTypeReverseLabel, evmChainIdToCoinType, reverseName, @@ -40,7 +40,7 @@ type ReverseResolutionOptions = Parameters[2]; * @param address the adddress whose Primary Name to resolve * @param coinType the coinType within which to resolve the address' Primary Name * @param options Optional settings - * @param options.accelerate Whether to accelerate resolution (default: true) + * @param options.accelerate Whether to attempt accelerated resolution (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) */ export async function resolveReverse( @@ -179,10 +179,16 @@ export async function resolveReverse( ); } -/** Thin chainId wrapper around {@link resolveReverse} for callers at the REST API boundary. */ +/** + * Thin `chainId` wrapper around {@link resolveReverse} for callers at the REST API boundary. + * + * @param address The address whose primary name to resolve. + * @param chainId The EVM chain id for the reverse lookup. `0` is valid as the ENSIP-19 default chain id. + * @param options Optional resolution settings. + */ export async function resolveReverseByChainId( address: Address, - chainId: ChainId, + chainId: DefaultableChainId, options: ReverseResolutionOptions, ): Promise { return resolveReverse(address, evmChainIdToCoinType(chainId), options); diff --git a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts index 1ff9c063db..534dcf307d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts @@ -30,17 +30,17 @@ export const AccountResolveRef = builder.objectRef("Account AccountResolveRef.implement({ description: - "Nested account resolution container exposing primary-name resolution with shared acceleration settings.", + "Nested account resolution container exposing primary name resolution.", fields: (t) => ({ trace: t.field({ description: - "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", + "Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes.", type: "JSON", nullable: false, resolve: (parent) => parent.trace as unknown as JsonValue, }), acceleration: t.field({ - description: "Protocol acceleration strategy status for this Account resolution.", + description: "Whether protocol acceleration was requested and attempted for this reverse resolution.", type: AccelerationStatusRef, nullable: false, resolve: ({ accelerate, canAccelerate }) => ({ diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 4c9888a90d..9fc5d0318e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -73,7 +73,7 @@ AccountRef.implement({ // Account.resolve ////////////////// resolve: t.field({ - description: "Resolve Primary Names for this Account.", + description: "Resolve primary names for this Account.", type: AccountResolveRef, nullable: false, args: { @@ -91,7 +91,7 @@ AccountRef.implement({ if (coinTypes === null) { throw new GraphQLError( - "Account.resolve requires at least one `primaryName(by: ...)` or `primaryNames(where: ...)` field to be selected.", + "Account.resolve requires at least one `primaryName(by: ...)` or `primaryNames(where: ...)` field to be selected for reverse resolution.", ); } diff --git a/apps/ensapi/src/omnigraph-api/schema/constants.ts b/apps/ensapi/src/omnigraph-api/schema/constants.ts index 736db5ab38..dd61d13434 100644 --- a/apps/ensapi/src/omnigraph-api/schema/constants.ts +++ b/apps/ensapi/src/omnigraph-api/schema/constants.ts @@ -18,5 +18,5 @@ export const RESOLVE_ACCELERATE_ARG = { required: false, defaultValue: true, description: - "When true (default), Protocol Acceleration is used for record resolution, when supported.\n@see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration", + "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.\n@see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration", } as const; 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 d4785f25e4..5ab60b33aa 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -619,7 +619,7 @@ describe("Domain.records", () => { }); it("returns null for an unnormalized canonical name (e.g. with labelhash)", async () => { - // A name with a labelhash is an InterpretedName but not a normalized name. + // A name with a label that is an encoded labelhash is an InterpretedName but not a normalized name. // Even if it exists in the DB, resolve should return null. const unnormalizedName = "[0000000000000000000000000000000000000000000000000000000000000000].eth"; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 4eaf12e1d1..31ccc4b0b2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -177,8 +177,7 @@ DomainInterfaceRef.implement({ // Domain.resolve ////////////////// resolve: t.field({ - description: - "Resolve protocol-level data for this Domain with trace and acceleration metadata.", + description: "Resolve protocol-level data for this Domain.", type: ResolveRef, nullable: false, args: { 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 7bfa930dae..b2846030be 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -48,8 +48,7 @@ PrimaryNameRecordRef.implement({ resolve: (r) => r.name ?? null, }), resolve: t.field({ - description: - "Resolve protocol-level records (and optionally profile preview) for this primary name.", + description: "Forward resolve data for this primary name.", type: ResolveRef, nullable: false, resolve: async (parent, _args, context, info): Promise => { diff --git a/apps/ensapi/src/omnigraph-api/schema/records.ts b/apps/ensapi/src/omnigraph-api/schema/records.ts index ec7d571b51..0a4a9ccecc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/records.ts +++ b/apps/ensapi/src/omnigraph-api/schema/records.ts @@ -44,7 +44,8 @@ ResolvedAddressRecordRef.implement({ resolve: (r) => r.coinType, }), address: t.exposeString("address", { - description: "The address value, or null if not set.", + description: + "The raw address record value, or null if not set. May be any address format for the associated coin type and may require validation or preprocessing before use. Only EVM addresses are guaranteed to be NormalizedAddress values; see ENSIP-9 (https://docs.ens.domains/ensip/9) and address-encoder (https://github.com/ensdomains/address-encoder).", nullable: true, }), }), diff --git a/apps/ensapi/src/omnigraph-api/schema/resolve.ts b/apps/ensapi/src/omnigraph-api/schema/resolve.ts index d94323462c..dada7b55b4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolve.ts @@ -20,17 +20,17 @@ export const ResolveRef = builder.objectRef("Resolve"); ResolveRef.implement({ description: - "Resolution container exposing trace and acceleration metadata alongside resolved ENS records.", + "Nested domain resolution container exposing resolved data for the domain.", fields: (t) => ({ trace: t.field({ description: - "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability.", + "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes.", type: "JSON", nullable: true, resolve: (parent) => parent.trace as unknown as JsonValue | null, }), acceleration: t.field({ - description: "Protocol acceleration strategy status for this resolution.", + description: "Whether protocol acceleration was requested and attempted for this resolution.", type: AccelerationStatusRef, nullable: false, resolve: ({ accelerate, canAccelerate }) => ({ @@ -49,7 +49,7 @@ ResolveRef.implement({ ...(INCLUDE_DEV_METHODS && { profile: t.field({ description: - "PREVIEW: An interpreted ENS profile for this name. Types are defined for query ergonomics; resolution is not yet wired. Returns null when no records resolution is available.", + "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical or normalized.", type: DomainProfileRef, nullable: true, resolve: (parent) => (parent.records ? {} : null), diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 5253c2943b..30f83961d6 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -871,8 +871,11 @@ const introspection = { { "name": "trace", "type": { - "kind": "SCALAR", - "name": "JSON" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "JSON" + } }, "args": [], "isDeprecated": false diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 2a50f127ec..8b3d15fa8e 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -31,10 +31,10 @@ type Account { """The Permissions on Registries granted to this Account.""" registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection - """Resolve Primary Names for this Account.""" + """Resolve primary names for this Account.""" resolve( """ - When true (default), Protocol Acceleration is used for record resolution, when supported. + 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 @@ -148,11 +148,11 @@ type AccountRegistryPermissionsConnectionEdge { node: RegistryPermissionsUser! } -""" -Nested account resolution container exposing primary-name resolution with shared acceleration settings. -""" +"""Nested account resolution container exposing primary name resolution.""" type AccountResolve { - """Protocol acceleration strategy status for this Account resolution.""" + """ + Whether protocol acceleration was requested and attempted for this reverse resolution. + """ acceleration: AccelerationStatus! """ @@ -172,7 +172,7 @@ type AccountResolve { ): [PrimaryNameRecord!]! """ - Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability. + Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes. """ trace: JSON! } @@ -322,12 +322,10 @@ interface Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - Resolve protocol-level data for this Domain with trace and acceleration metadata. - """ + """Resolve protocol-level data for this Domain.""" resolve( """ - When true (default), Protocol Acceleration is used for record resolution, when supported. + 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 @@ -556,12 +554,10 @@ type ENSv1Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - Resolve protocol-level data for this Domain with trace and acceleration metadata. - """ + """Resolve protocol-level data for this Domain.""" resolve( """ - When true (default), Protocol Acceleration is used for record resolution, when supported. + 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 @@ -679,12 +675,10 @@ type ENSv2Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! - """ - Resolve protocol-level data for this Domain with trace and acceleration metadata. - """ + """Resolve protocol-level data for this Domain.""" resolve( """ - When true (default), Protocol Acceleration is used for record resolution, when supported. + 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 @@ -1214,9 +1208,7 @@ type PrimaryNameRecord { """ name: CanonicalName - """ - Resolve protocol-level records (and optionally profile preview) for this primary name. - """ + """Forward resolve data for this primary name.""" resolve: Resolve! } @@ -1496,10 +1488,12 @@ type Renewal { scalar RenewalId """ -Resolution container exposing trace and acceleration metadata alongside resolved ENS records. +Nested domain resolution container exposing resolved data for the domain. """ type Resolve { - """Protocol acceleration strategy status for this resolution.""" + """ + Whether protocol acceleration was requested and attempted for this resolution. + """ acceleration: AccelerationStatus! """ @@ -1508,7 +1502,7 @@ type Resolve { records: ResolvedRecords """ - Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes. """ trace: JSON } @@ -1521,7 +1515,9 @@ type ResolvedAbiRecord { """A resolved address record for an ENS name.""" type ResolvedAddressRecord { - """The address value, or null if not set.""" + """ + The raw address record value, or null if not set. May be any address format for the associated coin type and may require validation or preprocessing before use. Only EVM addresses are guaranteed to be NormalizedAddress values; see ENSIP-9 (https://docs.ens.domains/ensip/9) and address-encoder (https://github.com/ensdomains/address-encoder). + """ address: String """The coin type for this address record.""" From d5d36331cb0bacf3638660108ff38f7eb39de069 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 30 May 2026 16:28:22 +0300 Subject: [PATCH 29/30] final fixes (hope so) --- .../src/lib/resolution/reverse-resolution.ts | 2 +- .../account-primary-names-selection.test.ts | 12 +- .../lib/resolution/chain-coin-type.ts | 38 +- .../lib/resolution/primary-name-input.ts | 6 +- .../lib/resolution/records-selection.test.ts | 15 +- .../resolve-primary-name-records.ts | 8 +- .../lib/resolution/test-helpers.ts | 28 +- apps/ensapi/src/omnigraph-api/schema.ts | 2 +- .../schema/account.integration.test.ts | 23 ++ .../src/omnigraph-api/schema/account.ts | 27 +- .../omnigraph-api/schema/domain-canonical.ts | 10 +- .../schema/domain.integration.test.ts | 18 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 9 +- .../schema/{resolve.ts => forward-resolve.ts} | 9 +- .../schema/primary-name-record.ts | 21 +- .../src/omnigraph-api/schema/resolution.ts | 18 +- ...{account-resolve.ts => reverse-resolve.ts} | 16 +- .../react/omnigraph/_lib/cache-exchange.ts | 4 +- .../src/omnigraph/generated/introspection.ts | 324 +++++++++--------- .../src/omnigraph/generated/schema.graphql | 144 ++++---- 20 files changed, 401 insertions(+), 333 deletions(-) rename apps/ensapi/src/omnigraph-api/schema/{resolve.ts => forward-resolve.ts} (89%) rename apps/ensapi/src/omnigraph-api/schema/{account-resolve.ts => reverse-resolve.ts} (82%) diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index 8b07d29e9f..3b18b585d8 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -2,8 +2,8 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; import { type Address, type CoinType, - type DefaultableChainId, coinTypeReverseLabel, + type DefaultableChainId, evmChainIdToCoinType, reverseName, } from "enssdk"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts index ca3720c85a..75ddd4b0f1 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -9,7 +9,7 @@ import { } from "graphql"; import { describe, expect, it } from "vitest"; -import { parseFieldNode } from "@/omnigraph-api/lib/resolution/test-helpers"; +import { mockResolveContainerInfo } from "@/omnigraph-api/lib/resolution/test-helpers"; import { buildAccountPrimaryNamesSelection } from "./account-primary-names-selection"; @@ -42,8 +42,9 @@ const PrimaryNameRecordType = new GraphQLObjectType({ }, }); +// Name must stay in sync with the real Pothos schema — see reverse-resolve.ts const AccountResolveType = new GraphQLObjectType({ - name: "AccountResolve", + name: "ReverseResolve", fields: { primaryName: { type: PrimaryNameRecordType, @@ -61,12 +62,7 @@ const AccountResolveType = new GraphQLObjectType({ }); function resolveInfoForAccountResolveSubselection(subselection: string): GraphQLResolveInfo { - return { - fieldNodes: [parseFieldNode("resolve", subselection)], - fragments: {}, - returnType: AccountResolveType, - variableValues: {}, - } as unknown as GraphQLResolveInfo; + return mockResolveContainerInfo("resolve", subselection, AccountResolveType); } describe("buildAccountPrimaryNamesSelection", () => { diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts index 5a321a4ce1..83f2e248cb 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -3,10 +3,10 @@ import { coinNameToTypeMap } from "@ensdomains/address-encoder"; import type { CoinType } from "enssdk"; /** - * address-encoder coin names for ENSIP-19 primary-name chains, paired with their canonical - * GraphQL `ENSIP19Chain` enum values. + * address-encoder coin names for primary-name chains, paired with their canonical + * GraphQL `ChainName` enum values. */ -export const ENSIP19_COIN_NAMES = [ +export const CHAIN_NAME_ENTRIES = [ ["default", "DEFAULT"], ["eth", "ETHEREUM"], ["base", "BASE"], @@ -16,29 +16,31 @@ export const ENSIP19_COIN_NAMES = [ ["scr", "SCROLL"], ] as const satisfies readonly (readonly [CoinName, string])[]; -export type ENSIP19CoinName = (typeof ENSIP19_COIN_NAMES)[number][0]; -export type ENSIP19ChainValue = (typeof ENSIP19_COIN_NAMES)[number][1]; +export type ChainNameCoinName = (typeof CHAIN_NAME_ENTRIES)[number][0]; -export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map( +/** A `ChainName` enum value. */ +export type ChainNameValue = (typeof CHAIN_NAME_ENTRIES)[number][1]; + +export const CHAIN_NAME_VALUES = CHAIN_NAME_ENTRIES.map( ([, chain]) => chain, -) as unknown as readonly [ENSIP19ChainValue, ...ENSIP19ChainValue[]]; +) as unknown as readonly [ChainNameValue, ...ChainNameValue[]]; -/** Canonical ENSIP-9 coin types for ENSIP-19 primary-name chains. */ -export const ENSIP19_COIN_TYPES = ENSIP19_COIN_NAMES.map( +/** Canonical ENSIP-9 coin types for primary-name `ChainName` values. */ +export const CHAIN_NAME_COIN_TYPES = CHAIN_NAME_ENTRIES.map( ([coinName]) => coinNameToTypeMap[coinName] as CoinType, ); -const ensip19ChainToCoinName = Object.fromEntries( - ENSIP19_COIN_NAMES.map(([coinName, chain]) => [chain, coinName]), -) as Record; +const chainNameToCoinName = Object.fromEntries( + CHAIN_NAME_ENTRIES.map(([coinName, chain]) => [chain, coinName]), +) as Record; -/** Maps an `ENSIP19Chain` enum value to its canonical ENSIP-9 coin type. */ -export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => - coinNameToTypeMap[ensip19ChainToCoinName[chain]] as CoinType; +/** Maps a `ChainName` enum value to its canonical ENSIP-9 coin type. */ +export const chainNameToCoinType = (chain: ChainNameValue): CoinType => + coinNameToTypeMap[chainNameToCoinName[chain]] as CoinType; -/** Maps a coin type to an `ENSIP19Chain` enum value, or null when not ENSIP-19 supported. */ -export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => { - const entry = ENSIP19_COIN_NAMES.find(([coinName]) => coinNameToTypeMap[coinName] === coinType); +/** Maps a coin type to a `ChainName` enum value, or null when not represented in `ChainName`. */ +export const coinTypeToChainName = (coinType: CoinType): ChainNameValue | null => { + const entry = CHAIN_NAME_ENTRIES.find(([coinName]) => coinNameToTypeMap[coinName] === coinType); if (!entry) return null; return entry[1]; }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts index 22afc51422..088ba708e4 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -1,6 +1,6 @@ import type { CoinType } from "enssdk"; -import { ensip19ChainToCoinType } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { chainNameToCoinType } from "@/omnigraph-api/lib/resolution/chain-coin-type"; import type { PrimaryNameByInputValue, PrimaryNamesWhereInputValue, @@ -11,7 +11,7 @@ import type { */ export const normalizePrimaryNameByInput = (by: PrimaryNameByInputValue): CoinType => { if (by.coinType != null) return by.coinType; - if (by.chain != null) return ensip19ChainToCoinType(by.chain); + if (by.chain != null) return chainNameToCoinType(by.chain); // this should never happen as the schema with `@oneOf` prevents it throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); }; @@ -23,7 +23,7 @@ export const normalizeAccountPrimaryNamesWhereInput = ( where: PrimaryNamesWhereInputValue, ): CoinType[] => { if (where.coinTypes != null) return where.coinTypes; - if (where.chains != null) return where.chains.map(ensip19ChainToCoinType); + if (where.chains != null) return where.chains.map(chainNameToCoinType); // this should never happen as the schema with `@oneOf` prevents it throw new Error("PrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index b37f8c803f..552743d946 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -20,7 +20,10 @@ import { RECORDS_SELECTION_PARAMETRIC_FIELDS, RECORDS_SELECTION_SIMPLE_FIELDS, } from "@/omnigraph-api/lib/resolution/records-selection-config"; -import { parseFieldNode } from "@/omnigraph-api/lib/resolution/test-helpers"; +import { + mockResolveContainerInfo, + parseFieldNode, +} from "@/omnigraph-api/lib/resolution/test-helpers"; // These mock types mirror the real Pothos-generated schema types. They cannot be imported // from `@/omnigraph-api/schema` directly because doing so loads the full Pothos schema into @@ -68,8 +71,9 @@ function buildMockResolvedRecordsType() { const ResolvedRecordsType = buildMockResolvedRecordsType(); +// Name must stay in sync with the real Pothos schema — see forward-resolve.ts const DomainResolveType = new GraphQLObjectType({ - name: "Resolve", + name: "ForwardResolve", fields: { trace: { type: GraphQLString }, records: { type: ResolvedRecordsType }, @@ -77,12 +81,7 @@ const DomainResolveType = new GraphQLObjectType({ }); function resolveInfoForDomainResolveSubselection(subselection: string): GraphQLResolveInfo { - return { - fieldNodes: [parseFieldNode("resolve", subselection)], - fragments: {}, - returnType: DomainResolveType, - variableValues: {}, - } as unknown as GraphQLResolveInfo; + return mockResolveContainerInfo("resolve", subselection, DomainResolveType); } function mockResolveInfo( diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts index 2effb792c2..f8e04c462d 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -5,8 +5,8 @@ import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { resolvePrimaryNamesByCoinTypes } from "@/lib/resolution/multichain-primary-name-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { - coinTypeToEnsip19Chain, - ENSIP19_COIN_TYPES, + CHAIN_NAME_COIN_TYPES, + coinTypeToChainName, } from "@/omnigraph-api/lib/resolution/chain-coin-type"; import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/primary-name-record"; @@ -27,7 +27,7 @@ const toPrimaryNameRecord = ( ): PrimaryNameRecordModel => ({ address, coinType, - chain: coinTypeToEnsip19Chain(coinType), + chain: coinTypeToChainName(coinType), name, }); @@ -37,7 +37,7 @@ export async function resolvePrimaryNameRecords( coinTypes: CoinType[], options: PrimaryNameResolutionOptions, ): Promise { - const supportedCoinTypes = new Set(ENSIP19_COIN_TYPES); + const supportedCoinTypes = new Set(CHAIN_NAME_COIN_TYPES); const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); if (resolvableCoinTypes.length === 0) { diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts index 37f357c891..269797721f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts @@ -1,4 +1,10 @@ -import { type FieldNode, type OperationDefinitionNode, parse } from "graphql"; +import { + type FieldNode, + type GraphQLObjectType, + type GraphQLResolveInfo, + type OperationDefinitionNode, + parse, +} from "graphql"; /** * Parses a GraphQL document of the form `{ { } }` and returns @@ -15,3 +21,23 @@ export function parseFieldNode(fieldName: string, subselection: string): FieldNo return field; } + +/** + * Builds a minimal mock {@link GraphQLResolveInfo} for a container field resolver (e.g. + * `resolve { }` or `records { }`). Used in unit tests for + * selection-building helpers that inspect `info.fieldNodes` and `info.returnType`. + * + * Keep mock type names in sync with the real schema — e.g. `ForwardResolve`, `ReverseResolve`. + */ +export function mockResolveContainerInfo( + containerField: string, + subselection: string, + returnType: GraphQLObjectType, +): GraphQLResolveInfo { + return { + fieldNodes: [parseFieldNode(containerField, subselection)], + fragments: {}, + returnType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 3c5061f07e..112ac3f20e 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -1,7 +1,7 @@ import { builder } from "@/omnigraph-api/builder"; import "./schema/account-id"; -import "./schema/account-resolve"; +import "./schema/reverse-resolve"; import "./schema/connection"; import "./schema/domain"; import "./schema/domain-canonical"; 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 66cb511e70..55e4efac97 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -661,4 +661,27 @@ describe("Account.primaryName and Account.primaryNames", () => { ), ).rejects.toThrow(); }); + + it("does not null-propagate Account when only acceleration is queried (no primaryName selected)", async () => { + await expect( + request<{ account: { id: string; resolve: { acceleration: { requested: boolean } } } }>( + gql` + query AccountResolveAccelerationOnly($address: Address!) { + account(by: { address: $address }) { + id + resolve { + acceleration { requested } + } + } + } + `, + { address: accounts.owner.address }, + ), + ).resolves.toMatchObject({ + account: { + id: accounts.owner.address, + resolve: { acceleration: { requested: true } }, + }, + }); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 9fc5d0318e..783d832b3f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,7 +1,6 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; import type { Address } from "enssdk"; -import { GraphQLError } from "graphql"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; @@ -13,10 +12,6 @@ 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"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; -import { - type AccountResolveModel, - AccountResolveRef, -} from "@/omnigraph-api/schema/account-resolve"; import { ID_PAGINATED_CONNECTION_ARGS, RESOLVE_ACCELERATE_ARG, @@ -28,6 +23,10 @@ import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; +import { + type ReverseResolveModel, + ReverseResolveRef, +} from "@/omnigraph-api/schema/reverse-resolve"; export const AccountRef = builder.loadableObjectRef("Account", { load: (ids: Address[]) => { @@ -74,7 +73,7 @@ AccountRef.implement({ ////////////////// resolve: t.field({ description: "Resolve primary names for this Account.", - type: AccountResolveRef, + type: ReverseResolveRef, nullable: false, args: { accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), @@ -84,15 +83,23 @@ AccountRef.implement({ { accelerate: accelerateArg }, context, info, - ): Promise => { + ): Promise => { const accelerate = accelerateArg ?? true; const { canAccelerate } = context; const coinTypes = buildAccountPrimaryNamesSelection(info); + // No primaryName/primaryNames fields selected (e.g. only acceleration/trace queried). + // Return an empty model rather than throwing so the non-nullable resolve field does not + // null-propagate the entire Account. if (coinTypes === null) { - throw new GraphQLError( - "Account.resolve requires at least one `primaryName(by: ...)` or `primaryNames(where: ...)` field to be selected for reverse resolution.", - ); + return { + address: account.id, + coinTypes: [], + accelerate, + canAccelerate, + trace: [], + records: [], + }; } const { trace, records } = await resolvePrimaryNameRecords(account.id, coinTypes, { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index 6864ac68c2..bdd01024dc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -15,7 +15,15 @@ DomainCanonicalRef.implement({ description: "The Canonical Name for this Domain.", type: CanonicalNameRef, nullable: false, - resolve: (domain) => domain.canonicalName!, + resolve: (domain) => { + if (domain.canonicalName == null) { + throw new Error( + `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, + ); + } + + return domain.canonicalName; + }, }), depth: t.field({ description: 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 5ab60b33aa..4e4a2ffd27 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -2,9 +2,11 @@ import { ADDR_REVERSE_NODE, asInterpretedLabel, type CoinType, + type ContentType, type DomainId, ETH_COIN_TYPE, ETH_NODE, + type Hex, type InterpretedLabel, type InterpretedName, labelhashInterpretedLabel, @@ -13,6 +15,7 @@ import { makeENSv2DomainId, makeENSv2RegistryId, makeStorageId, + type NormalizedAddress, } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; @@ -494,7 +497,7 @@ describe("Domain.records", () => { domain: { resolve: { records: { - addresses: Array<{ coinType: CoinType; address: string | null }>; + addresses: Array<{ coinType: CoinType; address: Hex | null }>; texts: Array<{ key: string; value: string | null }>; } | null; }; @@ -510,9 +513,10 @@ describe("Domain.records", () => { pubkey: { x: string; y: string } | null; dnszonehash: string | null; version: string | null; - abi: { contentType: string; data: string } | null; + abi: { contentType: ContentType; data: string } | null; interfaces: Array<{ interfaceId: string; implementer: string | null }>; - addresses: Array<{ coinType: CoinType; address: string | null }>; + // address is string (not Hex/NormalizedAddress) because non-EVM records may use non-Ethereum formats; matches GraphQL String field + addresses: Array<{ coinType: CoinType; address: Hex | null }>; texts: Array<{ key: string; value: string | null }>; } | null; }; @@ -586,6 +590,7 @@ describe("Domain.records", () => { name: "test.eth", addresses: [ETH_COIN_TYPE, 0, 2], texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], + // BigInt GraphQL vars must be strings here — JSON.stringify (used by the test client) cannot serialize bigint contentTypeMask: "1", interfaceIds: [fixtures.fourBytesInterface], }), @@ -655,8 +660,8 @@ describe("Domain.records", () => { domain: { resolve: { records: { - abi1: { contentType: string; data: string } | null; - abi2: { contentType: string; data: string } | null; + abi1: { contentType: ContentType; data: string } | null; + abi2: { contentType: ContentType; data: string } | null; }; }; }; @@ -685,7 +690,8 @@ describe("Domain.records", () => { profile: { description: string | null; avatar: { url: string | null } | null; - addresses: { ethereum: string | null } | null; + // ethereum address is a checksummed EVM address, so NormalizedAddress is the narrowed type + addresses: { ethereum: NormalizedAddress | null } | null; socials: { github: { handle: string | null; url: string | null } | null } | null; } | null; }; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 31ccc4b0b2..43a175e1e2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -40,11 +40,14 @@ import { import { DomainResolverRef } from "@/omnigraph-api/schema/domain-resolver"; import { EventRef } from "@/omnigraph-api/schema/event"; import { EventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; +import { + type ForwardResolveModel, + ForwardResolveRef, +} from "@/omnigraph-api/schema/forward-resolve"; import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; -import { type ResolveModel, ResolveRef } from "@/omnigraph-api/schema/resolve"; const tracer = trace.getTracer("schema/Domain"); @@ -178,7 +181,7 @@ DomainInterfaceRef.implement({ ////////////////// resolve: t.field({ description: "Resolve protocol-level data for this Domain.", - type: ResolveRef, + type: ForwardResolveRef, nullable: false, args: { accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), @@ -188,7 +191,7 @@ DomainInterfaceRef.implement({ { accelerate: accelerateArg }, context, info, - ): Promise => { + ): Promise => { const accelerate = accelerateArg ?? true; const { canAccelerate } = context; const name = domain.canonicalName; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolve.ts b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts similarity index 89% rename from apps/ensapi/src/omnigraph-api/schema/resolve.ts rename to apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts index dada7b55b4..37f97944f6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -9,18 +9,17 @@ import { DomainProfileRef } from "@/omnigraph-api/schema/profile"; import { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; import { AccelerationStatusRef } from "@/omnigraph-api/schema/resolution"; -export type ResolveModel = { +export type ForwardResolveModel = { accelerate: boolean; canAccelerate: boolean; trace: TracingTrace | null; records: ResolvedRecordsModel | null; }; -export const ResolveRef = builder.objectRef("Resolve"); +export const ForwardResolveRef = builder.objectRef("ForwardResolve"); -ResolveRef.implement({ - description: - "Nested domain resolution container exposing resolved data for the domain.", +ForwardResolveRef.implement({ + description: "Nested domain resolution container exposing resolved data for the domain.", fields: (t) => ({ trace: t.field({ description: 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 b2846030be..1148c42791 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -3,21 +3,24 @@ import { type Address, type CoinType, type InterpretedName, isNormalizedName } f import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; -import type { ENSIP19ChainValue } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { ChainNameValue } from "@/omnigraph-api/lib/resolution/chain-coin-type"; import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; -import { ENSIP19Chain } from "@/omnigraph-api/schema/resolution"; -import { type ResolveModel, ResolveRef } from "@/omnigraph-api/schema/resolve"; +import { + type ForwardResolveModel, + ForwardResolveRef, +} from "@/omnigraph-api/schema/forward-resolve"; +import { ChainName } from "@/omnigraph-api/schema/resolution"; export type PrimaryNameRecordModel = { address: Address; coinType: CoinType; - chain: ENSIP19ChainValue | null; + chain: ChainNameValue | null; name: InterpretedName | null; }; -/** GraphQL parent for `PrimaryNameRecord`, including `AccountResolve` acceleration settings. */ +/** GraphQL parent for `PrimaryNameRecord`, including `ReverseResolve` acceleration settings. */ export type PrimaryNameRecordParent = PrimaryNameRecordModel & { accelerate: boolean; }; @@ -35,8 +38,8 @@ PrimaryNameRecordRef.implement({ }), chain: t.field({ description: - "The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`.", - type: ENSIP19Chain, + "The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`.", + type: ChainName, nullable: true, resolve: (r) => r.chain, }), @@ -49,9 +52,9 @@ PrimaryNameRecordRef.implement({ }), resolve: t.field({ description: "Forward resolve data for this primary name.", - type: ResolveRef, + type: ForwardResolveRef, nullable: false, - resolve: async (parent, _args, context, info): Promise => { + resolve: async (parent, _args, context, info): Promise => { const { name, accelerate } = parent; const { canAccelerate } = context; diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.ts index 859b2ebdfc..5722d7eb1a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -1,5 +1,5 @@ import { builder } from "@/omnigraph-api/builder"; -import { ENSIP19_CHAIN_VALUES } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { CHAIN_NAME_VALUES } from "@/omnigraph-api/lib/resolution/chain-coin-type"; ////////////////////// // AccelerationStatus @@ -27,12 +27,12 @@ AccelerationStatusRef.implement({ }); ////////////////// -// ENSIP19Chain +// ChainName ////////////////// -export const ENSIP19Chain = builder.enumType("ENSIP19Chain", { +export const ChainName = builder.enumType("ChainName", { description: - "ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain.\n@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details.", - values: ENSIP19_CHAIN_VALUES, + "Primary-name chains supported by the Omnigraph API. Use `DEFAULT` for the default EVM chain.\n@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details.", + values: CHAIN_NAME_VALUES, }); /////////////////////// @@ -48,8 +48,8 @@ export const PrimaryNameByInput = builder.inputType("PrimaryNameByInput", { description: "The ENSIP-9 coin type to resolve the primary name for.", }), chain: t.field({ - type: ENSIP19Chain, - description: "An ENSIP-19 supported chain to resolve the primary name for.", + type: ChainName, + description: "A `ChainName` to resolve the primary name for.", }), }), }); @@ -67,8 +67,8 @@ export const PrimaryNamesWhereInput = builder.inputType("PrimaryNamesWhereInput" validate: { minLength: 1 }, }), chains: t.field({ - type: [ENSIP19Chain], - description: "ENSIP-19 supported chains to resolve primary names for.", + type: [ChainName], + description: "`ChainName` values to resolve primary names for.", validate: { minLength: 1 }, }), }), diff --git a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts similarity index 82% rename from apps/ensapi/src/omnigraph-api/schema/account-resolve.ts rename to apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts index 534dcf307d..091cd48d2b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts @@ -17,7 +17,7 @@ import { PrimaryNamesWhereInput, } from "@/omnigraph-api/schema/resolution"; -export type AccountResolveModel = { +export type ReverseResolveModel = { address: Address; coinTypes: CoinType[]; accelerate: boolean; @@ -26,11 +26,10 @@ export type AccountResolveModel = { records: PrimaryNameRecordModel[]; }; -export const AccountResolveRef = builder.objectRef("AccountResolve"); +export const ReverseResolveRef = builder.objectRef("ReverseResolve"); -AccountResolveRef.implement({ - description: - "Nested account resolution container exposing primary name resolution.", +ReverseResolveRef.implement({ + description: "Nested account resolution container exposing primary name resolution.", fields: (t) => ({ trace: t.field({ description: @@ -40,7 +39,8 @@ AccountResolveRef.implement({ resolve: (parent) => parent.trace as unknown as JsonValue, }), acceleration: t.field({ - description: "Whether protocol acceleration was requested and attempted for this reverse resolution.", + description: + "Whether protocol acceleration was requested and attempted for this reverse resolution.", type: AccelerationStatusRef, nullable: false, resolve: ({ accelerate, canAccelerate }) => ({ @@ -49,7 +49,7 @@ AccountResolveRef.implement({ }), }), primaryName: t.field({ - description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", + description: "The primary name for this Account on a specific coin type or chain.", type: PrimaryNameRecordRef, nullable: false, args: { @@ -69,7 +69,7 @@ AccountResolveRef.implement({ }, }), primaryNames: t.field({ - description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", + description: "Primary names for this Account on the requested coin types or chains.", type: [PrimaryNameRecordRef], nullable: false, args: { diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 51c2eb8460..665f56c688 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -49,8 +49,8 @@ export const omnigraphCacheExchange = cacheExchange({ CanonicalName: EMBEDDED_DATA, DomainCanonical: EMBEDDED_DATA, DomainResolver: EMBEDDED_DATA, - Resolve: EMBEDDED_DATA, - AccountResolve: EMBEDDED_DATA, + ForwardResolve: EMBEDDED_DATA, + ReverseResolve: EMBEDDED_DATA, ResolutionStatus: EMBEDDED_DATA, PrimaryNameRecord: EMBEDDED_DATA, AccelerationStatus: EMBEDDED_DATA, diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 30f83961d6..b51975a7d0 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -275,7 +275,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "AccountResolve" + "name": "ReverseResolve" } }, "args": [ @@ -800,89 +800,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "OBJECT", - "name": "AccountResolve", - "fields": [ - { - "name": "acceleration", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "AccelerationStatus" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "primaryName", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PrimaryNameRecord" - } - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PrimaryNameByInput" - } - } - } - ], - "isDeprecated": false - }, - { - "name": "primaryNames", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "PrimaryNameRecord" - } - } - } - }, - "args": [ - { - "name": "where", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PrimaryNamesWhereInput" - } - } - } - ], - "isDeprecated": false - }, - { - "name": "trace", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "JSON" - } - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "AccountResolverPermissionsConnection", @@ -1215,6 +1132,40 @@ const introspection = { "kind": "SCALAR", "name": "ChainId" }, + { + "kind": "ENUM", + "name": "ChainName", + "enumValues": [ + { + "name": "ARBITRUM_ONE", + "isDeprecated": false + }, + { + "name": "BASE", + "isDeprecated": false + }, + { + "name": "DEFAULT", + "isDeprecated": false + }, + { + "name": "ETHEREUM", + "isDeprecated": false + }, + { + "name": "LINEA", + "isDeprecated": false + }, + { + "name": "OPTIMISM", + "isDeprecated": false + }, + { + "name": "SCROLL", + "isDeprecated": false + } + ] + }, { "kind": "SCALAR", "name": "CoinType" @@ -1384,7 +1335,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "Resolve" + "name": "ForwardResolve" } }, "args": [ @@ -2034,40 +1985,6 @@ const introspection = { ], "isOneOf": false }, - { - "kind": "ENUM", - "name": "ENSIP19Chain", - "enumValues": [ - { - "name": "ARBITRUM_ONE", - "isDeprecated": false - }, - { - "name": "BASE", - "isDeprecated": false - }, - { - "name": "DEFAULT", - "isDeprecated": false - }, - { - "name": "ETHEREUM", - "isDeprecated": false - }, - { - "name": "LINEA", - "isDeprecated": false - }, - { - "name": "OPTIMISM", - "isDeprecated": false - }, - { - "name": "SCROLL", - "isDeprecated": false - } - ] - }, { "kind": "ENUM", "name": "ENSProtocolVersion", @@ -2259,7 +2176,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "Resolve" + "name": "ForwardResolve" } }, "args": [ @@ -2880,7 +2797,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "Resolve" + "name": "ForwardResolve" } }, "args": [ @@ -3860,6 +3777,43 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "ForwardResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "SCALAR", "name": "Hex" @@ -4881,7 +4835,7 @@ const introspection = { "name": "chain", "type": { "kind": "ENUM", - "name": "ENSIP19Chain" + "name": "ChainName" } }, { @@ -4902,7 +4856,7 @@ const introspection = { "name": "chain", "type": { "kind": "ENUM", - "name": "ENSIP19Chain" + "name": "ChainName" }, "args": [], "isDeprecated": false @@ -4934,7 +4888,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "Resolve" + "name": "ForwardResolve" } }, "args": [], @@ -4955,7 +4909,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "ENUM", - "name": "ENSIP19Chain" + "name": "ChainName" } } } @@ -6135,43 +6089,6 @@ const introspection = { "kind": "SCALAR", "name": "RenewalId" }, - { - "kind": "OBJECT", - "name": "Resolve", - "fields": [ - { - "name": "acceleration", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "AccelerationStatus" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "records", - "type": { - "kind": "OBJECT", - "name": "ResolvedRecords" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "trace", - "type": { - "kind": "SCALAR", - "name": "JSON" - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "ResolvedAbiRecord", @@ -6991,6 +6908,89 @@ const introspection = { "kind": "SCALAR", "name": "ResolverRecordsId" }, + { + "kind": "OBJECT", + "name": "ReverseResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "primaryName", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "primaryNames", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + } + } + }, + "args": [ + { + "name": "where", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesWhereInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "JSON" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "SCALAR", "name": "String" diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 8b3d15fa8e..27d0a5a62d 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -38,7 +38,7 @@ type Account { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): AccountResolve! + ): ReverseResolve! """The Permissions on Resolvers granted to this Account.""" resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection @@ -148,35 +148,6 @@ type AccountRegistryPermissionsConnectionEdge { node: RegistryPermissionsUser! } -"""Nested account resolution container exposing primary name resolution.""" -type AccountResolve { - """ - Whether protocol acceleration was requested and attempted for this reverse resolution. - """ - acceleration: AccelerationStatus! - - """ - The ENSIP-19 primary name for this Account on a specific coin type or chain. - """ - primaryName( - """Select a coin type or chain to resolve a primary name for.""" - by: PrimaryNameByInput! - ): PrimaryNameRecord! - - """ - ENSIP-19 primary names for this Account on the requested coin types or chains. - """ - primaryNames( - """Select coin types or chains to resolve primary names for.""" - where: PrimaryNamesWhereInput! - ): [PrimaryNameRecord!]! - - """ - Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes. - """ - trace: JSON! -} - type AccountResolverPermissionsConnection { edges: [AccountResolverPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -282,6 +253,20 @@ type CanonicalName { """ChainId represents an enssdk#ChainId.""" scalar ChainId +""" +Primary-name chains supported by the Omnigraph API. Use `DEFAULT` for the default EVM chain. +@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. +""" +enum ChainName { + ARBITRUM_ONE + BASE + DEFAULT + ETHEREUM + LINEA + OPTIMISM + SCROLL +} + """CoinType represents an enssdk#CoinType.""" scalar CoinType @@ -329,7 +314,7 @@ interface Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): Resolve! + ): ForwardResolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -496,20 +481,6 @@ input DomainsWhereInput { version: ENSProtocolVersion } -""" -ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. -@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. -""" -enum ENSIP19Chain { - ARBITRUM_ONE - BASE - DEFAULT - ETHEREUM - LINEA - OPTIMISM - SCROLL -} - """An ENS protocol version.""" enum ENSProtocolVersion { ENSv1 @@ -561,7 +532,7 @@ type ENSv1Domain implements Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): Resolve! + ): ForwardResolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -682,7 +653,7 @@ type ENSv2Domain implements Domain { @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration """ accelerate: Boolean = true - ): Resolve! + ): ForwardResolve! """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -955,6 +926,26 @@ input EventsWhereInput { timestamp: EventsTimestampFilter } +""" +Nested domain resolution container exposing resolved data for the domain. +""" +type ForwardResolve { + """ + Whether protocol acceleration was requested and attempted for this resolution. + """ + acceleration: AccelerationStatus! + + """ + Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes. + """ + trace: JSON +} + """Hex represents viem#Hex.""" scalar Hex @@ -1186,8 +1177,8 @@ scalar PermissionsUserId Select a primary name lookup target. Exactly one of `coinType` or `chain` must be provided. """ input PrimaryNameByInput @oneOf { - """An ENSIP-19 supported chain to resolve the primary name for.""" - chain: ENSIP19Chain + """A `ChainName` to resolve the primary name for.""" + chain: ChainName """The ENSIP-9 coin type to resolve the primary name for.""" coinType: CoinType @@ -1196,9 +1187,9 @@ input PrimaryNameByInput @oneOf { """An ENSIP-19 primary name for an Account on a specific coin type.""" type PrimaryNameRecord { """ - The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`. + The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`. """ - chain: ENSIP19Chain + chain: ChainName """The canonical ENSIP-9 coin type for this primary name lookup.""" coinType: CoinType! @@ -1209,15 +1200,15 @@ type PrimaryNameRecord { name: CanonicalName """Forward resolve data for this primary name.""" - resolve: Resolve! + resolve: ForwardResolve! } """ Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. """ input PrimaryNamesWhereInput @oneOf { - """ENSIP-19 supported chains to resolve primary names for.""" - chains: [ENSIP19Chain!] + """`ChainName` values to resolve primary names for.""" + chains: [ChainName!] """Coin types to resolve primary names for.""" coinTypes: [CoinType!] @@ -1487,26 +1478,6 @@ type Renewal { """RenewalId represents an enssdk#RenewalId.""" scalar RenewalId -""" -Nested domain resolution container exposing resolved data for the domain. -""" -type Resolve { - """ - Whether protocol acceleration was requested and attempted for this resolution. - """ - acceleration: AccelerationStatus! - - """ - Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). - """ - records: ResolvedRecords - - """ - Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes. - """ - trace: JSON -} - """A resolved ABI record for an ENS name.""" type ResolvedAbiRecord { contentType: BigInt! @@ -1699,6 +1670,31 @@ type ResolverRecordsConnectionEdge { """ResolverRecordsId represents an enssdk#ResolverRecordsId.""" scalar ResolverRecordsId +"""Nested account resolution container exposing primary name resolution.""" +type ReverseResolve { + """ + Whether protocol acceleration was requested and attempted for this reverse resolution. + """ + acceleration: AccelerationStatus! + + """The primary name for this Account on a specific coin type or chain.""" + primaryName( + """Select a coin type or chain to resolve a primary name for.""" + by: PrimaryNameByInput! + ): PrimaryNameRecord! + + """Primary names for this Account on the requested coin types or chains.""" + primaryNames( + """Select coin types or chains to resolve primary names for.""" + where: PrimaryNamesWhereInput! + ): [PrimaryNameRecord!]! + + """ + Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes. + """ + trace: JSON! +} + """Filter for Domain.subdomains query.""" input SubdomainsWhereInput { """If set, filters the set of subdomains by name.""" From 514e12b7283b878bdc9e21eff97bcd6514ce03c4 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 30 May 2026 17:12:09 +0300 Subject: [PATCH 30/30] fix tests --- .../src/enssdk-example.integration.test.ts | 4 +--- .../src/omnigraph-graphql-example.integration.test.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/integration-test-env/src/enssdk-example.integration.test.ts b/packages/integration-test-env/src/enssdk-example.integration.test.ts index dc1456dc5c..0ba6c15b9e 100644 --- a/packages/integration-test-env/src/enssdk-example.integration.test.ts +++ b/packages/integration-test-env/src/enssdk-example.integration.test.ts @@ -13,9 +13,7 @@ const EXAMPLE_DIR = join( "enssdk-example", ); -// TODO: uncomment when v2-sepolia (and other hosted instances) expose materialized -// `canonical { name { interpreted } }` so examples can use the local orchestrator ENSApi. -describe.skip("enssdk-example", () => { +describe("enssdk-example", () => { it("smoke test: completes against the configured ENSNode with exit code 0", () => { const result = spawnSync("pnpm", ["start"], { cwd: EXAMPLE_DIR, diff --git a/packages/integration-test-env/src/omnigraph-graphql-example.integration.test.ts b/packages/integration-test-env/src/omnigraph-graphql-example.integration.test.ts index 0ce78335cd..212ba1dd64 100644 --- a/packages/integration-test-env/src/omnigraph-graphql-example.integration.test.ts +++ b/packages/integration-test-env/src/omnigraph-graphql-example.integration.test.ts @@ -13,9 +13,7 @@ const EXAMPLE_DIR = join( "omnigraph-graphql-example", ); -// TODO: uncomment when v2-sepolia (and other hosted instances) expose materialized -// `canonical { name { interpreted } }` so examples can use the local orchestrator ENSApi. -describe.skip("omnigraph-graphql-example", () => { +describe("omnigraph-graphql-example", () => { it("smoke test: completes against the configured ENSNode with exit code 0", () => { const result = spawnSync("pnpm", ["start"], { cwd: EXAMPLE_DIR,