diff --git a/.changeset/omnigraph-resolution-api.md b/.changeset/omnigraph-resolution-api.md new file mode 100644 index 0000000000..491fc42894 --- /dev/null +++ b/.changeset/omnigraph-resolution-api.md @@ -0,0 +1,10 @@ +--- +"ensapi": patch +--- + +Changes related to **Omnigraph**: + +- add `Domain.records` with raw records resolution (`ResolvedRawTextRecord` for text record values) +- add `Account.primaryName(by: PrimaryNameByInput!)` and `Account.primaryNames(where: AccountPrimaryNamesWhereInput!)`. Primary name lookups accept `coinType` or `chain` (singular) and `coinTypes` or `chains` (plural, `@oneOf`); `ENSIP19Chain` includes `DEFAULT`; `PrimaryNameRecord.name` is a `CanonicalName` with `interpreted` and `beautified` +- add `UID` cache keys on `ResolvedRecords` (keyed by resolution `InterpretedName`) for graphcache normalization across queries +- add types-only `Domain.profile` and shared `DomainProfile` preview types (`ProfileAvatar`, `ProfileBanner`, `ProfileWebsite`, `ProfileAddresses`, `ProfileSocials`, etc.). Profile resolution is not wired yet; subfields return null 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/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index dc433dccbd..9a961c291a 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -1,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, resolveReverseByCoinType } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); -const getENSIP19SupportedChainIds = () => { +export const getENSIP19SupportedChainIds = () => { return uniq([ // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, // regardless of the current namespace @@ -34,6 +36,39 @@ 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) => resolveReverseByCoinType(address, coinType, options)), + ), + ); + + return coinTypes.reduce((memo, coinType, i) => { + // biome-ignore lint/style/noNonNullAssertion: names[i] guaranteed to be defined + memo[coinType] = names[i]!; + return memo; + }, {} as MultichainPrimaryNameByCoinTypeResolutionResult); +} + /** * Implements batch resolution of an address' Primary Name across the provided `chainIds`. * diff --git a/apps/ensapi/src/lib/resolution/reverse-resolution.ts b/apps/ensapi/src/lib/resolution/reverse-resolution.ts index 677d16188f..cf7468dfa0 100644 --- a/apps/ensapi/src/lib/resolution/reverse-resolution.ts +++ b/apps/ensapi/src/lib/resolution/reverse-resolution.ts @@ -1,10 +1,16 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; -import { coinTypeReverseLabel, evmChainIdToCoinType, reverseName } from "enssdk"; +import { + type Address, + type ChainId, + type CoinType, + coinTypeReverseLabel, + evmChainIdToCoinType, + reverseName, +} from "enssdk"; import { isAddress, isAddressEqual } from "viem"; import { type ResolverRecordsSelection, - type ReverseResolutionArgs, ReverseResolutionProtocolStep, type ReverseResolutionResult, TraceableENSProtocol, @@ -24,23 +30,24 @@ export const REVERSE_RESOLUTION_SELECTION = { const tracer = trace.getTracer("reverse-resolution"); +type ReverseResolutionOptions = Parameters[2]; + /** - * Implements ENS Reverse Resolution, including support for ENSIP-19 L2 Primary Names. + * Implements ENS Reverse Resolution for a specific coin type, including ENSIP-19 L2 Primary Names. * * @see https://docs.ens.domains/ensip/19/#algorithm * - * The DEFAULT_EVM_CHAIN_ID (0) is a valid chainId in this context. * * @param address the adddress whose Primary Name to resolve - * @param chainId the chainId within which to resolve the address' Primary Name + * @param coinType the coinType within which to resolve the address' Primary Name * @param options Optional settings * @param options.accelerate Whether to accelerate resolution (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) */ -export async function resolveReverse( - address: ReverseResolutionArgs["address"], - chainId: ReverseResolutionArgs["chainId"], - options: Parameters[2], +export async function resolveReverseByCoinType( + address: Address, + coinType: CoinType, + options: ReverseResolutionOptions, ): Promise { const { accelerate = true } = options; @@ -48,13 +55,13 @@ export async function resolveReverse( return withProtocolStep( TraceableENSProtocol.ReverseResolution, ReverseResolutionProtocolStep.Operation, - { address, chainId, accelerate }, + { address, coinType, accelerate }, (protocolTracingSpan) => // trace for internal metrics withActiveSpanAsync( tracer, - `resolveReverse(${address}, chainId: ${chainId})`, - { address, chainId, accelerate }, + `resolveReverseByCoinType(${address}, coinType: ${coinType})`, + { address, coinType, accelerate }, async (span) => { ///////////////////////////////////////////////////////// // Reverse Resolution @@ -62,7 +69,6 @@ export async function resolveReverse( ///////////////////////////////////////////////////////// // Steps 1-3 — Resolve coinType-specific name record - const coinType = evmChainIdToCoinType(chainId); const _reverseName = reverseName(address, coinType); const { name } = await withProtocolStep( TraceableENSProtocol.ReverseResolution, @@ -173,3 +179,24 @@ export async function resolveReverse( ), ); } + +/** + * Implements ENS Reverse Resolution, including support for ENSIP-19 L2 Primary Names. + * + * @see https://docs.ens.domains/ensip/19/#algorithm + * + * The DEFAULT_EVM_CHAIN_ID (0) is a valid chainId in this context. + * + * @param address the adddress whose Primary Name to resolve + * @param chainId the chainId within which to resolve the address' Primary Name + * @param options Optional settings + * @param options.accelerate Whether to accelerate resolution (default: true) + * @param options.canAccelerate Whether acceleration is currently possible (default: false) + */ +export async function resolveReverse( + address: Address, + chainId: ChainId, + options: ReverseResolutionOptions, +): Promise { + return resolveReverseByCoinType(address, evmChainIdToCoinType(chainId), options); +} diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 09ad2efd89..f3f22ac75f 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, @@ -24,11 +26,12 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + UID, } from "enssdk"; 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,12 +64,15 @@ 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 }; + UID: { Input: UID; Output: UID }; InterpretedLabel: { Input: InterpretedLabel; Output: InterpretedLabel }; BeautifiedName: { Input: BeautifiedName; Output: BeautifiedName }; BeautifiedLabel: { Input: BeautifiedLabel; Output: BeautifiedLabel }; @@ -82,7 +88,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..037a6030a9 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.test.ts @@ -0,0 +1,109 @@ +import { + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLString, + parse, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { buildAccountPrimaryNamesSelection } from "./account-primary-names-selection"; + +const PrimaryNameByInputType = new GraphQLInputObjectType({ + name: "PrimaryNameByInput", + fields: { + coinType: { type: GraphQLInt }, + chain: { type: GraphQLString }, + }, +}); + +const AccountPrimaryNamesWhereInputType = new GraphQLInputObjectType({ + name: "AccountPrimaryNamesWhereInput", + fields: { + coinTypes: { type: new GraphQLList(GraphQLInt) }, + chains: { type: new GraphQLList(GraphQLString) }, + }, +}); + +const PrimaryNameRecordType = new GraphQLObjectType({ + name: "PrimaryNameRecord", + fields: { + name: { type: GraphQLString }, + }, +}); + +const AccountResolveType = new GraphQLObjectType({ + name: "AccountResolve", + fields: { + primaryName: { + type: PrimaryNameRecordType, + args: { + by: { type: PrimaryNameByInputType }, + }, + }, + primaryNames: { + type: new GraphQLList(PrimaryNameRecordType), + args: { + where: { type: AccountPrimaryNamesWhereInputType }, + }, + }, + }, +}); + +function parseResolveFieldNode(subselection: string) { + const document = parse(`{ resolve { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const resolveField = operation.selectionSet.selections[0]; + if (resolveField.kind !== "Field") throw new Error("expected field"); + + return resolveField; +} + +function resolveInfoForAccountResolveSubselection(subselection: string): GraphQLResolveInfo { + return { + fieldNodes: [parseResolveFieldNode(subselection)], + fragments: {}, + returnType: AccountResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +describe("buildAccountPrimaryNamesSelection", () => { + it("returns null when neither primaryName nor primaryNames is selected", () => { + const info = resolveInfoForAccountResolveSubselection("trace acceleration { requested }"); + expect(buildAccountPrimaryNamesSelection(info)).toBeNull(); + }); + + it("extracts coin type from primaryName(by: { coinType: 60 })", () => { + const info = resolveInfoForAccountResolveSubselection( + "primaryName(by: { coinType: 60 }) { name }", + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + }); + + it("extracts coin types from primaryNames(where: { coinTypes: [60, 0] })", () => { + const info = resolveInfoForAccountResolveSubselection( + "primaryNames(where: { coinTypes: [60, 0] }) { name }", + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60, 0]); + }); + + it("extracts coin type from primaryName(by: { chain: ETH })", () => { + const info = resolveInfoForAccountResolveSubselection( + 'primaryName(by: { chain: "ETH" }) { name }', + ); + expect(buildAccountPrimaryNamesSelection(info)).toEqual([60]); + }); + + it("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]); + }); +}); 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..a3c520d8b6 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/account-primary-names-selection.ts @@ -0,0 +1,69 @@ +import type { CoinType } from "enssdk"; +import { + GraphQLError, + type GraphQLResolveInfo, + getArgumentValues, + getNamedType, + isObjectType, +} from "graphql"; + +import { + type AccountPrimaryNamesWhereInput, + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, + type PrimaryNameByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { collectNamedSubFieldNodes } from "@/omnigraph-api/lib/resolution/records-selection"; + +/** + * Derives primary-name coin types from `Account.resolve { primaryName | primaryNames }`, or null + * when neither field is selected. + * + * 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 AccountPrimaryNamesWhereInput, + ); + // Add all requested coin types from this 'primaryNames' call to our set + for (const coinType of normalized) coinTypes.add(coinType); + } + } + + // 2. Process all 'primaryName(by: { ... })' field selections + const primaryNameFieldNodes = collectNamedSubFieldNodes(selectionSet, "primaryName", info); + const primaryNameFieldDef = resolveReturnType.getFields().primaryName; + if (primaryNameFieldDef) { + for (const node of primaryNameFieldNodes) { + // Extract arguments from this specific field node + const args = getArgumentValues(primaryNameFieldDef, node, info.variableValues); + // Add the single requested coin type from this 'primaryName' call to our set + coinTypes.add(normalizePrimaryNameByInput(args.by as PrimaryNameByInput)); + } + } + } + + // 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..1b5f981fa9 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/chain-coin-type.ts @@ -0,0 +1,45 @@ +import type { CoinName } from "@ensdomains/address-encoder"; +import { coinNameToTypeMap } from "@ensdomains/address-encoder"; +import type { CoinType } from "enssdk"; + +/** + * address-encoder coin names for ENSIP-19 primary-name chains. + */ +export const ENSIP19_COIN_NAMES = [ + "default", + "eth", + "base", + "op", + "arb1", + "linea", + "scr", +] as const satisfies readonly CoinName[]; + +/** Canonical ENSIP-9 coin types for ENSIP-19 primary-name chains. */ +export const ENSIP19_COIN_TYPES = ENSIP19_COIN_NAMES.map( + (name) => coinNameToTypeMap[name] as CoinType, +); + +export type ENSIP19ChainValue = Uppercase<(typeof ENSIP19_COIN_NAMES)[number]>; + +export const ENSIP19_CHAIN_VALUES = ENSIP19_COIN_NAMES.map((coinName) => + coinName.toUpperCase(), +) as unknown as readonly [ENSIP19ChainValue, ...ENSIP19ChainValue[]]; + +const ensip19ChainToCoinName = Object.fromEntries( + ENSIP19_CHAIN_VALUES.map((chain) => [ + chain, + chain.toLowerCase() as (typeof ENSIP19_COIN_NAMES)[number], + ]), +) as Record; + +/** Maps an `ENSIP19Chain` enum value to its canonical ENSIP-9 coin type. */ +export const ensip19ChainToCoinType = (chain: ENSIP19ChainValue): CoinType => + coinNameToTypeMap[ensip19ChainToCoinName[chain]] as CoinType; + +/** Maps a coin type to an `ENSIP19Chain` enum value, or null when not ENSIP-19 supported. */ +export const coinTypeToEnsip19Chain = (coinType: CoinType): ENSIP19ChainValue | null => { + const coinName = ENSIP19_COIN_NAMES.find((name) => coinNameToTypeMap[name] === coinType); + if (!coinName) return null; + return coinName.toUpperCase() as ENSIP19ChainValue; +}; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts new file mode 100644 index 0000000000..a274c2b52c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/primary-name-input.ts @@ -0,0 +1,32 @@ +import type { CoinType } from "enssdk"; + +import { + type ENSIP19ChainValue, + ensip19ChainToCoinType, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; + +export type PrimaryNameByInput = { + coinType?: CoinType | null; + chain?: ENSIP19ChainValue | null; +}; + +export type AccountPrimaryNamesWhereInput = { + coinTypes?: CoinType[] | null; + chains?: ENSIP19ChainValue[] | null; +}; + +/** Normalizes a singular `PrimaryNameByInput` to a coin type. */ +export const normalizePrimaryNameByInput = (by: PrimaryNameByInput): CoinType => { + if (by.coinType != null) return by.coinType; + if (by.chain != null) return ensip19ChainToCoinType(by.chain); + throw new Error("PrimaryNameByInput must specify exactly one of coinType or chain."); +}; + +/** Normalizes `AccountPrimaryNamesWhereInput` to an ordered coin-type list. */ +export const normalizeAccountPrimaryNamesWhereInput = ( + where: AccountPrimaryNamesWhereInput, +): CoinType[] => { + if (where.coinTypes != null) return where.coinTypes; + if (where.chains != null) return where.chains.map(ensip19ChainToCoinType); + throw new Error("AccountPrimaryNamesWhereInput must specify exactly one of coinTypes or chains."); +}; 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..303c28757b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts @@ -0,0 +1,16 @@ +import type { InterpretedName, UID } from "enssdk"; + +import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; + +/** Cache key and resolution identity for {@link ResolvedRecordsRef}. */ +export type ResolvedRecordsModel = Partial & { + id: UID; +}; + +export const toResolvedRecordsModel = ( + name: InterpretedName, + response: Partial, +): ResolvedRecordsModel => ({ + id: name.toString() satisfies UID, + ...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..81c6ef22fc --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -0,0 +1,254 @@ +import { + type GraphQLFieldConfigMap, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLScalarType, + GraphQLString, + Kind, + parse, +} 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"; + +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(); + +const DomainResolveType = new GraphQLObjectType({ + name: "DomainResolve", + fields: { + trace: { type: GraphQLString }, + records: { type: ResolvedRecordsType }, + }, +}); + +function parseResolveFieldNode(subselection: string) { + const document = parse(`{ resolve { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const resolveField = operation.selectionSet.selections[0]; + if (resolveField.kind !== "Field") throw new Error("expected field"); + + return resolveField; +} + +function resolveInfoForDomainResolveSubselection(subselection: string): GraphQLResolveInfo { + return { + fieldNodes: [parseResolveFieldNode(subselection)], + fragments: {}, + returnType: DomainResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +function parseRecordsFieldNode(subselection: string) { + const document = parse(`{ records { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation.kind !== "OperationDefinition") throw new Error("expected operation"); + + const recordsField = operation.selectionSet.selections[0]; + if (recordsField.kind !== "Field") throw new Error("expected field"); + + return recordsField; +} + +function mockResolveInfo( + fieldNodes: ReturnType[], + variableValues: Record = {}, +): GraphQLResolveInfo { + return { + fieldNodes, + fragments: {}, + returnType: ResolvedRecordsType, + variableValues, + } as unknown as GraphQLResolveInfo; +} + +function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo { + return mockResolveInfo([parseRecordsFieldNode(subselection)]); +} + +/** Simulates GraphQL passing multiple AST field nodes for the same `records` resolver. */ +function resolveInfoForMultipleRecordsFieldNodes(...subselections: string[]): GraphQLResolveInfo { + return mockResolveInfo(subselections.map(parseRecordsFieldNode)); +} + +describe("buildRecordsSelectionFromResolveInfo", () => { + it.each(RECORDS_SELECTION_SIMPLE_FIELDS)( + "selects $graphqlField as $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..0078668650 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/resolve-primary-name-records.ts @@ -0,0 +1,59 @@ +import type { Address, CoinType, InterpretedName } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + +import { + type MultichainPrimaryNameByCoinTypeResolutionResult, + resolvePrimaryNamesByCoinTypes, +} from "@/lib/resolution/multichain-primary-name-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; +import { + coinTypeToEnsip19Chain, + ENSIP19_COIN_TYPES, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import type { PrimaryNameRecordModel } from "@/omnigraph-api/schema/resolution"; + +type PrimaryNameResolutionOptions = { + accelerate: boolean; + canAccelerate: boolean; +}; + +export type PrimaryNameRecordsResolution = { + trace: TracingTrace | null; + records: PrimaryNameRecordModel[]; +}; + +const toPrimaryNameRecord = ( + address: Address, + coinType: CoinType, + name: InterpretedName | null, +): PrimaryNameRecordModel => ({ + address, + coinType, + chain: coinTypeToEnsip19Chain(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(ENSIP19_COIN_TYPES); + const resolvableCoinTypes = coinTypes.filter((coinType) => supportedCoinTypes.has(coinType)); + + const { trace, result: resolvedByCoinType } = + resolvableCoinTypes.length > 0 + ? await runWithTrace(() => + resolvePrimaryNamesByCoinTypes(address, resolvableCoinTypes, options), + ) + : { trace: null, result: {} as MultichainPrimaryNameByCoinTypeResolutionResult }; + + const records = coinTypes.map((coinType) => { + const name = (resolvedByCoinType[coinType] ?? null) as InterpretedName | null; + return toPrimaryNameRecord(address, coinType, name); + }); + + return { trace, records }; +} diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 824a680a18..0ded718140 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -13,6 +13,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..b961dec656 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -312,3 +312,345 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { expect(events.length).toBe(0); }); }); + +describe("Account.primaryName and Account.primaryNames", () => { + 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: ETH }) { + 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: [ETH, 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: 60, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { coinType: 60, chain: "ETH", 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: 60, chain: "ETH", 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: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, + }, + }, + }); + }); + + it("resolves primary names for DEFAULT", async () => { + await expect( + request(AccountPrimaryNamesByDefaultChain, { + address: accounts.owner.address, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryNames: [ + { + coinType: 2_147_483_648, + chain: "DEFAULT", + name: null, + }, + ], + }, + }, + }); + }); + + it("returns null for user without a primary name", async () => { + await expect( + request(AccountPrimaryNameByCoinType, { + address: accounts.user.address, + coinType: 60, + }), + ).resolves.toEqual({ + account: { + resolve: { + primaryName: { coinType: 60, chain: "ETH", name: null }, + }, + }, + }); + }); + + it("resolves primary names for requested coin types", async () => { + await expect( + request(AccountPrimaryNamesByCoinTypes, { + address: accounts.owner.address, + coinTypes: [60, 2147492101], + }), + ).resolves.toMatchObject({ + account: { + resolve: { + primaryNames: [ + { coinType: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 2147492101, 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: 60, chain: "ETH", name: TEST_ETH_NAME }, + { coinType: 2147492101, 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: 60, address: accounts.owner.address }], + }, + }, + }, + }, + }, + }); + }); + + it("rejects empty coinTypes at GraphQL validation", async () => { + await expect( + request(AccountPrimaryNamesByCoinTypes, { + address: accounts.owner.address, + coinTypes: [], + }), + ).rejects.toThrow(); + }); + + it("rejects empty chains at GraphQL validation", async () => { + await expect( + request( + gql` + query AccountPrimaryNamesEmptyChains($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryNames(where: { chains: [] }) { coinType } + } + } + } + `, + { address: accounts.owner.address }, + ), + ).rejects.toThrow(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 1783d89bb2..71e3aced5c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -1,6 +1,8 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, count, eq, getTableColumns } from "drizzle-orm"; -import type { Address } from "enssdk"; +import type { Address, JsonValue } from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; @@ -9,14 +11,30 @@ 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 { + normalizeAccountPrimaryNamesWhereInput, + normalizePrimaryNameByInput, +} from "@/omnigraph-api/lib/resolution/primary-name-input"; +import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; -import { 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"; import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistryPermissionsUserRef } from "@/omnigraph-api/schema/registry-permissions-user"; +import { + AccelerationStatusRef, + AccountPrimaryNamesWhereInput, + PrimaryNameByInput, + type PrimaryNameRecordModel, + PrimaryNameRecordRef, +} from "@/omnigraph-api/schema/resolution"; import { ResolverPermissionsUserRef } from "@/omnigraph-api/schema/resolver-permissions-user"; export const AccountRef = builder.loadableObjectRef("Account", { @@ -32,6 +50,17 @@ export const AccountRef = builder.loadableObjectRef("Account", { }); export type Account = Exclude; +type AccountPrimaryNamesResult = { + trace: TracingTrace | null; + records: PrimaryNameRecordModel[]; +}; +type AccountResolveModel = { + account: Account; + accelerate: boolean; + canAccelerate: boolean; + primaryNamesResolution: Promise | null; +}; +const AccountResolveRef = builder.objectRef("AccountResolve"); /////////// // Account @@ -59,6 +88,30 @@ AccountRef.implement({ resolve: (parent) => parent.id, }), + ////////////////// + // Account.resolve + ////////////////// + resolve: t.field({ + description: "Resolve primary names for this Account with protocol acceleration controls.", + type: AccountResolveRef, + nullable: false, + args: { + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), + }, + resolve: (account, { accelerate: accelerateArg }, context, info) => { + const accelerate = accelerateArg ?? true; + const { canAccelerate } = context; + const coinTypes = buildAccountPrimaryNamesSelection(info); + + const primaryNamesResolution = + coinTypes !== null + ? resolvePrimaryNameRecords(account.id, coinTypes, { accelerate, canAccelerate }) + : null; + + return { account, accelerate, canAccelerate, primaryNamesResolution }; + }, + }), + //////////////////// // Account.domains //////////////////// @@ -214,6 +267,85 @@ AccountRef.implement({ }), }); +AccountResolveRef.implement({ + description: + "Nested account resolution container exposing primary-name resolution with shared acceleration settings.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ primaryNamesResolution }) => { + if (!primaryNamesResolution) return null; + const { trace } = await primaryNamesResolution; + return trace as unknown as JsonValue | null; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this Account resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + primaryName: t.field({ + description: "The ENSIP-19 primary name for this Account on a specific coin type or chain.", + type: PrimaryNameRecordRef, + nullable: false, + args: { + by: t.arg({ + type: PrimaryNameByInput, + required: true, + description: "Select a coin type or chain to resolve a primary name for.", + }), + }, + resolve: async ({ primaryNamesResolution, accelerate }, { by }) => { + if (!primaryNamesResolution) { + throw new Error("primaryName requires a primary-name resolution to be started."); + } + const coinType = normalizePrimaryNameByInput(by); + const { records } = await primaryNamesResolution; + const record = records.find((r) => r.coinType === coinType); + if (!record) { + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); + } + return { ...record, accelerate }; + }, + }), + primaryNames: t.field({ + description: "ENSIP-19 primary names for this Account on the requested coin types or chains.", + type: [PrimaryNameRecordRef], + nullable: false, + args: { + where: t.arg({ + type: AccountPrimaryNamesWhereInput, + required: true, + description: "Select coin types or chains to resolve primary names for.", + }), + }, + resolve: async ({ primaryNamesResolution, accelerate }, { where }) => { + if (!primaryNamesResolution) { + throw new Error("primaryNames requires a primary-name resolution to be started."); + } + const coinTypes = normalizeAccountPrimaryNamesWhereInput(where); + const { records } = await primaryNamesResolution; + + // return records in the order of requested coinTypes + return coinTypes.map((coinType) => { + const record = records.find((r) => r.coinType === coinType); + if (!record) { + throw new Error(`Missing primary name record for requested coin type: ${coinType}`); + } + return { ...record, accelerate }; + }); + }, + }), + }), +}); + ////////// // Inputs ////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts index 2bd765cb0f..bc9420e61c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts +++ b/apps/ensapi/src/omnigraph-api/schema/canonical-name.ts @@ -1,12 +1,16 @@ -import { beautifyInterpretedName } from "enssdk"; +import { beautifyInterpretedName, type InterpretedName } from "enssdk"; import { builder } from "@/omnigraph-api/builder"; -import type { Domain } from "@/omnigraph-api/schema/domain"; + +/** Parent object for {@link CanonicalNameRef} field resolvers. */ +export type CanonicalNameParent = { + canonicalName: InterpretedName; +}; //////////////////////////////// // CanonicalName //////////////////////////////// -export const CanonicalNameRef = builder.objectRef("CanonicalName"); +export const CanonicalNameRef = builder.objectRef("CanonicalName"); CanonicalNameRef.implement({ description: "A Canonical Name, exposed in each representation we support.", @@ -16,30 +20,14 @@ CanonicalNameRef.implement({ "The Canonical Name as an InterpretedName: each label is either a normalized literal Label or an Encoded LabelHash.", type: "InterpretedName", nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(CanonicalName.interpreted): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return domain.canonicalName; - }, + resolve: (parent) => parent.canonicalName, }), beautified: t.field({ description: "The Canonical Name as a BeautifiedName: the InterpretedName with its normalized labels beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. Encoded LabelHash labels are preserved verbatim. Display-only; use `interpreted` for navigation targets and lookup keys.", type: "BeautifiedName", nullable: false, - resolve: (domain) => { - if (!domain.canonicalName) { - throw new Error( - `Invariant(CanonicalName.beautified): canonical Domain '${domain.id}' is missing canonicalName.`, - ); - } - - return beautifyInterpretedName(domain.canonicalName); - }, + resolve: (parent) => beautifyInterpretedName(parent.canonicalName), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/constants.ts b/apps/ensapi/src/omnigraph-api/schema/constants.ts index 606609b593..736db5ab38 100644 --- a/apps/ensapi/src/omnigraph-api/schema/constants.ts +++ b/apps/ensapi/src/omnigraph-api/schema/constants.ts @@ -12,3 +12,11 @@ export const ID_PAGINATED_CONNECTION_ARGS = { defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, maxSize: PAGINATION_DEFAULT_MAX_SIZE, } as const; + +/** Shared `accelerate` argument for `Domain.resolve` and `Account.resolve`. */ +export const RESOLVE_ACCELERATE_ARG = { + required: false, + defaultValue: true, + description: + "When true (default), Protocol Acceleration is used for record resolution, when supported.\n@see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration", +} as const; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index bb93598698..44a92eee1a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -15,7 +15,15 @@ DomainCanonicalRef.implement({ description: "The Canonical Name for this Domain.", type: CanonicalNameRef, nullable: false, - resolve: (domain) => domain, + resolve: (domain) => { + if (!domain.canonicalName) { + throw new Error( + `Invariant(DomainCanonical.name): canonical Domain '${domain.id}' is missing canonicalName.`, + ); + } + + return { canonicalName: domain.canonicalName }; + }, }), depth: t.field({ description: diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index ba58c57b1f..fe40cad85a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,6 +1,7 @@ import { ADDR_REVERSE_NODE, asInterpretedLabel, + type CoinType, type DomainId, ETH_NODE, type InterpretedLabel, @@ -15,8 +16,10 @@ import { 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 +487,236 @@ describe("Domain.events filtering (EventsWhereInput)", () => { } }); }); + +describe("Domain.records", () => { + type DomainRecordsResult = { + domain: { + resolve: { + records: { + addresses: Array<{ coinType: CoinType; address: string | 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: string; data: string } | null; + interfaces: Array<{ interfaceId: string; implementer: string | null }>; + addresses: Array<{ coinType: CoinType; address: string | null }>; + texts: Array<{ key: string; value: string | null }>; + } | null; + }; + }; + }; + + const DomainRecords = gql` + query DomainRecords($name: InterpretedName!, $addresses: [CoinType!]!, $texts: [String!]!) { + domain(by: { name: $name }) { + 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 } + } + } + } + } + `; + + it("resolves address and text records for example.eth", async () => { + await expect( + request(DomainRecords, { + name: "example.eth", + addresses: [60], + texts: ["description"], + }), + ).resolves.toMatchObject({ + domain: { + resolve: { + records: { + texts: [{ key: "description", value: "example.eth" }], + addresses: [{ coinType: 60, address: accounts.owner.address }], + }, + }, + }, + }); + }); + + it("resolves every supported record type for test.eth", async () => { + await expect( + request(DomainRecordsAll, { + name: "test.eth", + addresses: [60, 0, 2], + texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], + contentTypeMask: "1", + interfaceIds: [fixtures.fourBytesInterface], + }), + ).resolves.toMatchObject({ + domain: { + resolve: { + records: { + contenthash: fixtures.contenthash, + pubkey: { x: fixtures.publicKeyX, y: fixtures.publicKeyY }, + dnszonehash: null, + version: expect.any(String), + abi: { contentType: "1", data: fixtures.abiBytes }, + interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], + addresses: [ + { coinType: 60, address: accounts.owner.address }, + { coinType: 0, address: fixtures.bitcoinAddress }, + { coinType: 2, address: fixtures.litecoinAddress }, + ], + texts: [ + { key: "avatar", value: "https://example.com/avatar.png" }, + { key: "description", value: "test.eth" }, + { key: "url", value: "https://ens.domains" }, + { key: "email", value: "test@ens.domains" }, + { key: "com.twitter", value: "ensdomains" }, + { key: "com.github", value: "ensdomains" }, + ], + }, + }, + }, + }); + }); + + it("returns null for an unnormalized canonical name (e.g. with labelhash)", async () => { + // A name with a labelhash is an InterpretedName but not a normalized name. + // Even if it exists in the DB, resolve should return null. + const unnormalizedName = + "[0000000000000000000000000000000000000000000000000000000000000000].eth"; + await expect( + request(DomainRecords, { + name: unnormalizedName, + addresses: [60], + texts: ["description"], + }), + ).resolves.toMatchObject({ + domain: null, + }); + }); + + it("returns null for an ABI alias that does not match the returned content type", async () => { + // test.eth has ABI with contentType 1 (JSON) + // If we ask for contentType 2 (zlib-JSON), it should return null + const DomainRecordsAbi = gql` + query DomainRecordsAbi($name: InterpretedName!, $mask1: BigInt!, $mask2: BigInt!) { + domain(by: { name: $name }) { + resolve { + records { + abi1: abi(contentTypeMask: $mask1) { contentType data } + abi2: abi(contentTypeMask: $mask2) { contentType data } + } + } + } + } + `; + + await expect( + request<{ + domain: { + resolve: { + records: { + abi1: { contentType: string; data: string } | null; + abi2: { contentType: string; data: string } | null; + }; + }; + }; + }>(DomainRecordsAbi, { + name: "test.eth", + mask1: "1", // JSON + mask2: "2", // zlib-JSON + }), + ).resolves.toMatchObject({ + domain: { + resolve: { + records: { + abi1: { contentType: "1", data: fixtures.abiBytes }, + abi2: null, + }, + }, + }, + }); + }); +}); + +(INCLUDE_DEV_METHODS ? describe : describe.skip)("Domain.profile", () => { + type DomainProfileResult = { + domain: { + resolve: { + profile: { + description: string | null; + avatar: { url: string | null } | null; + addresses: { ethereum: string | null } | null; + socials: { github: { handle: string | null; url: string | null } | null } | null; + } | null; + }; + }; + }; + + const DomainProfile = gql` + query DomainProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + 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..422eaf76a4 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, type JsonValue } from "enssdk"; -import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; +import type { RequiredAndNotNull, RequiredAndNull, TracingTrace } 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, @@ -19,12 +21,16 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { 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 { @@ -39,6 +45,11 @@ import { LabelRef } from "@/omnigraph-api/schema/label"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration"; import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; +import { + AccelerationStatusRef, + DomainProfileRef, + ResolvedRecordsRef, +} from "@/omnigraph-api/schema/resolution"; const tracer = trace.getTracer("schema/Domain"); @@ -62,6 +73,16 @@ export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { export type Domain = Exclude; export type DomainInterface = Omit; +type DomainRecordsResult = { + trace: TracingTrace; + records: ReturnType; +}; +type DomainResolveModel = { + domain: DomainInterface; + accelerate: boolean; + canAccelerate: boolean; + recordsResolution: Promise | null; +}; export type ENSv1Domain = RequiredAndNotNull & RequiredAndNull & { type: "ENSv1Domain" }; export type ENSv2Domain = RequiredAndNotNull & @@ -75,6 +96,7 @@ export const isENSv2Domain = (domain: DomainInterface): domain is ENSv2Domain => export const ENSv1DomainRef = builder.objectRef("ENSv1Domain"); export const ENSv2DomainRef = builder.objectRef("ENSv2Domain"); +const DomainResolveRef = builder.objectRef("DomainResolve"); ////////////////////////////////// // DomainInterface Implementation @@ -167,6 +189,41 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.id, }), + ////////////////// + // Domain.resolve + ////////////////// + resolve: t.field({ + description: + "Resolve protocol-level data for this Domain with trace and acceleration metadata.", + type: DomainResolveRef, + nullable: false, + args: { + accelerate: t.arg.boolean(RESOLVE_ACCELERATE_ARG), + }, + resolve: (domain, { accelerate: accelerateArg }, context, info) => { + const accelerate = accelerateArg ?? true; + const { canAccelerate } = context; + const name = domain.canonicalName; + + const recordsSelection = + name && isNormalizedName(name) + ? buildRecordsSelectionFromResolveContainerInfo(info) + : null; + + const recordsResolution = + name && recordsSelection + ? runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ).then(({ trace, result }) => ({ + trace, + records: toResolvedRecordsModel(name, result), + })) + : null; + + return { domain, accelerate, canAccelerate, recordsResolution }; + }, + }), + /////////////////////// // Domain.registration /////////////////////// @@ -259,6 +316,52 @@ DomainInterfaceRef.implement({ }), }); +DomainResolveRef.implement({ + description: + "Nested domain resolution container exposing trace/acceleration metadata and resolved data.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).trace as unknown as JsonValue; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this Domain resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ accelerate, canAccelerate }) => ({ + requested: accelerate, + attempted: accelerate && canAccelerate, + }), + }), + records: t.field({ + description: + "Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical.", + type: ResolvedRecordsRef, + nullable: true, + tracing: true, + resolve: async ({ domain, recordsResolution }) => { + if (!domain.canonicalName || !recordsResolution) return null; + return (await recordsResolution).records; + }, + }), + ...(INCLUDE_DEV_METHODS && { + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical.", + type: DomainProfileRef, + nullable: true, + resolve: ({ domain }) => (domain.canonicalName ? {} : null), + }), + }), + }), +}); + ////////////////////////////// // ENSv1Domain Implementation ////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts new file mode 100644 index 0000000000..7fcced2884 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { accounts } from "@ensnode/datasources/devnet"; + +import { request } from "@/test/integration/graphql-utils"; +import { gql } from "@/test/integration/omnigraph-api-client"; + +describe("Resolution Trace and Acceleration", () => { + const DomainResolution = gql` + query DomainResolution($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve { + trace + acceleration { + requested + attempted + } + records { + texts(keys: ["description"]) { + key + value + } + } + } + } + } + `; + + const AccountResolution = gql` + query AccountResolution($address: Address!) { + account(by: { address: $address }) { + resolve { + trace + acceleration { + requested + attempted + } + primaryName(by: { coinType: 60 }) { + name { interpreted } + resolve { + trace + acceleration { + requested + attempted + } + records { + addresses(coinTypes: [60]) { + coinType + address + } + } + } + } + } + } + } + `; + + it("returns trace and acceleration for Domain.resolve", async () => { + const result = await request(DomainResolution, { name: "example.eth" }); + const resolve = result.domain.resolve; + + expect(resolve.trace).toBeDefined(); + expect(Array.isArray(resolve.trace)).toBe(true); + expect(resolve.trace.length).toBeGreaterThan(0); + + expect(resolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + expect(resolve.records.texts).toContainEqual({ + key: "description", + value: "example.eth", + }); + }); + + it("returns trace and acceleration for Account.resolve and primaryName.resolve", async () => { + const result = await request(AccountResolution, { address: accounts.owner.address }); + const accountResolve = result.account.resolve; + + // Account.resolve.trace + expect(accountResolve.trace).toBeDefined(); + expect(Array.isArray(accountResolve.trace)).toBe(true); + expect(accountResolve.trace.length).toBeGreaterThan(0); + + // Account.resolve.acceleration + expect(accountResolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + const primaryName = accountResolve.primaryName; + expect(primaryName.name.interpreted).toBeDefined(); + + // primaryName.resolve.trace + expect(primaryName.resolve.trace).toBeDefined(); + expect(Array.isArray(primaryName.resolve.trace)).toBe(true); + expect(primaryName.resolve.trace.length).toBeGreaterThan(0); + + // primaryName.resolve.acceleration + expect(primaryName.resolve.acceleration).toEqual({ + requested: true, + attempted: expect.any(Boolean), + }); + + expect(primaryName.resolve.records.addresses).toContainEqual({ + coinType: 60, + address: accounts.owner.address, + }); + }); + + it("respects accelerate: false in Domain.resolve", async () => { + const result = await request( + gql` + query DomainNoAccelerate($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve(accelerate: false) { + acceleration { + requested + attempted + } + } + } + } + `, + { name: "example.eth" }, + ); + + expect(result.domain.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + }); + + it("respects accelerate: false in Account.resolve", async () => { + const result = await request( + gql` + query AccountNoAccelerate($address: Address!) { + account(by: { address: $address }) { + resolve(accelerate: false) { + acceleration { + requested + attempted + } + primaryName(by: { coinType: 60 }) { + resolve { + acceleration { + requested + attempted + } + } + } + } + } + } + `, + { address: accounts.owner.address }, + ); + + expect(result.account.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + + // PrimaryNameResolve should inherit accelerate: false from Account.resolve + expect(result.account.resolve.primaryName.resolve.acceleration).toEqual({ + requested: false, + attempted: false, + }); + }); +}); 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..37a9eb039b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.ts @@ -0,0 +1,629 @@ +import { + type Address, + type CoinType, + type Hex, + type InterfaceId, + type InterpretedName, + isNormalizedName, + type JsonValue, + type NormalizedAddress, +} from "enssdk"; + +import type { TracingTrace } from "@ensnode/ensnode-sdk"; + +import { resolveForward } from "@/lib/resolution/forward-resolution"; +import { runWithTrace } from "@/lib/tracing/tracing-api"; +import { builder } from "@/omnigraph-api/builder"; +import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; +import { + ENSIP19_CHAIN_VALUES, + type ENSIP19ChainValue, +} from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { + type ResolvedRecordsModel, + toResolvedRecordsModel, +} from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; +import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; + +export type AccelerationStatusModel = { + requested: boolean; + attempted: boolean; +}; + +export const AccelerationStatusRef = + builder.objectRef("AccelerationStatus"); + +AccelerationStatusRef.implement({ + description: "Execution status metadata for a resolver strategy.", + fields: (t) => ({ + requested: t.exposeBoolean("requested", { + description: "Whether this strategy was requested by the caller.", + nullable: false, + }), + attempted: t.exposeBoolean("attempted", { + description: "Whether this strategy was attempted at runtime.", + nullable: false, + }), + }), +}); + +////////////////// +// ENSIP19Chain +////////////////// +export const ENSIP19Chain = builder.enumType("ENSIP19Chain", { + description: + "ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain.\n@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details.", + values: ENSIP19_CHAIN_VALUES, +}); + +/////////////////////// +// PrimaryName inputs +/////////////////////// +export const PrimaryNameByInput = builder.inputType("PrimaryNameByInput", { + description: + "Select a primary name lookup target. Exactly one of `coinType` or `chain` must be provided.", + isOneOf: true, + fields: (t) => ({ + coinType: t.field({ + type: "CoinType", + description: "The ENSIP-9 coin type to resolve the primary name for.", + }), + chain: t.field({ + type: ENSIP19Chain, + description: "An ENSIP-19 supported chain to resolve the primary name for.", + }), + }), +}); + +export const AccountPrimaryNamesWhereInput = builder.inputType("AccountPrimaryNamesWhereInput", { + 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: [ENSIP19Chain], + description: "ENSIP-19 supported chains to resolve primary names for.", + validate: { minLength: 1 }, + }), + }), +}); + +////////////////////// +// DomainProfile (preview — types only, no resolution wired yet) +////////////////////// +type ProfileSectionModel = Record; + +export const ProfileSocialAccountRef = + builder.objectRef("ProfileSocialAccount"); + +ProfileSocialAccountRef.implement({ + description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", + fields: (t) => ({ + handle: t.string({ + description: "The social handle, or null when unset.", + nullable: true, + resolve: () => null, + }), + url: t.string({ + description: "The social profile URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); + +ProfileSocialsRef.implement({ + description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", + fields: (t) => ({ + github: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + telegram: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + twitter: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: () => ({}), + }), + }), +}); + +export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); + +ProfileAddressesRef.implement({ + description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", + fields: (t) => ({ + ethereum: t.field({ + description: "The interpreted Ethereum address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + base: t.field({ + description: "The interpreted Base address, or null when unset.", + type: "Address", + nullable: true, + resolve: () => null, + }), + bitcoin: t.string({ + description: "The interpreted Bitcoin address, or null when unset.", + nullable: true, + resolve: () => null, + }), + solana: t.string({ + description: "The interpreted Solana address, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); + +ProfileAvatarRef.implement({ + description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved avatar URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileBannerRef = builder.objectRef("ProfileBanner"); + +ProfileBannerRef.implement({ + description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved banner URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); + +ProfileWebsiteRef.implement({ + description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", + fields: (t) => ({ + url: t.string({ + description: "The resolved website URL, or null when unset.", + nullable: true, + resolve: () => null, + }), + }), +}); + +export const DomainProfileRef = builder.objectRef("DomainProfile"); + +DomainProfileRef.implement({ + description: + "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", + fields: (t) => ({ + avatar: t.field({ + type: ProfileAvatarRef, + nullable: true, + resolve: () => ({}), + }), + banner: t.field({ + type: ProfileBannerRef, + nullable: true, + resolve: () => ({}), + }), + website: t.field({ + type: ProfileWebsiteRef, + nullable: true, + resolve: () => ({}), + }), + description: t.string({ + description: "The profile description, or null when unset.", + nullable: true, + resolve: () => null, + }), + addresses: t.field({ + type: ProfileAddressesRef, + nullable: true, + resolve: () => ({}), + }), + socials: t.field({ + type: ProfileSocialsRef, + nullable: true, + resolve: () => ({}), + }), + }), +}); + +////////////////////////// +// ResolvedRawTextRecord +////////////////////////// +export const ResolvedRawTextRecordRef = builder.objectRef<{ key: string; value: string | null }>( + "ResolvedRawTextRecord", +); + +ResolvedRawTextRecordRef.implement({ + description: + "A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.", + fields: (t) => ({ + key: t.exposeString("key", { + description: "The text record key.", + nullable: false, + }), + value: t.exposeString("value", { + description: + "The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.", + nullable: true, + }), + }), +}); + +/////////////////////////// +// ResolvedAddressRecord +/////////////////////////// +export const ResolvedAddressRecordRef = builder.objectRef<{ + coinType: CoinType; + address: string | null; +}>("ResolvedAddressRecord"); + +ResolvedAddressRecordRef.implement({ + description: "A resolved address record for an ENS name.", + fields: (t) => ({ + coinType: t.field({ + description: "The coin type for this address record.", + type: "CoinType", + nullable: false, + resolve: (r) => r.coinType, + }), + address: t.exposeString("address", { + description: "The address value, or null if not set.", + nullable: true, + }), + }), +}); + +//////////////////////// +// ResolvedPubkeyRecord +//////////////////////// +export const ResolvedPubkeyRecordRef = builder.objectRef<{ x: Hex; y: Hex }>( + "ResolvedPubkeyRecord", +); + +ResolvedPubkeyRecordRef.implement({ + description: "A resolved PubkeyResolver (x, y) pair for an ENS name.", + fields: (t) => ({ + x: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.x, + }), + y: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.y, + }), + }), +}); + +/////////////////////// +// ResolvedAbiRecord +/////////////////////// +export const ResolvedAbiRecordRef = builder.objectRef<{ contentType: bigint; data: Hex }>( + "ResolvedAbiRecord", +); + +ResolvedAbiRecordRef.implement({ + description: "A resolved ABI record for an ENS name.", + fields: (t) => ({ + contentType: t.field({ + type: "BigInt", + nullable: false, + resolve: (r) => r.contentType, + }), + data: t.field({ + type: "Hex", + nullable: false, + resolve: (r) => r.data, + }), + }), +}); + +//////////////////////////// +// ResolvedInterfaceRecord +//////////////////////////// +export const ResolvedInterfaceRecordRef = builder.objectRef<{ + interfaceId: InterfaceId; + implementer: NormalizedAddress | null; +}>("ResolvedInterfaceRecord"); + +ResolvedInterfaceRecordRef.implement({ + description: "A resolved ERC-165 interface implementer record for an ENS name.", + fields: (t) => ({ + interfaceId: t.field({ + type: "InterfaceId", + nullable: false, + resolve: (r) => r.interfaceId, + }), + implementer: t.field({ + type: "Address", + nullable: true, + resolve: (r) => r.implementer, + }), + }), +}); + +//////////////////// +// ResolvedRecords +//////////////////// +export type { ResolvedRecordsModel }; + +export const ResolvedRecordsRef = builder.objectRef("ResolvedRecords"); + +ResolvedRecordsRef.implement({ + description: "Records resolved for a specific ENS name via the ENS protocol.", + fields: (t) => ({ + id: t.field({ + description: "Stable cache key for these records: the InterpretedName used to resolve them.", + type: "UID", + nullable: false, + resolve: (parent) => parent.id, + }), + reverseName: t.string({ + description: + "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", + nullable: true, + resolve: (r) => r.name ?? null, + }), + contenthash: t.field({ + description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.contenthash ?? null, + }), + pubkey: t.field({ + description: "The PubkeyResolver (x, y) pair, or null if not set.", + type: ResolvedPubkeyRecordRef, + nullable: true, + resolve: (r) => r.pubkey ?? null, + }), + dnszonehash: t.field({ + description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", + type: "Hex", + nullable: true, + resolve: (r) => r.dnszonehash ?? null, + }), + version: t.field({ + description: "The IVersionableResolver version, or null if not set or unavailable.", + type: "BigInt", + nullable: true, + resolve: (r) => r.version ?? null, + }), + abi: t.field({ + description: + "The first stored ABI matching the requested content-type bitmask, or null if not set.", + type: ResolvedAbiRecordRef, + nullable: true, + args: { + contentTypeMask: t.arg({ + type: "BigInt", + required: true, + description: + "Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).", + }), + }, + resolve: (r, { contentTypeMask }) => { + /* + ENSIP-4 ABIs are stored with a single-bit contentType (1=JSON, 2=zlib-JSON, etc). + The selection-building layer merges all requested contentTypeMasks from all 'abi' + field aliases into a single aggregate mask for the underlying resolution call. + At this resolver layer, we must verify that the specific ABI returned by the + protocol (which is the first one found matching the aggregate mask) actually + matches the specific bitmask requested by *this* GraphQL field alias. + + @see https://docs.ens.domains/ensip/4/ + */ + if (!r.abi) return null; + // check if the found contentType matches the requested contentTypeMask + const foundContentType = r.abi.contentType & contentTypeMask; + if (foundContentType === 0n) return null; + return r.abi; + }, + }), + interfaces: t.field({ + description: "Resolved ERC-165 interface implementer records for the requested ids.", + type: [ResolvedInterfaceRecordRef], + nullable: false, + args: { + ids: t.arg({ + type: ["InterfaceId"], + required: true, + description: "ERC-165 interface ids to resolve (4-byte hex selectors).", + }), + }, + resolve: (r, { ids }) => + // preserve the order of requested interface ids + r.interfaces + ? ids.map((interfaceId) => ({ + interfaceId, + implementer: r.interfaces?.[interfaceId] ?? null, + })) + : [], + }), + texts: t.field({ + description: "Resolved text records for the requested keys.", + type: [ResolvedRawTextRecordRef], + nullable: false, + args: { + keys: t.arg.stringList({ + required: true, + description: "Text record keys to resolve (e.g. `avatar`, `description`).", + }), + }, + resolve: (r, { keys }) => + // preserve the order of requested text keys + r.texts ? keys.map((key) => ({ key, value: r.texts?.[key] ?? null })) : [], + }), + addresses: t.field({ + description: "Resolved address records for the requested coin types.", + type: [ResolvedAddressRecordRef], + nullable: false, + args: { + coinTypes: t.arg({ + type: ["CoinType"], + required: true, + description: "Coin types to resolve (e.g. `60` for ETH).", + }), + }, + resolve: (r, { coinTypes }) => + r.addresses + ? // preserve the order of requested coin types + coinTypes.map((coinType) => ({ + coinType, + address: r.addresses?.[coinType] ?? null, + })) + : [], + }), + }), +}); + +////////////////////// +// PrimaryNameRecord +////////////////////// +export type PrimaryNameRecordModel = { + address: Address; + coinType: CoinType; + chain: ENSIP19ChainValue | null; + name: InterpretedName | null; +}; + +/** GraphQL parent for `PrimaryNameRecord`, including `AccountResolve` acceleration settings. */ +export type PrimaryNameRecordParent = PrimaryNameRecordModel & { + accelerate: boolean; +}; + +type PrimaryNameRecordsResult = { + trace: TracingTrace; + records: ResolvedRecordsModel; +}; + +type PrimaryNameResolveModel = { + parent: PrimaryNameRecordParent; + recordsResolution: Promise | null; +}; + +export const PrimaryNameRecordRef = builder.objectRef("PrimaryNameRecord"); +export const PrimaryNameResolveRef = + builder.objectRef("PrimaryNameResolve"); + +PrimaryNameRecordRef.implement({ + description: "An ENSIP-19 primary name for an Account on a specific coin type.", + fields: (t) => ({ + coinType: t.field({ + description: "The canonical ENSIP-9 coin type for this primary name lookup.", + type: "CoinType", + nullable: false, + resolve: (r) => r.coinType, + }), + chain: t.field({ + description: + "The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`.", + type: ENSIP19Chain, + nullable: true, + resolve: (r) => r.chain, + }), + name: t.field({ + description: + "The validated primary name for this Account on this coin type, or null if none is set.", + type: CanonicalNameRef, + nullable: true, + resolve: (r) => (r.name ? { canonicalName: r.name } : null), + }), + resolve: t.field({ + description: + "Resolve protocol-level records (and optionally profile preview) for this primary name.", + type: PrimaryNameResolveRef, + nullable: false, + resolve: (parent, _args, context, info) => { + const { name, accelerate } = parent; + const { canAccelerate } = context; + + const recordsSelection = + name && isNormalizedName(name) + ? buildRecordsSelectionFromResolveContainerInfo(info) + : null; + + const recordsResolution = + name && recordsSelection + ? runWithTrace(() => + resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + ).then(({ trace, result }) => ({ + trace, + records: toResolvedRecordsModel(name, result), + })) + : null; + + return { parent, recordsResolution }; + }, + }), + }), +}); + +PrimaryNameResolveRef.implement({ + description: + "Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data.", + fields: (t) => ({ + trace: t.field({ + description: + "Protocol trace tree emitted by resolution, represented as JSON for schema stability.", + type: "JSON", + nullable: true, + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).trace as unknown as JsonValue; + }, + }), + acceleration: t.field({ + description: "Protocol acceleration strategy status for this primary name resolution.", + type: AccelerationStatusRef, + nullable: false, + resolve: ({ parent }, _args, context) => ({ + requested: parent.accelerate, + attempted: parent.accelerate && context.canAccelerate, + }), + }), + records: t.field({ + description: + "Forward-resolve ENS records for the validated primary name. Null when `name` is null.", + type: ResolvedRecordsRef, + nullable: true, + tracing: true, + resolve: async ({ recordsResolution }) => { + if (!recordsResolution) return null; + return (await recordsResolution).records; + }, + }), + ...(INCLUDE_DEV_METHODS && { + profile: t.field({ + description: + "PREVIEW: An interpreted ENS profile for the validated primary name. Not yet resolved.", + type: DomainProfileRef, + nullable: true, + resolve: ({ parent }) => (parent.name ? {} : null), + }), + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index c5597e9ed8..e2e6e8814a 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, @@ -20,6 +23,7 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, + type UID, } from "enssdk"; import { isHex, size } from "viem"; import { z } from "zod/v4"; @@ -38,6 +42,12 @@ builder.scalarType("BigInt", { parseValue: (value) => z.coerce.bigint().parse(value), }); +builder.scalarType("JSON", { + description: "JSON represents arbitrary JSON-serializable data.", + serialize: (value: JsonValue) => value, + parseValue: (value) => z.unknown().parse(value) as JsonValue, +}); + builder.scalarType("Address", { description: "Address represents an EVM Address in all lowercase.", serialize: (value: NormalizedAddress) => value, @@ -75,6 +85,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, @@ -94,6 +124,16 @@ builder.scalarType("Node", { .parse(value), }); +builder.scalarType("UID", { + description: "UID is a stable cache key for records/profile entities.", + serialize: (value: UID) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as UID) + .parse(value), +}); + builder.scalarType("InterpretedName", { description: "InterpretedName represents an enssdk#InterpretedName.", serialize: (value: Name) => value, diff --git a/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/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro index 5ecebe945b..4f7975e642 100644 --- a/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro +++ b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/StaticExampleCard.astro @@ -40,8 +40,7 @@ const { uid } = Astro.props; if (integrationTabsEl) { integrationTabsEl.addEventListener("click", (e) => { - const btn = - e.target instanceof Element ? e.target.closest("[data-integration-tab]") : null; + const btn = e.target instanceof Element ? e.target.closest("[data-integration-tab]") : null; if (btn && integrationTabsEl.contains(btn)) { activateIntegration(btn.dataset.integrationTab); } diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 490a043df0..e8b3f4b653 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -7,6 +7,7 @@ import { byIdLookupResolvers } from "./by-id-lookup-resolvers"; import { localBigIntResolvers } from "./local-bigint-resolvers"; import { localConnectionResolvers } from "./local-connection-resolvers"; import { mergeResolverMaps } from "./merge-resolver-maps"; +import { recordsProfileCacheResolvers } from "./records-profile-cache-resolvers"; /** * Entities without keys are 'Embedded Data', and we tell graphcache about them to avoid warnings @@ -37,12 +38,37 @@ export const omnigraphCacheExchange = cacheExchange({ return typeof key === "string" ? key : null; }, + // ResolvedRecords are keyable by just `id` + ResolvedRecords: (data) => { + const key = data.id; + return typeof key === "string" ? key : null; + }, + // These entities are Embedded Data and don't have a relevant key Label: EMBEDDED_DATA, WrappedBaseRegistrarRegistration: EMBEDDED_DATA, CanonicalName: EMBEDDED_DATA, DomainCanonical: EMBEDDED_DATA, DomainResolver: EMBEDDED_DATA, + DomainResolve: EMBEDDED_DATA, + AccountResolve: EMBEDDED_DATA, + PrimaryNameResolve: EMBEDDED_DATA, + ResolutionStatus: EMBEDDED_DATA, + PrimaryNameRecord: EMBEDDED_DATA, + 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 @@ -53,5 +79,6 @@ export const omnigraphCacheExchange = cacheExchange({ // produce local cache resolvers for the Query.entity(by: { }) lookups byIdLookupResolvers, + recordsProfileCacheResolvers, ), }); diff --git a/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts b/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts new file mode 100644 index 0000000000..0d532db56a --- /dev/null +++ b/packages/enskit/src/react/omnigraph/_lib/records-profile-cache-resolvers.ts @@ -0,0 +1,63 @@ +import type { Cache, ResolveInfo, Resolver, Variables } from "@urql/exchange-graphcache"; + +/** + * Delegates to graphcache network resolution when no cached entity is found locally. + */ +const passthrough = (args: Variables, cache: Cache, info: ResolveInfo) => + cache.resolve(info.parentTypeName, info.fieldName, args); + +const asEntityKey = (value: unknown): string | null => (typeof value === "string" ? value : null); + +const lookupCachedRecordsByInterpretedName = (cache: Cache, interpretedName: string) => { + const key = cache.keyOfEntity({ __typename: "ResolvedRecords", id: interpretedName }); + if (key && cache.resolve(key, "id")) return key; + return undefined; +}; + +const resolveInterpretedNameFromCanonical = (cache: Cache, parentKey: string): string | null => { + const canonicalKey = asEntityKey(cache.resolve(parentKey, "canonical")); + if (!canonicalKey) return null; + + const nameKey = asEntityKey(cache.resolve(canonicalKey, "name")); + if (!nameKey) return null; + + const interpreted = cache.resolve(nameKey, "interpreted"); + return typeof interpreted === "string" ? interpreted : null; +}; + +const resolveInterpretedNameFromPrimaryNameRecord = ( + cache: Cache, + parentKey: string, +): string | null => { + const nameKey = asEntityKey(cache.resolve(parentKey, "name")); + if (!nameKey) return null; + + const interpreted = cache.resolve(nameKey, "interpreted"); + return typeof interpreted === "string" ? interpreted : null; +}; + +const resolveRecordsFromParentName: Resolver = (parent, args, cache, info) => { + const parentKey = asEntityKey(parent); + if (!parentKey) return passthrough(args, cache, info); + + const interpreted = + info.parentTypeName === "PrimaryNameRecord" || info.parentTypeName === "PrimaryNameResolve" + ? resolveInterpretedNameFromPrimaryNameRecord(cache, parentKey) + : resolveInterpretedNameFromCanonical(cache, parentKey); + + if (interpreted) { + const cached = lookupCachedRecordsByInterpretedName(cache, interpreted); + if (cached) return cached; + } + + return passthrough(args, cache, info); +}; + +export const recordsProfileCacheResolvers: Record> = { + DomainResolve: { + records: resolveRecordsFromParentName, + }, + PrimaryNameResolve: { + records: resolveRecordsFromParentName, + }, +}; diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index c2af181618..92a1e3676b 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: [ETH, 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/ens.ts b/packages/enssdk/src/lib/types/ens.ts index 10efea7f4f..a89377c7fa 100644 --- a/packages/enssdk/src/lib/types/ens.ts +++ b/packages/enssdk/src/lib/types/ens.ts @@ -158,6 +158,11 @@ export type LiteralName = Name & { __brand: "LiteralName" }; */ export type InterpretedName = Name & { __brand: "InterpretedName" }; +/** + * Stable cache identity for normalized GraphQL entities (e.g. records/profile). + */ +export type UID = String; + /** * A Beautified Name is a Name produced for presentation in a UI from an {@link InterpretedName}. * diff --git a/packages/enssdk/src/lib/types/shared.ts b/packages/enssdk/src/lib/types/shared.ts index 21837dad79..fb483d1156 100644 --- a/packages/enssdk/src/lib/types/shared.ts +++ b/packages/enssdk/src/lib/types/shared.ts @@ -35,6 +35,17 @@ export type DatetimeISO8601 = string; */ export type UrlString = string; +/** + * Any JSON-serializable value. + */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + /** * String representation of {@link AccountId}. * diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 5ff06f0a70..ff9e3b999c 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -30,6 +30,37 @@ const introspection = { "mutationType": null, "subscriptionType": null, "types": [ + { + "kind": "OBJECT", + "name": "AccelerationStatus", + "fields": [ + { + "name": "attempted", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "requested", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "Account", @@ -238,6 +269,27 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolverPermissions", "type": { @@ -668,6 +720,39 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "INPUT_OBJECT", + "name": "AccountPrimaryNamesWhereInput", + "inputFields": [ + { + "name": "chains", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "ENSIP19Chain" + } + } + } + }, + { + "name": "coinTypes", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } + } + ], + "isOneOf": true + }, { "kind": "OBJECT", "name": "AccountRegistryPermissionsConnection", @@ -748,6 +833,86 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "AccountResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "primaryName", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "primaryNames", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PrimaryNameRecord" + } + } + } + }, + "args": [ + { + "name": "where", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AccountPrimaryNamesWhereInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "AccountResolverPermissionsConnection", @@ -1243,6 +1408,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -1536,6 +1722,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", @@ -1616,6 +1863,43 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "DomainResolve", + "fields": [ + { + "name": "acceleration", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccelerationStatus" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "DomainResolver", @@ -1819,36 +2103,70 @@ const introspection = { }, { "kind": "ENUM", - "name": "ENSProtocolVersion", + "name": "ENSIP19Chain", "enumValues": [ { - "name": "ENSv1", + "name": "ARB1", "isDeprecated": false }, { - "name": "ENSv2", + "name": "BASE", "isDeprecated": false - } - ] - }, - { - "kind": "OBJECT", - "name": "ENSv1Domain", - "fields": [ + }, { - "name": "canonical", - "type": { - "kind": "OBJECT", - "name": "DomainCanonical" - }, - "args": [], + "name": "DEFAULT", "isDeprecated": false }, { - "name": "events", - "type": { - "kind": "OBJECT", - "name": "DomainEventsConnection" + "name": "ETH", + "isDeprecated": false + }, + { + "name": "LINEA", + "isDeprecated": false + }, + { + "name": "OP", + "isDeprecated": false + }, + { + "name": "SCR", + "isDeprecated": false + } + ] + }, + { + "kind": "ENUM", + "name": "ENSProtocolVersion", + "enumValues": [ + { + "name": "ENSv1", + "isDeprecated": false + }, + { + "name": "ENSv2", + "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", + "name": "ENSv1Domain", + "fields": [ + { + "name": "canonical", + "type": { + "kind": "OBJECT", + "name": "DomainCanonical" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "events", + "type": { + "kind": "OBJECT", + "name": "DomainEventsConnection" }, "args": [ { @@ -2002,6 +2320,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -2602,6 +2941,27 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "resolve", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "DomainResolve" + } + }, + "args": [ + { + "name": "accelerate", + "type": { + "kind": "SCALAR", + "name": "Boolean" + }, + "defaultValue": "true" + } + ], + "isDeprecated": false + }, { "name": "resolver", "type": { @@ -3579,6 +3939,10 @@ const introspection = { "kind": "SCALAR", "name": "Int" }, + { + "kind": "SCALAR", + "name": "InterfaceId" + }, { "kind": "SCALAR", "name": "InterpretedLabel" @@ -3587,6 +3951,10 @@ const introspection = { "kind": "SCALAR", "name": "InterpretedName" }, + { + "kind": "SCALAR", + "name": "JSON" + }, { "kind": "OBJECT", "name": "Label", @@ -4573,171 +4941,67 @@ const introspection = { "name": "PermissionsUserId" }, { - "kind": "OBJECT", - "name": "Query", - "fields": [ + "kind": "INPUT_OBJECT", + "name": "PrimaryNameByInput", + "inputFields": [ { - "name": "account", + "name": "chain", "type": { - "kind": "OBJECT", - "name": "Account" - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AccountByInput" - } - } - } - ], - "isDeprecated": false + "kind": "ENUM", + "name": "ENSIP19Chain" + } }, { - "name": "domain", + "name": "coinType", "type": { - "kind": "INTERFACE", - "name": "Domain" - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DomainIdInput" - } - } - } - ], - "isDeprecated": false - }, + "kind": "SCALAR", + "name": "CoinType" + } + } + ], + "isOneOf": true + }, + { + "kind": "OBJECT", + "name": "PrimaryNameRecord", + "fields": [ { - "name": "domains", + "name": "chain", "type": { - "kind": "OBJECT", - "name": "QueryDomainsConnection" + "kind": "ENUM", + "name": "ENSIP19Chain" }, - "args": [ - { - "name": "after", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "before", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "order", - "type": { - "kind": "INPUT_OBJECT", - "name": "DomainsOrderInput" - } - }, - { - "name": "where", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DomainsWhereInput" - } - } - } - ], + "args": [], "isDeprecated": false }, { - "name": "permissions", + "name": "coinType", "type": { - "kind": "OBJECT", - "name": "Permissions" - }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PermissionsIdInput" - } - } + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" } - ], - "isDeprecated": false - }, - { - "name": "registry", - "type": { - "kind": "INTERFACE", - "name": "Registry" }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RegistryIdInput" - } - } - } - ], + "args": [], "isDeprecated": false }, { - "name": "resolver", + "name": "name", "type": { "kind": "OBJECT", - "name": "Resolver" + "name": "CanonicalName" }, - "args": [ - { - "name": "by", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ResolverIdInput" - } - } - } - ], + "args": [], "isDeprecated": false }, { - "name": "root", + "name": "resolve", "type": { "kind": "NON_NULL", "ofType": { - "kind": "INTERFACE", - "name": "Registry" + "kind": "OBJECT", + "name": "PrimaryNameResolve" } }, "args": [], @@ -4748,30 +5012,650 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryDomainsConnection", + "name": "PrimaryNameResolve", "fields": [ { - "name": "edges", + "name": "acceleration", "type": { "kind": "NON_NULL", "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge" - } - } + "kind": "OBJECT", + "name": "AccelerationStatus" } }, "args": [], "isDeprecated": false }, { - "name": "pageInfo", + "name": "records", "type": { - "kind": "NON_NULL", + "kind": "OBJECT", + "name": "ResolvedRecords" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "trace", + "type": { + "kind": "SCALAR", + "name": "JSON" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "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" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DomainsWhereInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "permissions", + "type": { + "kind": "OBJECT", + "name": "Permissions" + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PermissionsIdInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "registry", + "type": { + "kind": "INTERFACE", + "name": "Registry" + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RegistryIdInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "resolver", + "type": { + "kind": "OBJECT", + "name": "Resolver" + }, + "args": [ + { + "name": "by", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ResolverIdInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "root", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Registry" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "QueryDomainsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "QueryDomainsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "QueryDomainsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Domain" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INTERFACE", + "name": "Registration", + "fields": [ + { + "name": "domain", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Domain" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "event", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Event" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "expired", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "expiry", + "type": { + "kind": "SCALAR", + "name": "BigInt" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "RegistrationId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "referrer", + "type": { + "kind": "SCALAR", + "name": "Hex" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "registrant", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "registrar", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "renewals", + "type": { + "kind": "OBJECT", + "name": "RegistrationRenewalsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "start", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "unregistrant", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [], + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "BaseRegistrarRegistration" + }, + { + "kind": "OBJECT", + "name": "ENSv2RegistryRegistration" + }, + { + "kind": "OBJECT", + "name": "ENSv2RegistryReservation" + }, + { + "kind": "OBJECT", + "name": "NameWrapperRegistration" + }, + { + "kind": "OBJECT", + "name": "ThreeDNSRegistration" + } + ] + }, + { + "kind": "SCALAR", + "name": "RegistrationId" + }, + { + "kind": "OBJECT", + "name": "RegistrationRenewalsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "RegistrationRenewalsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", "ofType": { "kind": "OBJECT", "name": "PageInfo" @@ -4797,7 +5681,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "QueryDomainsConnectionEdge", + "name": "RegistrationRenewalsConnectionEdge", "fields": [ { "name": "cursor", @@ -4816,8 +5700,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "INTERFACE", - "name": "Domain" + "kind": "OBJECT", + "name": "Renewal" } }, "args": [], @@ -4828,34 +5712,10 @@ const introspection = { }, { "kind": "INTERFACE", - "name": "Registration", + "name": "Registry", "fields": [ { - "name": "domain", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INTERFACE", - "name": "Domain" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "event", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "Event" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "expired", + "name": "canonical", "type": { "kind": "NON_NULL", "ofType": { @@ -4867,61 +5727,86 @@ const introspection = { "isDeprecated": false }, { - "name": "expiry", - "type": { - "kind": "SCALAR", - "name": "BigInt" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "id", + "name": "contract", "type": { "kind": "NON_NULL", "ofType": { - "kind": "SCALAR", - "name": "RegistrationId" + "kind": "OBJECT", + "name": "AccountId" } }, "args": [], "isDeprecated": false }, { - "name": "referrer", - "type": { - "kind": "SCALAR", - "name": "Hex" - }, - "args": [], - "isDeprecated": false - }, - { - "name": "registrant", + "name": "domains", "type": { "kind": "OBJECT", - "name": "Account" + "name": "RegistryDomainsConnection" }, - "args": [], + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "order", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsOrderInput" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "RegistryDomainsWhereInput" + } + } + ], "isDeprecated": false }, { - "name": "registrar", + "name": "id", "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "AccountId" + "kind": "SCALAR", + "name": "RegistryId" } }, "args": [], "isDeprecated": false }, { - "name": "renewals", + "name": "parents", "type": { "kind": "OBJECT", - "name": "RegistrationRenewalsConnection" + "name": "RegistryParentsConnection" }, "args": [ { @@ -4956,58 +5841,153 @@ const introspection = { "isDeprecated": false }, { - "name": "start", + "name": "permissions", + "type": { + "kind": "OBJECT", + "name": "Permissions" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [], + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "ENSv1Registry" + }, + { + "kind": "OBJECT", + "name": "ENSv1VirtualRegistry" + }, + { + "kind": "OBJECT", + "name": "ENSv2Registry" + } + ] + }, + { + "kind": "OBJECT", + "name": "RegistryDomainsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "RegistryDomainsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "RegistryDomainsConnectionEdge", + "fields": [ + { + "name": "cursor", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "BigInt" + "name": "String" } }, "args": [], "isDeprecated": false }, { - "name": "unregistrant", + "name": "node", "type": { - "kind": "OBJECT", - "name": "Account" + "kind": "NON_NULL", + "ofType": { + "kind": "INTERFACE", + "name": "Domain" + } }, "args": [], "isDeprecated": false } ], - "interfaces": [], - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "BaseRegistrarRegistration" - }, - { - "kind": "OBJECT", - "name": "ENSv2RegistryRegistration" - }, - { - "kind": "OBJECT", - "name": "ENSv2RegistryReservation" - }, - { - "kind": "OBJECT", - "name": "NameWrapperRegistration" - }, + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "RegistryDomainsWhereInput", + "inputFields": [ { - "kind": "OBJECT", - "name": "ThreeDNSRegistration" + "name": "name", + "type": { + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter" + } } - ] + ], + "isOneOf": false }, { "kind": "SCALAR", - "name": "RegistrationId" + "name": "RegistryId" + }, + { + "kind": "INPUT_OBJECT", + "name": "RegistryIdInput", + "inputFields": [ + { + "name": "contract", + "type": { + "kind": "INPUT_OBJECT", + "name": "AccountIdInput" + } + }, + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "RegistryId" + } + } + ], + "isOneOf": true }, { "kind": "OBJECT", - "name": "RegistrationRenewalsConnection", + "name": "RegistryParentsConnection", "fields": [ { "name": "edges", @@ -5019,7 +5999,7 @@ const introspection = { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "RegistrationRenewalsConnectionEdge" + "name": "RegistryParentsConnectionEdge" } } } @@ -5056,7 +6036,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "RegistrationRenewalsConnectionEdge", + "name": "RegistryParentsConnectionEdge", "fields": [ { "name": "cursor", @@ -5075,8 +6055,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "Renewal" + "kind": "INTERFACE", + "name": "Domain" } }, "args": [], @@ -5086,203 +6066,135 @@ const introspection = { "interfaces": [] }, { - "kind": "INTERFACE", - "name": "Registry", + "kind": "OBJECT", + "name": "RegistryPermissionsUser", "fields": [ { - "name": "canonical", + "name": "id", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Boolean" + "name": "PermissionsUserId" } }, "args": [], "isDeprecated": false }, { - "name": "contract", + "name": "registry", "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "AccountId" + "kind": "INTERFACE", + "name": "Registry" } }, "args": [], "isDeprecated": false }, { - "name": "domains", + "name": "resource", "type": { - "kind": "OBJECT", - "name": "RegistryDomainsConnection" - }, - "args": [ - { - "name": "after", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "before", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "order", - "type": { - "kind": "INPUT_OBJECT", - "name": "DomainsOrderInput" - } - }, - { - "name": "where", - "type": { - "kind": "INPUT_OBJECT", - "name": "RegistryDomainsWhereInput" - } + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" } - ], + }, + "args": [], "isDeprecated": false }, { - "name": "id", + "name": "roles", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "RegistryId" + "name": "BigInt" } }, "args": [], "isDeprecated": false }, { - "name": "parents", + "name": "user", "type": { - "kind": "OBJECT", - "name": "RegistryParentsConnection" - }, - "args": [ - { - "name": "after", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "before", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int" - } - }, - { - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int" - } + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Account" } - ], - "isDeprecated": false - }, - { - "name": "permissions", - "type": { - "kind": "OBJECT", - "name": "Permissions" }, "args": [], "isDeprecated": false } ], - "interfaces": [], - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "ENSv1Registry" - }, - { - "kind": "OBJECT", - "name": "ENSv1VirtualRegistry" - }, - { - "kind": "OBJECT", - "name": "ENSv2Registry" - } - ] + "interfaces": [] }, { "kind": "OBJECT", - "name": "RegistryDomainsConnection", + "name": "Renewal", "fields": [ { - "name": "edges", + "name": "base", + "type": { + "kind": "SCALAR", + "name": "BigInt" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "duration", "type": { "kind": "NON_NULL", "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "RegistryDomainsConnectionEdge" - } - } + "kind": "SCALAR", + "name": "BigInt" } }, "args": [], "isDeprecated": false }, { - "name": "pageInfo", + "name": "event", "type": { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", - "name": "PageInfo" + "name": "Event" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "RenewalId" } }, "args": [], "isDeprecated": false }, { - "name": "totalCount", + "name": "premium", + "type": { + "kind": "SCALAR", + "name": "BigInt" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "referrer", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Int" - } + "kind": "SCALAR", + "name": "Hex" }, "args": [], "isDeprecated": false @@ -5290,29 +6202,33 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "RenewalId" + }, { "kind": "OBJECT", - "name": "RegistryDomainsConnectionEdge", + "name": "ResolvedAbiRecord", "fields": [ { - "name": "cursor", + "name": "contentType", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "String" + "name": "BigInt" } }, "args": [], "isDeprecated": false }, { - "name": "node", + "name": "data", "type": { "kind": "NON_NULL", "ofType": { - "kind": "INTERFACE", - "name": "Domain" + "kind": "SCALAR", + "name": "Hex" } }, "args": [], @@ -5321,86 +6237,54 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "INPUT_OBJECT", - "name": "RegistryDomainsWhereInput", - "inputFields": [ - { - "name": "name", - "type": { - "kind": "INPUT_OBJECT", - "name": "DomainsNameFilter" - } - } - ], - "isOneOf": false - }, - { - "kind": "SCALAR", - "name": "RegistryId" - }, - { - "kind": "INPUT_OBJECT", - "name": "RegistryIdInput", - "inputFields": [ - { - "name": "contract", - "type": { - "kind": "INPUT_OBJECT", - "name": "AccountIdInput" - } - }, - { - "name": "id", - "type": { - "kind": "SCALAR", - "name": "RegistryId" - } - } - ], - "isOneOf": true - }, { "kind": "OBJECT", - "name": "RegistryParentsConnection", + "name": "ResolvedAddressRecord", "fields": [ { - "name": "edges", + "name": "address", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "RegistryParentsConnectionEdge" - } - } - } + "kind": "SCALAR", + "name": "String" }, "args": [], "isDeprecated": false }, { - "name": "pageInfo", + "name": "coinType", "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "PageInfo" + "kind": "SCALAR", + "name": "CoinType" } }, "args": [], "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedInterfaceRecord", + "fields": [ + { + "name": "implementer", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false }, { - "name": "totalCount", + "name": "interfaceId", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Int" + "name": "InterfaceId" } }, "args": [], @@ -5411,27 +6295,27 @@ const introspection = { }, { "kind": "OBJECT", - "name": "RegistryParentsConnectionEdge", + "name": "ResolvedPubkeyRecord", "fields": [ { - "name": "cursor", + "name": "x", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "String" + "name": "Hex" } }, "args": [], "isDeprecated": false }, { - "name": "node", + "name": "y", "type": { "kind": "NON_NULL", "ofType": { - "kind": "INTERFACE", - "name": "Domain" + "kind": "SCALAR", + "name": "Hex" } }, "args": [], @@ -5442,134 +6326,214 @@ const introspection = { }, { "kind": "OBJECT", - "name": "RegistryPermissionsUser", + "name": "ResolvedRawTextRecord", "fields": [ { - "name": "id", + "name": "key", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "PermissionsUserId" + "name": "String" } }, "args": [], "isDeprecated": false }, { - "name": "registry", + "name": "value", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "INTERFACE", - "name": "Registry" - } + "kind": "SCALAR", + "name": "String" }, "args": [], "isDeprecated": false - }, + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "ResolvedRecords", + "fields": [ { - "name": "resource", + "name": "abi", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "BigInt" - } + "kind": "OBJECT", + "name": "ResolvedAbiRecord" }, - "args": [], + "args": [ + { + "name": "contentTypeMask", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + } + } + ], "isDeprecated": false }, { - "name": "roles", + "name": "addresses", "type": { "kind": "NON_NULL", "ofType": { - "kind": "SCALAR", - "name": "BigInt" + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ResolvedAddressRecord" + } + } } }, - "args": [], + "args": [ + { + "name": "coinTypes", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "CoinType" + } + } + } + } + } + ], "isDeprecated": false }, { - "name": "user", + "name": "contenthash", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "Account" - } + "kind": "SCALAR", + "name": "Hex" }, "args": [], "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "OBJECT", - "name": "Renewal", - "fields": [ + }, { - "name": "base", + "name": "dnszonehash", "type": { "kind": "SCALAR", - "name": "BigInt" + "name": "Hex" }, "args": [], "isDeprecated": false }, { - "name": "duration", + "name": "id", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "BigInt" + "name": "UID" } }, "args": [], "isDeprecated": false }, { - "name": "event", + "name": "interfaces", "type": { "kind": "NON_NULL", "ofType": { - "kind": "OBJECT", - "name": "Event" + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "ResolvedInterfaceRecord" + } + } } }, - "args": [], + "args": [ + { + "name": "ids", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterfaceId" + } + } + } + } + } + ], "isDeprecated": false }, { - "name": "id", + "name": "pubkey", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "RenewalId" - } + "kind": "OBJECT", + "name": "ResolvedPubkeyRecord" }, "args": [], "isDeprecated": false }, { - "name": "premium", + "name": "reverseName", "type": { "kind": "SCALAR", - "name": "BigInt" + "name": "String" }, "args": [], "isDeprecated": false }, { - "name": "referrer", + "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": "Hex" + "name": "BigInt" }, "args": [], "isDeprecated": false @@ -5577,10 +6541,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "SCALAR", - "name": "RenewalId" - }, { "kind": "OBJECT", "name": "Resolver", @@ -6241,6 +7201,10 @@ const introspection = { } ] }, + { + "kind": "SCALAR", + "name": "UID" + }, { "kind": "OBJECT", "name": "WrappedBaseRegistrarRegistration", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index c11ac881ed..b0fab814ce 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -1,3 +1,12 @@ +"""Execution status metadata for a resolver strategy.""" +type AccelerationStatus { + """Whether this strategy was attempted at runtime.""" + attempted: Boolean! + + """Whether this strategy was requested by the caller.""" + requested: Boolean! +} + """Represents an individual Account, keyed by its Address.""" type Account { """An EVM Address that uniquely identifies this Account on-chain.""" @@ -22,6 +31,17 @@ type Account { """The Permissions on Registries granted to this Account.""" registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection + """ + Resolve primary names for this Account with protocol acceleration controls. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): AccountResolve! + """The Permissions on Resolvers granted to this Account.""" resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection } @@ -119,6 +139,17 @@ input AccountPermissionsWhereInput { contract: AccountIdInput } +""" +Filter primary name lookups. Exactly one of `coinTypes` or `chains` must be provided. +""" +input AccountPrimaryNamesWhereInput @oneOf { + """ENSIP-19 supported chains to resolve primary names for.""" + chains: [ENSIP19Chain!] + + """Coin types to resolve primary names for.""" + coinTypes: [CoinType!] +} + type AccountRegistryPermissionsConnection { edges: [AccountRegistryPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -130,6 +161,35 @@ type AccountRegistryPermissionsConnectionEdge { node: RegistryPermissionsUser! } +""" +Nested account resolution container exposing primary-name resolution with shared acceleration settings. +""" +type AccountResolve { + """Protocol acceleration strategy status for this Account resolution.""" + acceleration: AccelerationStatus! + + """ + The ENSIP-19 primary name for this Account on a specific coin type or chain. + """ + primaryName( + """Select a coin type or chain to resolve a primary name for.""" + by: PrimaryNameByInput! + ): PrimaryNameRecord! + + """ + ENSIP-19 primary names for this Account on the requested coin types or chains. + """ + primaryNames( + """Select coin types or chains to resolve primary names for.""" + where: AccountPrimaryNamesWhereInput! + ): [PrimaryNameRecord!]! + + """ + Protocol trace tree emitted by primary-name resolution, represented as JSON for schema stability. + """ + trace: JSON +} + type AccountResolverPermissionsConnection { edges: [AccountResolverPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -275,6 +335,17 @@ interface Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -349,6 +420,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! @@ -360,6 +445,24 @@ type DomainRegistrationsConnectionEdge { node: Registration! } +""" +Nested domain resolution container exposing trace/acceleration metadata and resolved data. +""" +type DomainResolve { + """Protocol acceleration strategy status for this Domain resolution.""" + acceleration: AccelerationStatus! + + """ + Resolve ENS records for this Domain via the ENS protocol. Only canonical, normalized names can be resolved. Returns null if the domain is not canonical. + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. + """ + trace: JSON +} + """Metadata describing this Domain's relationship to its Resolver(s).""" type DomainResolver { """ @@ -426,6 +529,20 @@ input DomainsWhereInput { version: ENSProtocolVersion } +""" +ENSIP-19 supported chains that can have a primary name. Use `DEFAULT` for the ENSIP-19 default EVM chain. +@see https://github.com/ensdomains/address-encoder/blob/master/docs/supported-cryptocurrencies.md for more details. +""" +enum ENSIP19Chain { + ARB1 + BASE + DEFAULT + ETH + LINEA + OP + SCR +} + """An ENS protocol version.""" enum ENSProtocolVersion { ENSv1 @@ -470,6 +587,17 @@ type ENSv1Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -582,6 +710,17 @@ type ENSv2Domain implements Domain { """The Registry under which this Domain exists.""" registry: Registry! + """ + Resolve protocol-level data for this Domain with trace and acceleration metadata. + """ + resolve( + """ + When true (default), Protocol Acceleration is used for record resolution, when supported. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): DomainResolve! + """Resolver relationship metadata for this Domain.""" resolver: DomainResolver! @@ -856,12 +995,18 @@ input EventsWhereInput { """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 +1219,119 @@ 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 { + """An ENSIP-19 supported chain to resolve the primary name for.""" + chain: ENSIP19Chain + + """The ENSIP-9 coin type to resolve the primary name for.""" + coinType: CoinType +} + +"""An ENSIP-19 primary name for an Account on a specific coin type.""" +type PrimaryNameRecord { + """ + The ENSIP-19 chain corresponding to `coinType`, or null when `coinType` is not represented in `ENSIP19Chain`. + """ + chain: ENSIP19Chain + + """The canonical ENSIP-9 coin type for this primary name lookup.""" + coinType: CoinType! + + """ + The validated primary name for this Account on this coin type, or null if none is set. + """ + name: CanonicalName + + """ + Resolve protocol-level records (and optionally profile preview) for this primary name. + """ + resolve: PrimaryNameResolve! +} + +""" +Nested resolution container for a PrimaryNameRecord, including acceleration settings and resolved data. +""" +type PrimaryNameResolve { + """ + Protocol acceleration strategy status for this primary name resolution. + """ + acceleration: AccelerationStatus! + + """ + Forward-resolve ENS records for the validated primary name. Null when `name` is null. + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as JSON for schema stability. + """ + trace: JSON +} + +""" +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 +1535,99 @@ 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 address value, or null if not set.""" + 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: UID! + + """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 { """ @@ -1424,6 +1775,9 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } +"""UID is a stable cache key for records/profile entities.""" +scalar UID + """ Additional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name) """ diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index 3e09a77fa3..e3d6edfac7 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, @@ -19,6 +21,7 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + UID, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -38,11 +41,14 @@ export type OmnigraphScalars = { // the omnigraph returns serialized bigint values from the api; further deserialization is // handled by enskit's graphcache local resolvers (see cache-exchange.ts) BigInt: `${bigint}`; + JSON: JsonValue; Address: NormalizedAddress; Hex: Hex; ChainId: ChainId; CoinType: CoinType; + InterfaceId: InterfaceId; InterpretedName: InterpretedName; + UID: UID; InterpretedLabel: InterpretedLabel; BeautifiedName: BeautifiedName; BeautifiedLabel: BeautifiedLabel; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ccc2b135..f3917c5020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: apps/ensapi: dependencies: + '@ensdomains/address-encoder': + specifier: ^1.1.2 + version: 1.1.4 '@ensdomains/ensjs': specifier: ^4.0.2 version: 4.0.2(typescript@5.9.3)(viem@2.50.3(typescript@5.9.3)(zod@4.3.6))(zod@4.3.6)