diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md new file mode 100644 index 0000000000..8c6f14748d --- /dev/null +++ b/.changeset/omnigraph-resolution-api.md @@ -0,0 +1,9 @@ +--- +"ensapi": patch +--- + +Changes related to **Omnigraph**: + +- 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/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/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index 563e31c05e..8f9f984195 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -1,24 +1,45 @@ +import type { Duration } from "enssdk"; + import { hasOmnigraphApiConfigSupport, hasOmnigraphApiIndexingStatusSupport, } 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({ middlewares: [indexingStatusMiddleware] }); +const app = createApp({ + middlewares: [ + indexingStatusMiddleware, + makeIsRealtimeMiddleware("omnigraph-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE), + canAccelerateMiddleware, + ], +}); 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 @@ -27,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/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 dc433dccbd..ba1234f40a 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, CoinType } 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, resolveReverseByChainId } 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,36 @@ const getENSIP19SupportedChainIds = () => { ]); }; +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) => resolveReverse(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`. * @@ -46,17 +78,19 @@ const getENSIP19SupportedChainIds = () => { * @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 677d16188f..3b18b585d8 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 CoinType, + coinTypeReverseLabel, + type DefaultableChainId, + evmChainIdToCoinType, + reverseName, +} from "enssdk"; import { isAddress, isAddressEqual } from "viem"; import { type ResolverRecordsSelection, - type ReverseResolutionArgs, ReverseResolutionProtocolStep, type ReverseResolutionResult, TraceableENSProtocol, @@ -24,23 +30,23 @@ 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.accelerate Whether to attempt accelerated 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], + address: Address, + coinType: CoinType, + options: ReverseResolutionOptions, ): Promise { const { accelerate = true } = options; @@ -48,13 +54,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 +68,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 +178,18 @@ export async function resolveReverse( ), ); } + +/** + * 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: DefaultableChainId, + options: ReverseResolutionOptions, +): Promise { + 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 09ad2efd89..fe1b81b556 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -12,8 +12,10 @@ import type { CoinType, DomainId, Hex, + InterfaceId, InterpretedLabel, InterpretedName, + JsonValue, Node, NormalizedAddress, PermissionsId, @@ -28,7 +30,7 @@ import type { import { getNamedType } from "graphql"; import superjson from "superjson"; -import type { context } from "@/omnigraph-api/context"; +import type { Context } from "@/omnigraph-api/context"; const tracer = trace.getTracer("graphql"); const createSpan = createOpenTelemetryWrapper(tracer, { @@ -61,10 +63,12 @@ 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 }; 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 }; @@ -82,7 +86,7 @@ export type BuilderScalars = { }; export const builder = new SchemaBuilder<{ - Context: ReturnType; + 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 51b65489e6..3d30e6185b 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 di from "@/di"; +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) => { @@ -24,9 +28,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 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 a433bb46fb..0fdb08f2fd 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 di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; -import type { context as createContext } from "@/omnigraph-api/context"; +import type { Context } from "@/omnigraph-api/context"; import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor"; import { cursorFilter, @@ -102,7 +102,7 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa * @param args - Compound `where` filter, optional ordering, and relay connection args */ export function resolveFindDomains( - context: ReturnType, + context: Context, { where, order, 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..75ddd4b0f1 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -0,0 +1,123 @@ +import { coinNameToTypeMap } from "@ensdomains/address-encoder"; +import { + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLString, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { mockResolveContainerInfo } 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: { + coinType: { type: GraphQLInt }, + chain: { type: GraphQLString }, + }, +}); + +const PrimaryNamesWhereInputType = new GraphQLInputObjectType({ + name: "PrimaryNamesWhereInput", + fields: { + coinTypes: { type: new GraphQLList(GraphQLInt) }, + chains: { type: new GraphQLList(GraphQLString) }, + }, +}); + +const PrimaryNameRecordType = new GraphQLObjectType({ + name: "PrimaryNameRecord", + fields: { + name: { type: GraphQLString }, + }, +}); + +// Name must stay in sync with the real Pothos schema — see reverse-resolve.ts +const AccountResolveType = new GraphQLObjectType({ + name: "ReverseResolve", + fields: { + primaryName: { + type: PrimaryNameRecordType, + args: { + by: { type: PrimaryNameByInputType }, + }, + }, + primaryNames: { + type: new GraphQLList(PrimaryNameRecordType), + args: { + where: { type: PrimaryNamesWhereInputType }, + }, + }, + }, +}); + +function resolveInfoForAccountResolveSubselection(subselection: string): GraphQLResolveInfo { + return mockResolveContainerInfo("resolve", subselection, AccountResolveType); +} + +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: ETHEREUM })", () => { + const info = resolveInfoForAccountResolveSubselection( + 'primaryName(by: { chain: "ETHEREUM" }) { name }', + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([coinNameToTypeMap.eth]); + }); + + 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, 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_ONE"] }) { 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 new file mode 100644 index 0000000000..42cc09b5c4 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts @@ -0,0 +1,71 @@ +import type { CoinType } from "enssdk"; +import { + GraphQLError, + type GraphQLResolveInfo, + getArgumentValues, + getNamedType, + isObjectType, +} from "graphql"; + +import { + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, +} 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 + * 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 resolveReturnType = getNamedType(info.returnType); + if (!isObjectType(resolveReturnType)) { + throw new GraphQLError("Return type must be an object type."); + } + + // Use a Set to collect and deduplicate all requested coin types across all field nodes + const coinTypes = new Set(); + + // 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; + + // 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 PrimaryNamesWhereInputValue, + ); + // 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 PrimaryNameByInputValue)); + } + } + } + + // 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/chain-coin-type.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts new file mode 100644 index 0000000000..83f2e248cb --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -0,0 +1,46 @@ +import type { CoinName } from "@ensdomains/address-encoder"; +import { coinNameToTypeMap } from "@ensdomains/address-encoder"; +import type { CoinType } from "enssdk"; + +/** + * address-encoder coin names for primary-name chains, paired with their canonical + * GraphQL `ChainName` enum values. + */ +export const CHAIN_NAME_ENTRIES = [ + ["default", "DEFAULT"], + ["eth", "ETHEREUM"], + ["base", "BASE"], + ["op", "OPTIMISM"], + ["arb1", "ARBITRUM_ONE"], + ["linea", "LINEA"], + ["scr", "SCROLL"], +] as const satisfies readonly (readonly [CoinName, string])[]; + +export type ChainNameCoinName = (typeof CHAIN_NAME_ENTRIES)[number][0]; + +/** 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 [ChainNameValue, ...ChainNameValue[]]; + +/** 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 chainNameToCoinName = Object.fromEntries( + CHAIN_NAME_ENTRIES.map(([coinName, chain]) => [chain, coinName]), +) as Record; + +/** 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 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 new file mode 100644 index 0000000000..088ba708e4 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -0,0 +1,29 @@ +import type { CoinType } from "enssdk"; + +import { chainNameToCoinType } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { + PrimaryNameByInputValue, + PrimaryNamesWhereInputValue, +} from "@/omnigraph-api/schema/resolution"; + +/** + * 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 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."); +}; + +/** + * Normalizes `PrimaryNamesWhereInput` to an ordered coin-type list. + */ +export const normalizeAccountPrimaryNamesWhereInput = ( + where: PrimaryNamesWhereInputValue, +): CoinType[] => { + if (where.coinTypes != null) return where.coinTypes; + 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-profile-model.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts new file mode 100644 index 0000000000..b0c491ad05 --- /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: Partial, +): ResolvedRecordsModel => ({ + id: name, + ...response, +}); 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 new file mode 100644 index 0000000000..a33c8a110f --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection-config.ts @@ -0,0 +1,114 @@ +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; + recordsSelectionKey: RecordsSelectionSimpleKey; +}; + +export type RecordsSelectionParametricField = { + graphqlField: string; + argName: string; + recordsSelectionKey: RecordsSelectionParametricKey; + applyToRecordsSelection: ( + recordsSelection: 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", recordsSelectionKey: "name" }, + { graphqlField: "contenthash", recordsSelectionKey: "contenthash" }, + { graphqlField: "pubkey", recordsSelectionKey: "pubkey" }, + { graphqlField: "dnszonehash", recordsSelectionKey: "dnszonehash" }, + { graphqlField: "version", recordsSelectionKey: "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", + recordsSelectionKey: "texts", + applyToRecordsSelection: (recordsSelection, args) => { + const keys = args.keys as string[] | undefined; + if (keys && keys.length > 0) { + recordsSelection.texts = [...new Set([...(recordsSelection.texts ?? []), ...keys])]; + } + }, + }, + { + graphqlField: "addresses", + argName: "coinTypes", + recordsSelectionKey: "addresses", + applyToRecordsSelection: (recordsSelection, args) => { + const coinTypes = args.coinTypes as CoinType[] | undefined; + if (coinTypes && coinTypes.length > 0) { + recordsSelection.addresses = [ + ...new Set([...(recordsSelection.addresses ?? []), ...coinTypes]), + ]; + } + }, + }, + { + graphqlField: "abi", + argName: "contentTypeMask", + recordsSelectionKey: "abi", + applyToRecordsSelection: (recordsSelection, args) => { + const contentTypeMask = args.contentTypeMask as ContentType | undefined; + if (contentTypeMask !== undefined) { + recordsSelection.abi = (recordsSelection.abi ?? 0n) | contentTypeMask; + } + }, + }, + { + graphqlField: "interfaces", + argName: "ids", + recordsSelectionKey: "interfaces", + applyToRecordsSelection: (recordsSelection, args) => { + const ids = args.ids as InterfaceId[] | undefined; + if (ids && ids.length > 0) { + recordsSelection.interfaces = [ + ...new Set([...(recordsSelection.interfaces ?? []), ...ids]), + ]; + } + }, + }, +] 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/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts new file mode 100644 index 0000000000..552743d946 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -0,0 +1,237 @@ +import { + type GraphQLFieldConfigMap, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLScalarType, + GraphQLString, + Kind, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { + buildRecordsSelectionFromResolveContainerInfo, + buildRecordsSelectionFromResolveInfo, + EMPTY_RECORDS_SELECTION_MESSAGE, +} from "@/omnigraph-api/lib/resolution/records-selection"; +import { + RECORDS_SELECTION_PARAMETRIC_FIELDS, + RECORDS_SELECTION_SIMPLE_FIELDS, +} from "@/omnigraph-api/lib/resolution/records-selection-config"; +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 +// 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))); + +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(); + +// Name must stay in sync with the real Pothos schema — see forward-resolve.ts +const DomainResolveType = new GraphQLObjectType({ + name: "ForwardResolve", + fields: { + trace: { type: GraphQLString }, + records: { type: ResolvedRecordsType }, + }, +}); + +function resolveInfoForDomainResolveSubselection(subselection: string): GraphQLResolveInfo { + return mockResolveContainerInfo("resolve", subselection, DomainResolveType); +} + +function mockResolveInfo( + fieldNodes: ReturnType[], + variableValues: Record = {}, +): GraphQLResolveInfo { + return { + fieldNodes, + fragments: {}, + returnType: ResolvedRecordsType, + variableValues, + } as unknown as GraphQLResolveInfo; +} + +function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { + 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((s) => parseFieldNode("records", s))); +} + +describe("buildRecordsSelectionFromResolveInfo", () => { + it.each(RECORDS_SELECTION_SIMPLE_FIELDS)( + "selects $graphqlField as $recordsSelectionKey", + ({ graphqlField, recordsSelectionKey }) => { + const info = resolveInfoForRecordsSubselection(graphqlField); + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ [recordsSelectionKey]: 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("merges selections from multiple field nodes", () => { + const info = resolveInfoForMultipleRecordsFieldNodes( + 'texts(keys: ["description"])', + "addresses(coinTypes: [60])", + ); + + expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ + texts: ["description"], + addresses: [60], + }); + }); + + 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]) + 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"], + }); + }); + + 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 = resolveInfoForRecordsSubselection("unknownField"); + + expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow( + EMPTY_RECORDS_SELECTION_MESSAGE, + ); + }); +}); + +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("returns null when records is selected with an empty subselection", () => { + const info = resolveInfoForDomainResolveSubselection("records { __typename }"); + + 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 new file mode 100644 index 0000000000..343678c162 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -0,0 +1,184 @@ +import { + type FieldNode, + GraphQLError, + type GraphQLObjectType, + type GraphQLResolveInfo, + getArgumentValues, + getNamedType, + isObjectType, + Kind, + type SelectionSetNode, +} from "graphql"; + +import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { + getParametricRecordsSelectionField, + getSimpleRecordsSelectionField, +} from "@/omnigraph-api/lib/resolution/records-selection-config"; + +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[] { + const fields: FieldNode[] = []; + + 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)); + } + } + + return fields; +} + +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; +} + +/** + * 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 | 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) { + // 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 + const mergedGraphqlSelectionSet: SelectionSetNode = { + kind: Kind.SELECTION_SET, + selections: graphqlSelections, + }; + + 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); + } + + if (isSelectionEmpty(recordsSelection)) { + // 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; +} + +/** + * 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."); + } + + const selection = buildRecordsSelectionFromRecordsFieldNodes(info.fieldNodes, returnType, info); + if (!selection) { + throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE); + } + + return selection; +} + +/** + * 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 new file mode 100644 index 0000000000..f8e04c462d --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -0,0 +1,60 @@ +import type { Address, CoinType, InterpretedName } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + +import { resolvePrimaryNamesByCoinTypes } from "@/lib/resolution/multichain-primary-name-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; +import { + CHAIN_NAME_COIN_TYPES, + coinTypeToChainName, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/primary-name-record"; + +type PrimaryNameResolutionOptions = { + accelerate: boolean; + canAccelerate: boolean; +}; + +export type PrimaryNameRecordsResolution = { + trace: TracingTrace; + records: PrimaryNameRecordModel[]; +}; + +const toPrimaryNameRecord = ( + address: Address, + coinType: CoinType, + name: InterpretedName | null, +): PrimaryNameRecordModel => ({ + address, + coinType, + chain: coinTypeToChainName(coinType), + name, +}); + +/** 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(CHAIN_NAME_COIN_TYPES); + const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); + + if (resolvableCoinTypes.length === 0) { + return { + trace: [], + 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; + return toPrimaryNameRecord(address, coinType, name); + }); + + return { trace, records }; +} 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..269797721f --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/test-helpers.ts @@ -0,0 +1,43 @@ +import { + type FieldNode, + type GraphQLObjectType, + type GraphQLResolveInfo, + 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; +} + +/** + * 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 824a680a18..112ac3f20e 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/reverse-resolve"; import "./schema/connection"; import "./schema/domain"; import "./schema/domain-canonical"; @@ -13,6 +14,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/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index a914a09675..55e4efac97 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"; @@ -312,3 +318,370 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { expect(events.length).toBe(0); }); }); + +describe("Account.primaryName and Account.primaryNames", () => { + const BASE_COIN_TYPE = evmChainIdToCoinType(base.id); + + type CanonicalNameResult = { + interpreted: string; + beautified: string; + } | null; + + type PrimaryNameRecordResult = { + coinType: number; + chain: string | null; + name: CanonicalNameResult; + resolve?: { + records?: { addresses: Array<{ coinType: number; address: string | null }> } | null; + } | null; + }; + + const TEST_ETH_NAME: CanonicalNameResult = { + interpreted: "test.eth", + beautified: "test.eth", + }; + + type AccountPrimaryNameResult = { + account: { + resolve: { + primaryName: PrimaryNameRecordResult; + }; + }; + }; + + type AccountPrimaryNamesResult = { + account: { + resolve: { + primaryNames: PrimaryNameRecordResult[]; + }; + }; + }; + + const AccountPrimaryNameByCoinType = gql` + query AccountPrimaryNameByCoinType($address: Address!, $coinType: CoinType!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { coinType: $coinType }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNameByChain = gql` + query AccountPrimaryNameByChain($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { chain: ETHEREUM }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNameByDefaultChain = gql` + query AccountPrimaryNameByDefaultChain($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { chain: DEFAULT }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNamesByDefaultChain = gql` + query AccountPrimaryNamesByDefaultChain($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryNames(where: { chains: [DEFAULT] }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNamesByCoinTypes = gql` + query AccountPrimaryNamesByCoinTypes($address: Address!, $coinTypes: [CoinType!]!) { + account(by: { address: $address }) { + resolve { + primaryNames(where: { coinTypes: $coinTypes }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNamesByChains = gql` + query AccountPrimaryNamesByChains($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryNames(where: { chains: [ETHEREUM, BASE] }) { + coinType + chain + name { interpreted beautified } + } + } + } + } + `; + + const AccountPrimaryNameNonEnsip19 = gql` + query AccountPrimaryNameNonEnsip19($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { coinType: 0 }) { + coinType + chain + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { address } + } + } + } + } + } + } + `; + + const AccountPrimaryNameChainedRecords = gql` + query AccountPrimaryNameChainedRecords($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { coinType: 60 }) { + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { coinType address } + } + } + } + } + } + } + `; + + it("resolves primary name by coinType for owner on Ethereum", async () => { + await expect( + request(AccountPrimaryNameByCoinType, { + address: accounts.owner.address, + coinType: ETH_COIN_TYPE, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + }, + }, + }); + }); + + it("resolves the same primary name by chain as by coinType", async () => { + await expect( + request(AccountPrimaryNameByChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + }, + }, + }); + }); + + 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: { + resolve: { + primaryName: { + coinType: DEFAULT_EVM_COIN_TYPE, + chain: "DEFAULT", + name: null, + }, + }, + }, + }); + }); + + it("resolves primary names for DEFAULT", async () => { + await expect( + request(AccountPrimaryNamesByDefaultChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryNames: [ + { + coinType: DEFAULT_EVM_COIN_TYPE, + chain: "DEFAULT", + name: null, + }, + ], + }, + }, + }); + }); + + it("returns null for user without a primary name", async () => { + await expect( + request(AccountPrimaryNameByCoinType, { + address: accounts.user.address, + coinType: ETH_COIN_TYPE, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: null }, + }, + }, + }); + }); + + it("resolves primary names for requested coin types", async () => { + await expect( + request(AccountPrimaryNamesByCoinTypes, { + address: accounts.owner.address, + coinTypes: [ETH_COIN_TYPE, BASE_COIN_TYPE], + }), + ).resolves.toMatchObject({ + account: { + resolve: { + primaryNames: [ + { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + { coinType: BASE_COIN_TYPE, chain: "BASE", name: null }, + ], + }, + }, + }); + }); + + it("resolves primary names for requested chains", async () => { + await expect( + request(AccountPrimaryNamesByChains, { + address: accounts.owner.address, + }), + ).resolves.toMatchObject({ + account: { + resolve: { + primaryNames: [ + { coinType: ETH_COIN_TYPE, chain: "ETHEREUM", name: TEST_ETH_NAME }, + { coinType: BASE_COIN_TYPE, chain: "BASE", name: null }, + ], + }, + }, + }); + }); + + it("returns null name and chain for non-ENSIP-19 coin types", async () => { + await expect( + request(AccountPrimaryNameNonEnsip19, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { + coinType: 0, + chain: null, + name: null, + resolve: { + records: null, + }, + }, + }, + }, + }); + }); + + it("chains forward resolution through primaryName.records", async () => { + await expect( + request(AccountPrimaryNameChainedRecords, { + address: accounts.owner.address, + }), + ).resolves.toMatchObject({ + account: { + resolve: { + primaryName: { + name: TEST_ETH_NAME, + resolve: { + records: { + addresses: [{ coinType: ETH_COIN_TYPE, 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 }) { + resolve { + primaryNames(where: { chains: [] }) { coinType } + } + } + } + `, + { address: accounts.owner.address }, + ), + ).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 1783d89bb2..783d832b3f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -9,8 +9,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 { 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"; @@ -18,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[]) => { @@ -59,6 +68,49 @@ AccountRef.implement({ resolve: (parent) => parent.id, }), + ////////////////// + // Account.resolve + ////////////////// + resolve: t.field({ + description: "Resolve primary names for this Account.", + type: ReverseResolveRef, + nullable: false, + args: { + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), + }, + resolve: async ( + account, + { accelerate: accelerateArg }, + context, + info, + ): 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) { + return { + address: account.id, + coinTypes: [], + accelerate, + canAccelerate, + trace: [], + records: [], + }; + } + + const { trace, records } = await resolvePrimaryNameRecords(account.id, coinTypes, { + accelerate, + canAccelerate, + }); + + return { address: account.id, coinTypes, accelerate, canAccelerate, trace, records }; + }, + }), + //////////////////// // Account.domains //////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts index 2bd765cb0f..5d9ebfe0bb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts +++ b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts @@ -1,12 +1,11 @@ -import { beautifyInterpretedName } from "enssdk"; +import { beautifyInterpretedName, type InterpretedName } from "enssdk"; import { builder } from "@/omnigraph-api/builder"; -import type { Domain } from "@/omnigraph-api/schema/domain"; //////////////////////////////// // 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 +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: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(CanonicalName.interpreted): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return domain.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: (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), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/constants.ts b/apps/ensapi/src/omnigraph-api/schema/constants.ts index 606609b593..dd61d13434 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 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-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index bb93598698..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, + 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 ba58c57b1f..4e4a2ffd27 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,8 +1,12 @@ import { ADDR_REVERSE_NODE, asInterpretedLabel, + type CoinType, + type ContentType, type DomainId, + ETH_COIN_TYPE, ETH_NODE, + type Hex, type InterpretedLabel, type InterpretedName, labelhashInterpretedLabel, @@ -11,12 +15,15 @@ import { makeENSv2DomainId, makeENSv2RegistryId, makeStorageId, + type NormalizedAddress, } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; 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, @@ -484,3 +491,242 @@ describe("Domain.events filtering (EventsWhereInput)", () => { } }); }); + +describe("Domain.records", () => { + type DomainRecordsResult = { + domain: { + resolve: { + records: { + addresses: Array<{ coinType: CoinType; address: Hex | null }>; + texts: Array<{ key: string; value: string | null }>; + } | null; + }; + }; + }; + + type DomainAllRecordsResult = { + domain: { + resolve: { + records: { + reverseName: string | null; + contenthash: string | null; + pubkey: { x: string; y: string } | null; + dnszonehash: string | null; + version: string | null; + abi: { contentType: ContentType; data: string } | null; + interfaces: Array<{ interfaceId: string; implementer: 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; + }; + }; + }; + + const DomainRecords = gql` + query DomainRecords($name: InterpretedName!, $addresses: [CoinType!]!, $texts: [String!]!) { + domain(by: { name: $name }) { + resolve { + records { + addresses(coinTypes: $addresses) { coinType address } + texts(keys: $texts) { key value } + } + } + } + } + `; + + const DomainRecordsAll = gql` + query DomainRecordsAll( + $name: InterpretedName! + $addresses: [CoinType!]! + $texts: [String!]! + $contentTypeMask: BigInt! + $interfaceIds: [InterfaceId!]! + ) { + domain(by: { name: $name }) { + 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 } + } + } + } + } + `; + + 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: [ETH_COIN_TYPE], + texts: ["description"], + }), + ).resolves.toMatchObject({ + domain: { + resolve: { + records: { + texts: [{ key: "description", value: "example.eth" }], + addresses: [{ coinType: ETH_COIN_TYPE, address: accounts.owner.address }], + }, + }, + }, + }); + }); + + it("resolves every supported record type for test.eth", async () => { + await expect( + request(DomainRecordsAll, { + 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], + }), + ).resolves.toMatchObject({ + domain: { + 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: 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" }, + { 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" }, + ], + }, + }, + }, + }); + }); + + it("returns null for an unnormalized canonical name (e.g. with labelhash)", async () => { + // 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"; + 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: ContentType; data: string } | null; + abi2: { contentType: ContentType; 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", () => { + type DomainProfileResult = { + domain: { + resolve: { + profile: { + description: string | null; + avatar: { url: 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; + }; + }; + }; + + const DomainProfile = gql` + query DomainProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve { + profile { + 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: { + 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 1ed25f7a20..43a175e1e2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -1,12 +1,14 @@ 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, isNormalizedName } from "enssdk"; import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; import di from "@/di"; 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 { EMPTY_CONNECTION, @@ -20,11 +22,14 @@ 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 { 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 { @@ -35,6 +40,10 @@ 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"; @@ -167,6 +176,48 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.id, }), + ////////////////// + // Domain.resolve + ////////////////// + resolve: t.field({ + description: "Resolve protocol-level data for this Domain.", + type: ForwardResolveRef, + nullable: false, + args: { + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), + }, + resolve: async ( + domain, + { accelerate: accelerateArg }, + context, + info, + ): Promise => { + const accelerate = accelerateArg ?? true; + const { canAccelerate } = context; + const name = domain.canonicalName; + + 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), + }; + }, + }), + /////////////////////// // Domain.registration /////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts new file mode 100644 index 0000000000..37f97944f6 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -0,0 +1,58 @@ +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 { DomainProfileRef } from "@/omnigraph-api/schema/profile"; +import { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; +import { AccelerationStatusRef } from "@/omnigraph-api/schema/resolution"; + +export type ForwardResolveModel = { + accelerate: boolean; + canAccelerate: boolean; + trace: TracingTrace | null; + records: ResolvedRecordsModel | null; +}; + +export const ForwardResolveRef = builder.objectRef("ForwardResolve"); + +ForwardResolveRef.implement({ + description: "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. 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: "Whether protocol acceleration was requested and attempted 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 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/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..1148c42791 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -0,0 +1,83 @@ +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 { 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 { + type ForwardResolveModel, + ForwardResolveRef, +} from "@/omnigraph-api/schema/forward-resolve"; +import { ChainName } from "@/omnigraph-api/schema/resolution"; + +export type PrimaryNameRecordModel = { + address: Address; + coinType: CoinType; + chain: ChainNameValue | null; + name: InterpretedName | null; +}; + +/** GraphQL parent for `PrimaryNameRecord`, including `ReverseResolve` 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 chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`.", + type: ChainName, + 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: "Forward resolve data for this primary name.", + type: ForwardResolveRef, + 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..0a4a9ccecc --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/records.ts @@ -0,0 +1,256 @@ +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 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, + }), + }), +}); + +//////////////////////// +// 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 new file mode 100644 index 0000000000..1fb3ddf75b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts @@ -0,0 +1,173 @@ +import { ETH_COIN_TYPE } from "enssdk"; +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: ETH_COIN_TYPE, + 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, + }); + + // 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 new file mode 100644 index 0000000000..5722d7eb1a --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -0,0 +1,77 @@ +import { builder } from "@/omnigraph-api/builder"; +import { CHAIN_NAME_VALUES } from "@/omnigraph-api/lib/resolution/chain-coin-type"; + +////////////////////// +// AccelerationStatus +////////////////////// +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 protocol acceleration was requested by the caller.", + nullable: false, + }), + attempted: t.exposeBoolean("attempted", { + description: "Whether protocol acceleration was attempted at runtime.", + nullable: false, + }), + }), +}); + +////////////////// +// ChainName +////////////////// +export const ChainName = builder.enumType("ChainName", { + description: + "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, +}); + +/////////////////////// +// 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: ChainName, + description: "A `ChainName` to resolve the primary name for.", + }), + }), +}); + +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, + fields: (t) => ({ + coinTypes: t.field({ + type: ["CoinType"], + description: "Coin types to resolve primary names for.", + validate: { minLength: 1 }, + }), + chains: t.field({ + type: [ChainName], + description: "`ChainName` values to resolve primary names for.", + validate: { minLength: 1 }, + }), + }), +}); + +export type PrimaryNamesWhereInputValue = typeof PrimaryNamesWhereInput.$inferInput; diff --git a/apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts new file mode 100644 index 0000000000..091cd48d2b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/reverse-resolve.ts @@ -0,0 +1,94 @@ +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 { + type PrimaryNameRecordModel, + PrimaryNameRecordRef, +} from "@/omnigraph-api/schema/primary-name-record"; +import { + AccelerationStatusRef, + PrimaryNameByInput, + PrimaryNamesWhereInput, +} from "@/omnigraph-api/schema/resolution"; + +export type ReverseResolveModel = { + address: Address; + coinTypes: CoinType[]; + accelerate: boolean; + canAccelerate: boolean; + trace: TracingTrace; + records: PrimaryNameRecordModel[]; +}; + +export const ReverseResolveRef = builder.objectRef("ReverseResolve"); + +ReverseResolveRef.implement({ + description: "Nested account resolution container exposing primary name resolution.", + fields: (t) => ({ + trace: t.field({ + description: + "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: + "Whether protocol acceleration was requested and attempted for this reverse resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + primaryName: t.field({ + description: "The 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: "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/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index c5597e9ed8..2b41126c32 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -5,10 +5,13 @@ import { type CoinType, type DomainId, type Hex, + type InterfaceId, type InterpretedLabel, type InterpretedName, + isInterfaceId, isInterpretedLabel, isInterpretedName, + type JsonValue, type Name, type Node, type NormalizedAddress, @@ -38,6 +41,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, @@ -75,6 +84,26 @@ builder.scalarType("CoinType", { parseValue: (value) => makeCoinTypeSchema("CoinType").parse(value), }); +builder.scalarType("InterfaceId", { + description: "InterfaceId represents an ERC-165 interface id (4-byte hex selector).", + serialize: (value: InterfaceId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val.toLowerCase()) + .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 as InterfaceId) + .parse(value), +}); + builder.scalarType("Node", { description: "Node represents an enssdk#Node.", serialize: (value: Node) => value, diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts index 118a799ad0..4336c57b91 100644 --- a/apps/ensapi/src/omnigraph-api/yoga.ts +++ b/apps/ensapi/src/omnigraph-api/yoga.ts @@ -7,7 +7,11 @@ import { createYoga } from "graphql-yoga"; import { ZodError } from "zod/v4"; import { makeLogger } from "@/lib/logger"; -import { context } from "@/omnigraph-api/context"; +import { + type Context, + createOmnigraphContext, + type OmnigraphYogaServerContext, +} from "@/omnigraph-api/context"; import { schema } from "@/omnigraph-api/schema"; const logger = makeLogger("omnigraph"); @@ -33,10 +37,10 @@ const yogaLogger = { }, }; -export const yoga = createYoga({ +export const yoga = createYoga({ graphqlEndpoint: "*", schema, - context, + context: createOmnigraphContext, // CORS is handled by the Hono middleware in app.ts cors: false, graphiql: { 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/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 490a043df0..665f56c688 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -37,12 +37,36 @@ 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, + ForwardResolve: EMBEDDED_DATA, + ReverseResolve: 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, + ProfileBanner: EMBEDDED_DATA, + ProfileWebsite: EMBEDDED_DATA, + ProfileAddresses: EMBEDDED_DATA, + ProfileSocials: EMBEDDED_DATA, + ProfileSocialAccount: EMBEDDED_DATA, + ResolvedAbiRecord: EMBEDDED_DATA, + ResolvedAddressRecord: EMBEDDED_DATA, + ResolvedInterfaceRecord: EMBEDDED_DATA, + ResolvedPubkeyRecord: 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 c2af181618..ca8f741064 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -198,6 +198,36 @@ query DomainRegistration($name: InterpretedName!) { }, }, + //////////////////// + // Domain Records + //////////////////// + { + id: "domain-records", + query: ` +query DomainRecords( + $name: InterpretedName! +) { + domain(by: { name: $name }) { + canonical { name { interpreted } } + resolve { + 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, + }, + }, + }, + ////////////////////// // Domain Subdomains ////////////////////// @@ -301,6 +331,38 @@ query AccountDomains( }, }, + ///////////////////////// + // Account Primary Names + ///////////////////////// + { + id: "account-primary-names", + query: ` +query AccountPrimaryNames($address: Address!) { + account(by: { address: $address }) { + address + resolve { + primaryNames(where: { chains: [ETHEREUM, BASE] }) { + coinType + chain + name { interpreted beautified } + resolve { + records { + addresses(coinTypes: [60]) { + coinType + address + } + } + } + } + } + } +}`, + variables: { + default: { address: VITALIK_ADDRESS }, + [ENSNamespaceIds.EnsTestEnv]: { address: accounts.owner.address }, + [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ACCOUNT }, + }, + }, //////////////////// // Account Events //////////////////// 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 5ff06f0a70..b51975a7d0 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", @@ -238,6 +269,27 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ReverseResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolverPermissions", "type": { @@ -1080,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" @@ -1243,6 +1329,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ForwardResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -1536,6 +1643,67 @@ 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": "socials", + "type": { + "kind": "OBJECT", + "name": "ProfileSocials" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "website", + "type": { + "kind": "OBJECT", + "name": "ProfileWebsite" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "DomainRegistrationsConnection", @@ -2002,6 +2170,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ForwardResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -2602,6 +2791,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ForwardResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -3567,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" @@ -3579,6 +3826,10 @@ const introspection = { "kind": "SCALAR", "name": "Int" }, + { + "kind": "SCALAR", + "name": "InterfaceId" + }, { "kind": "SCALAR", "name": "InterpretedLabel" @@ -3587,6 +3838,10 @@ const introspection = { "kind": "SCALAR", "name": "InterpretedName" }, + { + "kind": "SCALAR", + "name": "JSON" + }, { "kind": "OBJECT", "name": "Label", @@ -4572,52 +4827,305 @@ const introspection = { "kind": "SCALAR", "name": "PermissionsUserId" }, + { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput", + "inputFields": [ + { + "name": "chain", + "type": { + "kind": "ENUM", + "name": "ChainName" + } + }, + { + "name": "coinType", + "type": { + "kind": "SCALAR", + "name": "CoinType" + } + } + ], + "isOneOf": true + }, { "kind": "OBJECT", - "name": "Query", + "name": "PrimaryNameRecord", "fields": [ { - "name": "account", + "name": "chain", "type": { - "kind": "OBJECT", - "name": "Account" + "kind": "ENUM", + "name": "ChainName" }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AccountByInput" - } - } - } - ], + "args": [], "isDeprecated": false }, { - "name": "domain", + "name": "coinType", "type": { - "kind": "INTERFACE", - "name": "Domain" - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DomainIdInput" - } - } + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" } - ], + }, + "args": [], "isDeprecated": false }, { - "name": "domains", + "name": "name", + "type": { + "kind": "OBJECT", + "name": "CanonicalName" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ForwardResolve" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "PrimaryNamesWhereInput", + "inputFields": [ + { + "name": "chains", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "ChainName" + } + } + } + }, + { + "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": "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": [] + }, + { + "kind": "OBJECT", + "name": "Query", + "fields": [ + { + "name": "account", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AccountByInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "domain", + "type": { + "kind": "INTERFACE", + "name": "Domain" + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DomainIdInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "domains", "type": { "kind": "OBJECT", "name": "QueryDomainsConnection" @@ -5583,39 +6091,374 @@ const introspection = { }, { "kind": "OBJECT", - "name": "Resolver", + "name": "ResolvedAbiRecord", "fields": [ { - "name": "bridged", + "name": "contentType", "type": { - "kind": "INTERFACE", - "name": "Registry" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } }, "args": [], "isDeprecated": false }, { - "name": "contract", + "name": "data", "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "AccountId" + "kind": "SCALAR", + "name": "Hex" } }, "args": [], "isDeprecated": false - }, + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedAddressRecord", + "fields": [ { - "name": "events", + "name": "address", "type": { - "kind": "OBJECT", - "name": "ResolverEventsConnection" + "kind": "SCALAR", + "name": "String" }, - "args": [ - { - "name": "after", - "type": { + "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": "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", + "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": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterpretedName" + } + }, + "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": "ResolvedRawTextRecord" + } + } + } + }, + "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": "Resolver", + "fields": [ + { + "name": "bridged", + "type": { + "kind": "INTERFACE", + "name": "Registry" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "contract", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "events", + "type": { + "kind": "OBJECT", + "name": "ResolverEventsConnection" + }, + "args": [ + { + "name": "after", + "type": { "kind": "SCALAR", "name": "String" } @@ -6065,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 c11ac881ed..27d0a5a62d 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 protocol acceleration was attempted at runtime.""" + attempted: Boolean! + + """Whether protocol acceleration 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.""" @@ -22,6 +31,15 @@ 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( + """ + 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 + ): ReverseResolve! + """The Permissions on Resolvers granted to this Account.""" resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection } @@ -235,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 @@ -275,6 +307,15 @@ interface Domain { """The Registry under which this Domain exists.""" registry: Registry! + """Resolve protocol-level data for this Domain.""" + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -349,6 +390,20 @@ 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 + socials: ProfileSocials + website: ProfileWebsite +} + type DomainRegistrationsConnection { edges: [DomainRegistrationsConnectionEdge!]! pageInfo: PageInfo! @@ -470,6 +525,15 @@ type ENSv1Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """Resolve protocol-level data for this Domain.""" + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -582,6 +646,15 @@ type ENSv2Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """Resolve protocol-level data for this Domain.""" + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -853,15 +926,41 @@ 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 +"""InterfaceId represents an ERC-165 interface id (4-byte hex selector).""" +scalar InterfaceId + """InterpretedLabel represents an enssdk#InterpretedLabel.""" 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. """ @@ -1074,6 +1173,108 @@ type PermissionsUserEventsConnectionEdge { """PermissionsUserId represents an enssdk#PermissionsUserId.""" scalar PermissionsUserId +""" +Select a primary name lookup target. Exactly one of `coinType` or `chain` must be provided. +""" +input PrimaryNameByInput @oneOf { + """A `ChainName` to resolve the primary name for.""" + chain: ChainName + + """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 chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`. + """ + chain: ChainName + + """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: CanonicalName + + """Forward resolve data for this primary name.""" + resolve: ForwardResolve! +} + +""" +Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. +""" +input PrimaryNamesWhereInput @oneOf { + """`ChainName` values to resolve primary names for.""" + chains: [ChainName!] + + """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: 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 { """Identify an Account by ID or Address.""" account(by: AccountByInput!): Account @@ -1277,6 +1478,101 @@ type Renewal { """RenewalId represents an enssdk#RenewalId.""" scalar RenewalId +"""A resolved ABI record for an ENS name.""" +type ResolvedAbiRecord { + contentType: BigInt! + data: Hex! +} + +"""A resolved address record for an ENS name.""" +type ResolvedAddressRecord { + """ + 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.""" + coinType: CoinType! +} + +"""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! +} + +""" +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 { + """ + 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 + + """ + Stable cache key for these records: the InterpretedName used to resolve them. + """ + id: InterpretedName! + + """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. 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 + + """Resolved text records for the requested keys.""" + texts( + """Text record keys to resolve (e.g. `avatar`, `description`).""" + keys: [String!]! + ): [ResolvedRawTextRecord!]! + + """The IVersionableResolver version, or null if not set or unavailable.""" + version: BigInt +} + """A Resolver represents a Resolver contract on-chain.""" type Resolver { """ @@ -1374,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.""" diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index 3e09a77fa3..0788574005 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -7,8 +7,10 @@ import type { CoinType, DomainId, Hex, + InterfaceId, InterpretedLabel, InterpretedName, + JsonValue, Node, NormalizedAddress, PermissionsId, @@ -38,10 +40,12 @@ 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; InterpretedLabel: InterpretedLabel; BeautifiedName: BeautifiedName; 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c668a9288..30aa7e18d5 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)