diff --git a/.changeset/extend-resolver-records.md b/.changeset/extend-resolver-records.md new file mode 100644 index 000000000..98495ea5e --- /dev/null +++ b/.changeset/extend-resolver-records.md @@ -0,0 +1,9 @@ +--- +"@ensnode/ensdb-sdk": minor +"@ensnode/ensnode-sdk": minor +"ensindexer": minor +"ensapi": minor +"enssdk": minor +--- + +Resolution API: support `contenthash`, `pubkey`, `abi`, `interfaces`, `dnszonehash`, and `version` selection. Protocol acceleration indexes `contenthash`, `pubkey`, `dnszonehash`, and handles `VersionChanged` (clears records for the node, bumps version). `ABI` (bitmask query, contract-equivalent) and `interface` records are selectable but always resolved via RPC. Adds `ContentType` / `InterfaceId` / `RecordVersion` semantic types to `enssdk`. diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index b864731b4..ced33fced 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -1,3 +1,4 @@ +import { replaceBigInts } from "@ponder/utils"; import type { Duration } from "enssdk"; import type { @@ -64,7 +65,11 @@ app.openapi(resolveRecordsRoute, async (c) => { ...(showTrace && { trace }), } satisfies ResolveRecordsResponse; - return c.json(response, 200); + // serialize bigints (e.g. `records.version`, `records.abi.contentType`) as strings + // NOTE: this matches the openapi wire format + // NOTE: the ts client, which uses the ResolverRecordsResponse type, must parse the bigint fields + // into native bigints (see packages/ensnode-sdk/src/ensnode/client.ts) + return c.json(replaceBigInts(response, String), 200); }); /** diff --git a/apps/ensapi/src/lib/handlers/params.schema.ts b/apps/ensapi/src/lib/handlers/params.schema.ts index a911128cc..5ecbf0592 100644 --- a/apps/ensapi/src/lib/handlers/params.schema.ts +++ b/apps/ensapi/src/lib/handlers/params.schema.ts @@ -1,5 +1,12 @@ import { z } from "@hono/zod-openapi"; -import { DEFAULT_EVM_CHAIN_ID, isNormalizedName, type Name } from "enssdk"; +import { + type ContentType, + DEFAULT_EVM_CHAIN_ID, + type InterfaceId, + isInterfaceId, + isNormalizedName, + type Name, +} from "enssdk"; import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { @@ -74,6 +81,30 @@ const nameParamDescription = "to be represented as the primary name for an address. " + "More details here: https://docs.ens.domains/web/reverse"; +const contentTypeBitmask = z + .string() + .transform((val, ctx) => { + try { + const n = BigInt(val); + if (n <= 0n) throw new Error("nope"); + return n as ContentType; + } catch { + ctx.issues.push({ + code: "custom", + message: "Must be a valid positive integer string.", + input: val, + }); + + return z.NEVER; + } + }) + .openapi({ type: "string", example: "1" }); + +const interfaceId = z + .string() + .refine(isInterfaceId, "Must be a 4-byte hex (0x + 8 hex chars)") + .transform((val) => val.toLowerCase() as InterfaceId); + const rawSelectionParams = z.object({ name: z .string() @@ -94,12 +125,24 @@ const rawSelectionParams = z.object({ .describe( "Comma-separated list of text record keys to resolve (e.g. 'avatar,description,url').", ), + contenthash: z.string().optional(), + pubkey: z.string().optional(), + dnszonehash: z.string().optional(), + version: z.string().optional(), + abi: z.string().optional(), + interfaces: z.string().optional(), }); const selectionFields = z.object({ name: z.optional(boolstring), addresses: z.optional(stringarray.pipe(z.array(coinType))), texts: z.optional(stringarray), + contenthash: z.optional(boolstring), + pubkey: z.optional(boolstring), + dnszonehash: z.optional(boolstring), + version: z.optional(boolstring), + abi: z.optional(contentTypeBitmask), + interfaces: z.optional(stringarray.pipe(z.array(interfaceId))), }); type SelectionFields = z.output; @@ -112,6 +155,12 @@ function toSelection( ...(fields.name && { name: true }), ...(fields.addresses && { addresses: fields.addresses }), ...(fields.texts && { texts: fields.texts }), + ...(fields.contenthash && { contenthash: true }), + ...(fields.pubkey && { pubkey: true }), + ...(fields.dnszonehash && { dnszonehash: true }), + ...(fields.version && { version: true }), + ...(fields.abi !== undefined && { abi: fields.abi }), + ...(fields.interfaces && { interfaces: fields.interfaces }), }; if (isSelectionEmpty(sel)) { diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts index c35ea44dd..830b5d125 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts @@ -3,7 +3,7 @@ import { type CoinType, coinTypeReverseLabel, DEFAULT_EVM_COIN_TYPE, - type Name, + type InterpretedName, type NormalizedAddress, } from "enssdk"; @@ -17,7 +17,7 @@ const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE); export async function getENSIP19ReverseNameRecordFromIndex( address: NormalizedAddress, coinType: CoinType, -): Promise { +): Promise { const _coinType = BigInt(coinType); // retrieve from index diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts index d09e930b8..43da88b1b 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts @@ -6,7 +6,6 @@ import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { staticResolverImplementsAddressRecordDefaulting } from "@ensnode/ensnode-sdk/internal"; import { ensDb } from "@/lib/ensdb/singleton"; -import type { IndexedResolverRecords } from "@/lib/resolution/make-records-response"; const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE); @@ -18,43 +17,48 @@ export async function getRecordsFromIndex { - const records = (await ensDb.query.resolverRecords.findFirst({ +}) { + const row = await ensDb.query.resolverRecords.findFirst({ where: (t, { and, eq }) => and( - // filter by specific resolver + // by (chainId, address, node) eq(t.chainId, resolver.chainId), eq(t.address, resolver.address), - // filter by specific node eq(t.node, node), ), - columns: { name: true }, + columns: { + name: true, + contenthash: true, + pubkeyX: true, + pubkeyY: true, + dnszonehash: true, + version: true, + }, with: { addressRecords: true, textRecords: true }, - })) as IndexedResolverRecords | undefined; + }); - // no records found - if (!records) return null; + // coalesce undefined to null + if (!row) return null; - // if the resolver doesn't implement address record defaulting, return records as-is - if (!staticResolverImplementsAddressRecordDefaulting(config.namespace, resolver)) return records; + const implementsAddressRecordDefaulting = staticResolverImplementsAddressRecordDefaulting( + config.namespace, + resolver, + ); - // otherwise, materialize all selected address records that do not yet exist - if (selection.addresses) { - const defaultRecord = records.addressRecords.find( + if (implementsAddressRecordDefaulting && selection.addresses) { + // materialize any selected address record that isn't yet in the index, defaulting + // to the DEFAULT_EVM_COIN_TYPE record's value, if exists + const defaultRecord = row.addressRecords.find( (record) => record.coinType === DEFAULT_EVM_COIN_TYPE_BIGINT, ); - - for (const coinType of selection.addresses) { - const _coinType = BigInt(coinType); - const existing = records.addressRecords.find((record) => record.coinType === _coinType); - if (!existing && defaultRecord) { - records.addressRecords.push({ - value: defaultRecord.value, - coinType: _coinType, - }); + if (defaultRecord) { + for (const coinType of selection.addresses) { + const _coinType = BigInt(coinType); + const existing = row.addressRecords.find((record) => record.coinType === _coinType); + if (!existing) row.addressRecords.push({ ...defaultRecord, coinType: _coinType }); } } } - return records; + return row; } diff --git a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts new file mode 100644 index 000000000..6f0061501 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts @@ -0,0 +1,45 @@ +import type { InterpretedName } from "enssdk"; +import { parseReverseName } from "enssdk"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleration/get-primary-name-from-index"; +import { isOperationResolved, type Operation } from "@/lib/resolution/operations"; + +/** + * Acceleration pass for a Known ENSIP-19 Reverse Resolver, retrieving the Primary Name from + * the index if possible. + * + * If the caller didn't select `name`, this is a no-op — any other selected operations flow + * through to the RPC tail unchanged. A reverse resolver won't meaningfully answer them either + * way; letting the rpc return its natural null-per-record response preserves parity with the + * unaccelerated path. + */ +export async function accelerateENSIP19ReverseResolver({ + operations, + name, + selection, +}: { + operations: Operation[]; + name: InterpretedName; + selection: ResolverRecordsSelection; +}): Promise { + if (selection.name !== true) return operations; + + // parse the Reverse Name into { address, coinType } + const parsed = parseReverseName(name); + if (!parsed) { + throw new Error( + `Invariant(ENSIP-19 Reverse Resolver): expected a valid reverse name, got '${name}'.`, + ); + } + + const result = await getENSIP19ReverseNameRecordFromIndex(parsed.address, parsed.coinType); + + // resolve the 'name' operation with the indexed result, passing others along as-is + return operations.map((op) => { + if (isOperationResolved(op)) return op; + if (op.functionName === "name") return { ...op, result }; + return op; + }); +} diff --git a/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts new file mode 100644 index 000000000..98b8d461c --- /dev/null +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -0,0 +1,80 @@ +import type { AccountId, Node } from "enssdk"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index"; +import { isOperationResolved, type Operation } from "@/lib/resolution/operations"; + +type IndexedRecords = Awaited>; + +/** + * Acceleration pass for a Known On-Chain Static Resolver whose records are fully indexed. + * + * Fills in resolved results for calls that are indexable (name, addr, text, contenthash, pubkey, + * zonehash, recordVersions). Calls that aren't indexable (ABI, interfaceImplementer) remain + * unresolved and flow to the terminal RPC pass. + */ +export async function accelerateKnownOnchainStaticResolver({ + operations, + resolver, + node, + selection, +}: { + operations: Operation[]; + resolver: AccountId; + node: Node; + selection: ResolverRecordsSelection; +}): Promise { + const records = await getRecordsFromIndex({ resolver, node, selection }); + + return operations.map((op) => { + if (isOperationResolved(op)) return op; + return resolveOperationWithIndex(op, records); + }); +} + +/** + * Attempts to resolve an Operation from indexed records. + * + * For indexable calls returns a new Operation with a resolved `result`. For calls that aren't + * indexable (ABI, interfaceImplementer) returns the input Operation unchanged — so its + * `result: undefined` flows on to the RPC tail. + * + * Pass `null` `records` when there is no indexed row for (resolver, node) — indexable calls still + * have well-defined "no record" results in that case. + */ +function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Operation { + switch (op.functionName) { + case "name": + return { ...op, result: records?.name ?? null }; + case "addr": { + const coinType = op.args[1]; + const found = records?.addressRecords.find((r) => r.coinType === coinType); + return { ...op, result: found?.value ?? null }; + } + case "text": { + const key = op.args[1]; + const found = records?.textRecords.find((r) => r.key === key); + return { ...op, result: found?.value ?? null }; + } + case "contenthash": + return { ...op, result: records?.contenthash ?? null }; + case "pubkey": + return { + ...op, + result: + records?.pubkeyX && records?.pubkeyY ? { x: records.pubkeyX, y: records.pubkeyY } : null, + }; + case "zonehash": + return { ...op, result: records?.dnszonehash ?? null }; + case "recordVersions": + // null when no `VersionChanged` event has been seen for this node + return { ...op, result: records?.version ?? null }; + /** + * The following return the Operation as-is, instructing forward-resolution to resolve them via RPC. + */ + case "ABI": + case "interfaceImplementer": + return op; + } +} diff --git a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts similarity index 67% rename from apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts rename to apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts index 36f46ad58..33ba525d6 100644 --- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts @@ -3,7 +3,8 @@ import { vi } from "vitest"; import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; // we're testing a function specifically, not fetching through the running ensapi instance, so -// we need to mock the config when this worker process attempts to import ./resolve-with-universal-resolver +// we need to mock the config when this worker process attempts to import ./execute-operations +// (and this is an integration test because we want to RPC fetch against the running devnet) vi.mock("@/config", () => ({ default: { namespace: ENSNamespaceIds.EnsTestEnv, @@ -22,10 +23,12 @@ import { } from "enssdk"; import { describe, expect, it } from "vitest"; -import { getPublicClient } from "@/lib/public-client"; -import { makeResolveCalls } from "@/lib/resolution/resolve-calls-and-results"; +import { DatasourceNames } from "@ensnode/datasources"; +import { getDatasourceContract } from "@ensnode/ensnode-sdk"; -import { executeResolveCallsWithUniversalResolver } from "./resolve-with-universal-resolver"; +import { getPublicClient } from "@/lib/public-client"; +import { executeOperations } from "@/lib/resolution/execute-operations"; +import { makeOperations } from "@/lib/resolution/operations"; const NAME = asInterpretedName("example.eth"); const NAME_WITH_ENCODED_LABELHASHES = interpretedLabelsToInterpretedName([ @@ -37,12 +40,21 @@ const EXPECTED_DESCRIPTION = "example.eth"; const publicClient = getPublicClient(ensTestEnvChain.id); -describe("executeResolveCallsWithUniversalResolver", () => { +const UniversalResolverV2 = getDatasourceContract( + ENSNamespaceIds.EnsTestEnv, + DatasourceNames.ENSRoot, + "UniversalResolverV2", +); + +describe("executeOperations against UniversalResolver", () => { it("should resolve interpreted name without encoded labelhashes", async () => { + const node = namehashInterpretedName(NAME); await expect( - executeResolveCallsWithUniversalResolver({ + executeOperations({ name: NAME, - calls: makeResolveCalls(namehashInterpretedName(NAME), { texts: ["description"] }), + resolverAddress: UniversalResolverV2.address, + useENSIP10Resolve: true, + operations: makeOperations(node, { texts: ["description"] }), publicClient, }), ).resolves.toMatchObject([{ result: EXPECTED_DESCRIPTION }]); @@ -60,12 +72,13 @@ describe("executeResolveCallsWithUniversalResolver", () => { * Which likely doesn't have the appropriate records set. */ it("should NOT resolve interpreted name with encoded labelhashes", async () => { + const node = namehashInterpretedName(NAME_WITH_ENCODED_LABELHASHES); await expect( - executeResolveCallsWithUniversalResolver({ + executeOperations({ name: NAME_WITH_ENCODED_LABELHASHES, - calls: makeResolveCalls(namehashInterpretedName(NAME_WITH_ENCODED_LABELHASHES), { - texts: ["description"], - }), + resolverAddress: UniversalResolverV2.address, + useENSIP10Resolve: true, + operations: makeOperations(node, { texts: ["description"] }), publicClient, }), ).resolves.toMatchObject([{ result: null }]); diff --git a/apps/ensapi/src/lib/resolution/execute-operations.ts b/apps/ensapi/src/lib/resolution/execute-operations.ts new file mode 100644 index 000000000..9d4381624 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/execute-operations.ts @@ -0,0 +1,166 @@ +import { trace } from "@opentelemetry/api"; +import { + type Address, + asLiteralName, + type ContentType, + type Hex, + type Name, + type RecordVersion, +} from "enssdk"; +import { + ContractFunctionExecutionError, + decodeAbiParameters, + encodeFunctionData, + getAbiItem, + type PublicClient, + size, + toHex, +} from "viem"; +import { packetToBytes } from "viem/ens"; + +import { ResolverABI } from "@ensnode/datasources"; +import { + interpretAddress, + interpretAddressRecordValue, + interpretContenthashValue, + interpretDnszonehashValue, + interpretNameRecordValue, + interpretPubkeyValue, + interpretTextRecordValue, +} from "@ensnode/ensnode-sdk/internal"; + +import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { isOperationResolved, type Operation } from "@/lib/resolution/operations"; + +const tracer = trace.getTracer("execute-operations"); + +/** + * Execute a set of Operations against `resolverAddress`, resolving any unresolved entries. + * Operations already carrying a `result` (from an earlier acceleration pass) are passed through. + * + * @dev viem#readContract implements CCIP-Read, so we get that behavior for free + * TODO: CCIP-Read Gateways can fail, should likely implement retries? + */ +export async function executeOperations({ + name, + resolverAddress, + useENSIP10Resolve, + operations, + publicClient, +}: { + name: Name; + resolverAddress: Address; + useENSIP10Resolve: boolean; + operations: Operation[]; + publicClient: PublicClient; +}): Promise { + return withActiveSpanAsync(tracer, "executeOperations", { name }, async (span) => { + const ResolverContract = { abi: ResolverABI, address: resolverAddress } as const; + + // NOTE: automatically multicalled by viem + return await Promise.all( + operations.map(async (op) => { + if (isOperationResolved(op)) return op; + + try { + if (useENSIP10Resolve) { + return await withSpanAsync( + tracer, + `resolve(${op.functionName}, ${op.args})`, + {}, + async (span) => { + const encodedName = toHex(packetToBytes(name)); // DNS-encode `name` for resolve() + // NOTE: cast through unknown — viem cannot narrow our Operation union back into + // its generic EncodeFunctionDataParameters constraint. + const encodedMethod = encodeFunctionData({ + abi: ResolverABI, + functionName: op.functionName, + args: op.args, + } as unknown as Parameters[0]); + + span.setAttribute("encodedName", encodedName); + span.setAttribute("encodedMethod", encodedMethod); + + const value = await publicClient.readContract({ + ...ResolverContract, + functionName: "resolve", + args: [encodedName, encodedMethod], + }); + + if (size(value) === 0) return interpretOperationWithRawResult(op, null); + + const results = decodeAbiParameters( + getAbiItem({ abi: ResolverABI, name: op.functionName, args: op.args }).outputs, + value, + ); + + // Some calls (ABI, pubkey) return a tuple; single-output calls unwrap. + const raw = results.length === 1 ? results[0] : results; + return interpretOperationWithRawResult(op, raw); + }, + ); + } + + return await withSpanAsync( + tracer, + `${op.functionName}(${op.args})`, + {}, + async (): Promise => { + // NOTE: cast through unknown — same viem-narrowing limitation as the ENSIP-10 branch. + const raw = await publicClient.readContract({ + ...ResolverContract, + functionName: op.functionName, + args: op.args, + } as unknown as Parameters[0]); + return interpretOperationWithRawResult(op, raw); + }, + ); + } catch (error) { + // reverts are expected, treat as null + if (error instanceof ContractFunctionExecutionError) { + return interpretOperationWithRawResult(op, null); + } + + if (error instanceof Error) span.recordException(error); + throw error; + } + }), + ); + }); +} + +/** + * Interprets a single raw RPC result into its semantic value, producing a resolved Operation. + * + * A `null` raw maps to `result: null` for all call types — including `recordVersions`, where + * revert (resolver doesn't implement `IVersionableResolver`) is surfaced to callers rather than + * conflated with an explicit `0n`. + */ +export function interpretOperationWithRawResult(call: Operation, raw: unknown): Operation { + if (raw === null) return { ...call, result: null } as Operation; + + switch (call.functionName) { + case "name": + return { ...call, result: interpretNameRecordValue(asLiteralName(raw as string)) }; + case "addr": + return { ...call, result: interpretAddressRecordValue(raw as string) }; + case "text": + return { ...call, result: interpretTextRecordValue(raw as string) }; + case "contenthash": + return { ...call, result: interpretContenthashValue(raw as Hex) }; + case "pubkey": { + const [x, y] = raw as [Hex, Hex]; + return { ...call, result: interpretPubkeyValue(x, y) }; + } + case "zonehash": + return { ...call, result: interpretDnszonehashValue(raw as Hex) }; + case "recordVersions": + return { ...call, result: raw as RecordVersion }; + case "ABI": { + const [contentType, data] = raw as [ContentType, Hex]; + return { ...call, result: size(data) === 0 ? null : { contentType, data } }; + } + case "interfaceImplementer": + return { ...call, result: interpretAddress(raw as Address) }; + } +} diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 2a14b6a6d..2cf615d15 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -10,17 +10,17 @@ import { isNormalizedName, type Node, namehashInterpretedName, - parseReverseName, } from "enssdk"; +import { DatasourceNames } from "@ensnode/datasources"; import { type ForwardResolutionArgs, ForwardResolutionProtocolStep, type ForwardResolutionResult, + getDatasourceContract, getENSv1Registry, - isSelectionEmpty, + maybeGetDatasourceContract, PluginName, - type ResolverRecordsResponse, type ResolverRecordsSelection, TraceableENSProtocol, } from "@ensnode/ensnode-sdk"; @@ -32,24 +32,16 @@ import { } from "@ensnode/ensnode-sdk/internal"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { lazy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; import { findResolver } from "@/lib/protocol-acceleration/find-resolver"; -import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleration/get-primary-name-from-index"; -import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index"; import { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; import { getPublicClient } from "@/lib/public-client"; -import { - makeEmptyResolverRecordsResponse, - makeRecordsResponseFromIndexedRecords, - makeRecordsResponseFromResolveResults, -} from "@/lib/resolution/make-records-response"; -import { - executeResolveCalls, - interpretRawCallsAndResults, - makeResolveCalls, - tablifyCallResults, -} from "@/lib/resolution/resolve-calls-and-results"; -import { executeResolveCallsWithUniversalResolver } from "@/lib/resolution/resolve-with-universal-resolver"; +import { accelerateENSIP19ReverseResolver } from "@/lib/resolution/accelerate-ensip19-reverse-resolver"; +import { accelerateKnownOnchainStaticResolver } from "@/lib/resolution/accelerate-known-onchain-static-resolver"; +import { executeOperations } from "@/lib/resolution/execute-operations"; +import { makeRecordsResponse } from "@/lib/resolution/make-records-response"; +import { isOperationResolved, logOperations, makeOperations } from "@/lib/resolution/operations"; import { addEnsProtocolStepEvent, withEnsProtocolStep, @@ -58,6 +50,14 @@ import { const logger = makeLogger("forward-resolution"); const tracer = trace.getTracer("forward-resolution"); +const getUniversalResolverV1 = lazy(() => + getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), +); + +const getUniversalResolverV2 = lazy(() => + maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"), +); + /** * Implements Forward Resolution of record values for a specified ENS Name. * @@ -118,7 +118,8 @@ async function _resolveForward( canAccelerate = false, } = options; - const selectionString = JSON.stringify(selection); + // `selection` may contain bigints (e.g. `abi: ContentType`); stringify safely for tracing. + const selectionString = JSON.stringify(replaceBigInts(selection, String)); // trace for external consumers return withEnsProtocolStep( @@ -161,57 +162,43 @@ async function _resolveForward( const node: Node = namehashInterpretedName(name); span.setAttribute("node", node); - // if selection is empty, give them what they asked for - if (isSelectionEmpty(selection)) return makeEmptyResolverRecordsResponse(selection); + // construct the set of resolve() operations indicated by node/selection + let operations = makeOperations(node, selection); + span.setAttribute("operations", JSON.stringify(replaceBigInts(operations, String))); - // construct the set of resolve() calls indicated by selection - const calls = makeResolveCalls(node, selection); - span.setAttribute("calls", JSON.stringify(replaceBigInts(calls, String))); - - // Invariant: a non-empty selection must have generated some resolve calls - if (calls.length === 0) { - throw new Error( - `Invariant: Selection ${JSON.stringify(selection)} is not empty but resulted in no resolution calls.`, - ); - } + // if no operations were generated, this was an empty selection; give them what they asked for + if (operations.length === 0) return makeRecordsResponse(operations); const publicClient = getPublicClient(chainId); //////////////////////////// - /// Temporary ENSv2 Bailout + /// 0. Temporary ENSv2 Bailout //////////////////////////// // TODO: re-enable protocol acceleration for ENSv2 if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { - // execute each record's call against the UniversalResolverV2 - const rawResults = await withEnsProtocolStep( + const UniversalResolverAddress = + getUniversalResolverV2()?.address ?? getUniversalResolverV1().address; + operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, {}, () => - executeResolveCallsWithUniversalResolver({ + executeOperations({ name, - calls, + resolverAddress: UniversalResolverAddress, + operations, publicClient, + useENSIP10Resolve: true, }), ); - // additional semantic interpretation of the raw results from the chain - const results = interpretRawCallsAndResults(rawResults); - - if (process.env.NODE_ENV !== "production") { - console.table(tablifyCallResults(rawResults, results)); - } else { - logger.debug({ rawResults, results }); - } - - // return record values - return makeRecordsResponseFromResolveResults(selection, results); + logOperations(operations, logger); + return makeRecordsResponse(operations); } - ////////////////////////////////////////////////// - // 1. Identify the active resolver for the name on the specified chain. - ////////////////////////////////////////////////// - + /////////////////////////// + // 1. Find Active Resolver + /////////////////////////// const { activeName, activeResolver, requiresWildcardSupport } = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.FindResolver, @@ -234,7 +221,7 @@ async function _resolveForward( !!activeResolver, ); // we're unable to find an active resolver for this name, return empty response - if (!activeResolver) return makeEmptyResolverRecordsResponse(selection); + if (!activeResolver) return makeRecordsResponse(operations); // set some attributes on the span for easy reference span.setAttribute("activeResolver", activeResolver); @@ -247,73 +234,11 @@ async function _resolveForward( requiresWildcardSupport, }); - ////////////////////////////////////////////////// - // 2. _resolveBatch with activeResolver, w/ ENSIP-10 Wildcard Resolution support - ////////////////////////////////////////////////// - - ////////////////////////////////////////////////// - // Protocol Acceleration - ////////////////////////////////////////////////// + ///////////////////////////////////// + // 2. Bridged Resolver short-circuit + ///////////////////////////////////// if (accelerate && canAccelerate) { - // NOTE: because Resolvers can exist without emitting events (and therefore may or may - // not actually exist in the index), we have to do runtime validation of the Resolver's - // metadata (i.e. whether it's an ENSIP-19 Reverse Resolver, a Bridged Resolver, etc) - // ex: BasenamesL1Resolver need not emit events to function properly - // https://etherscan.io/address/0xde9049636f4a1dfe0a64d1bfe3155c0a14c54f31#code const resolver = { chainId, address: activeResolver }; - - ////////////////////////////////////////////////// - // Protocol Acceleration: ENSIP-19 Reverse Resolvers - // If the activeResolver is a Known ENSIP-19 Reverse Resolver, - // then we can just read the name record value directly from the index. - ////////////////////////////////////////////////// - if (isKnownENSIP19ReverseResolver(config.namespace, resolver)) { - return withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, - {}, - async () => { - // Invariant: consumer must be selecting the `name` record at this point - if (selection.name !== true) { - throw new Error( - `Invariant(ENSIP-19 Reverse Resolvers Protocol Acceleration): expected 'name' record in selection but instead received: ${JSON.stringify(selection)}.`, - ); - } - - // Sanity Check: This should only happen in the context of Reverse Resolution, and - // the selection should just be `{ name: true }`, but technically not prohibited to - // select more records than just 'name', so just warn if that happens. - if (selection.addresses !== undefined || selection.texts !== undefined) { - logger.warn( - `Sanity Check(ENSIP-19 Reverse Resolvers Protocol Acceleration): expected a selection of exactly '{ name: true }' but received ${JSON.stringify(selection)}.`, - ); - } - - // Invariant: the name in question should be an ENSIP-19 Reverse Name that we're able to parse - const parsed = parseReverseName(name); - if (!parsed) { - throw new Error( - `Invariant(ENSIP-19 Reverse Resolvers Protocol Acceleration): expected a valid ENSIP-19 Reverse Name but recieved '${name}'.`, - ); - } - - // retrieve the name record from the index - const nameRecordValue = await getENSIP19ReverseNameRecordFromIndex( - parsed.address, - parsed.coinType, - ); - - // NOTE: typecast is ok because of sanity checks above - return { name: nameRecordValue } as ResolverRecordsResponse; - }, - ); - } - - ////////////////////////////////////////////////// - // Protocol Acceleration: Bridged Resolvers - // If the activeResolver is a Bridged Resolver, - // then we can short-circuit the CCIP-Read and defer resolution to the indicated (shadow)Registry. - ////////////////////////////////////////////////// const bridgesTo = isBridgedResolver(config.namespace, resolver); if (bridgesTo) { return withEnsProtocolStep( @@ -330,62 +255,63 @@ async function _resolveForward( ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, false, ); + } - ////////////////////////////////////////////////// - // Protocol Acceleration: Known On-Chain Static Resolvers - // If: - // 1) the ProtocolAcceleration Plugin indexes records for all Resolver contracts on - // this chain, and - // 2) the activeResolver is a Static Resolver, - // then we can retrieve records directly from the database. - ////////////////////////////////////////////////// + ////////////////////////////////////////////////// + // 3. Accelerate if possible — each strategy is its own pass over operations. + ////////////////////////////////////////////////// + if (accelerate && canAccelerate) { + const resolver = { chainId, address: activeResolver }; + + // Pass: ENSIP-19 Reverse Resolver + if (isKnownENSIP19ReverseResolver(config.namespace, resolver)) { + operations = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, + {}, + () => accelerateENSIP19ReverseResolver({ operations, name, selection }), + ); + } + + // Pass: Known On-Chain Static Resolver with indexed records const resolverRecordsAreIndexed = areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId( config.namespace, chainId, ); - if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { - return withEnsProtocolStep( + operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, {}, - async () => { - const records = await getRecordsFromIndex({ - resolver: { chainId, address: activeResolver }, - node, - selection, - }); - - // if resolver doesn't exist here, there are no records in the index - if (!records) return makeEmptyResolverRecordsResponse(selection); - - // otherwise, format into RecordsResponse and return - return makeRecordsResponseFromIndexedRecords(selection, records); - }, + () => + accelerateKnownOnchainStaticResolver({ operations, resolver, node, selection }), + ); + } else { + addEnsProtocolStepEvent( + protocolTracingSpan, + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, + false, ); } - - addEnsProtocolStepEvent( - protocolTracingSpan, - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, - false, - ); } - ////////////////////////////////////////////////// - // 3. Execute each record's call against the active Resolver. - // NOTE: from here, MUST execute EVM code to be compliant with ENS Protocol. - // i.e. must execute resolve() to retrieve active record values - ////////////////////////////////////////////////// + // early return if every operation is resolved + if (operations.every(isOperationResolved)) { + logOperations(operations, logger); + return makeRecordsResponse(operations); + } - // 3.1 requireResolver() — verifies that the resolver supports ENSIP-10 if necessary + //////////////////////////////////////////////////////////////////////////// + // 4. Determine Resolver ENSIP-10 support + requirement. + // From here, we MUST execute EVM code to be compliant with ENS Protocol + //////////////////////////////////////////////////////////////////////////// const extended = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.RequireResolver, { chainId, activeResolver, requiresWildcardSupport }, - async (span) => { + async (stepSpan) => { const extended = await withSpanAsync( tracer, "isExtendedResolver", @@ -393,47 +319,48 @@ async function _resolveForward( () => isExtendedResolver({ address: activeResolver, publicClient }), ); - span.setAttribute("isExtendedResolver", extended); + stepSpan.setAttribute("isExtendedResolver", extended); return extended; }, ); - // if we require wildcard support and this is NOT an extended resolver, the resolver is not - // valid, i.e. there is no active resolver for the name + // if we require wildcard support and this is NOT an extended resolver, the resolver is + // not valid, i.e. there is no active resolver for the name // https://docs.ens.domains/ensip/10/#specification if (requiresWildcardSupport && !extended) { - return makeEmptyResolverRecordsResponse(selection); + return makeRecordsResponse(operations); } - // execute each record's call against the active Resolver - const rawResults = await withEnsProtocolStep( + /////////////////////////////////////////// + // 5. Resolve remaining operations via RPC + /////////////////////////////////////////// + operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, {}, () => - executeResolveCalls({ + executeOperations({ name, resolverAddress: activeResolver, // NOTE: ENSIP-10 specifies that if a resolver supports IExtendedResolver, // the client MUST use the ENSIP-10 resolve() method over the legacy methods. useENSIP10Resolve: extended, - calls, + operations, publicClient, }), ); - // additional semantic interpretation of the raw results from the chain - const results = interpretRawCallsAndResults(rawResults); - - if (process.env.NODE_ENV !== "production") { - console.table(tablifyCallResults(rawResults, results)); - } else { - logger.debug({ rawResults, results }); + // Invariant: all operations must be resolved + if (!operations.every(isOperationResolved)) { + throw new Error( + `Invariant(forward-resolution): Not all operations were resolved at the end of resolution!\n${JSON.stringify(replaceBigInts(operations, String))}`, + ); } // return record values - return makeRecordsResponseFromResolveResults(selection, results); + logOperations(operations, logger); + return makeRecordsResponse(operations); }, ), ); diff --git a/apps/ensapi/src/lib/resolution/make-records-response.test.ts b/apps/ensapi/src/lib/resolution/make-records-response.test.ts index 322f13c59..e9c31db5c 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.test.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.test.ts @@ -1,87 +1,102 @@ -import type { CoinType } from "enssdk"; +import { asInterpretedName, type CoinType, type Hex, type InterfaceId } from "enssdk"; import { describe, expect, it } from "vitest"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; -import { - type IndexedResolverRecords, - makeRecordsResponseFromIndexedRecords, -} from "./make-records-response"; +import { makeRecordsResponse } from "./make-records-response"; +import { makeOperations, type Operation } from "./operations"; -describe("lib-resolution", () => { - describe("makeRecordsResponseFromIndexedRecords", () => { - const mockRecords: IndexedResolverRecords = { - name: "test.eth", - addressRecords: [ - { coinType: 60n, value: "0x123" }, - { coinType: 1001n, value: "0x456" }, - ], - textRecords: [ - { key: "com.twitter", value: "@test" }, - { key: "avatar", value: "ipfs://..." }, - ], - }; +describe("makeRecordsResponse", () => { + const node = `0x${"00".repeat(32)}` as Hex; + + it("writes a resolved name record", () => { + const operations = [ + { functionName: "name", args: [node], result: asInterpretedName("test.eth") }, + ] satisfies Operation[]; + expect(makeRecordsResponse(operations)).toEqual({ name: "test.eth" }); + }); - it("should return name record when requested", () => { - const selection: ResolverRecordsSelection = { name: true }; - const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); - expect(result).toEqual({ name: "test.eth" }); + it("writes resolved address records keyed by CoinType", () => { + const operations = [ + { functionName: "addr", args: [node, 60n], result: "0x123" }, + { functionName: "addr", args: [node, 1001n], result: "0x456" }, + ] satisfies Operation[]; + expect(makeRecordsResponse(operations)).toEqual({ + addresses: { 60: "0x123", 1001: "0x456" }, }); + }); - it("should return address records when requested", () => { - const selection: ResolverRecordsSelection = { addresses: [60, 1001] }; - const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); - expect(result).toEqual({ - addresses: { - 60: "0x123", - 1001: "0x456", - }, - }); + it("writes resolved text records keyed by key", () => { + const operations = [ + { functionName: "text", args: [node, "com.twitter"], result: "@test" }, + { functionName: "text", args: [node, "avatar"], result: "ipfs://..." }, + ] satisfies Operation[]; + expect(makeRecordsResponse(operations)).toEqual({ + texts: { "com.twitter": "@test", avatar: "ipfs://..." }, }); + }); - it("should return text records when requested", () => { - const selection: ResolverRecordsSelection = { texts: ["com.twitter", "avatar"] }; - const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); - expect(result).toEqual({ - texts: { - "com.twitter": "@test", - avatar: "ipfs://...", - }, - }); + it("writes resolved contenthash / pubkey / dnszonehash / version", () => { + const operations = [ + { functionName: "contenthash", args: [node], result: "0xdeadbeef" as Hex }, + { + functionName: "pubkey", + args: [node], + result: { x: `0x${"11".repeat(32)}` as Hex, y: `0x${"22".repeat(32)}` as Hex }, + }, + { functionName: "zonehash", args: [node], result: "0xcafe" as Hex }, + { functionName: "recordVersions", args: [node], result: 7n }, + ] satisfies Operation[]; + expect(makeRecordsResponse(operations)).toEqual({ + contenthash: "0xdeadbeef", + pubkey: { x: `0x${"11".repeat(32)}`, y: `0x${"22".repeat(32)}` }, + dnszonehash: "0xcafe", + version: 7n, }); + }); - it("should return null for missing records", () => { - const selection: ResolverRecordsSelection = { - addresses: [1 as CoinType], - texts: ["missing"], - }; - const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); - expect(result).toEqual({ - addresses: { - 1: null, - }, - texts: { - missing: null, - }, - }); + it("writes a resolved ABI", () => { + const operations = [ + { + functionName: "ABI", + args: [node, 1n], + result: { contentType: 1n, data: "0xabcd" as Hex }, + }, + ] satisfies Operation[]; + expect(makeRecordsResponse(operations)).toEqual({ + abi: { contentType: 1n, data: "0xabcd" }, }); + }); - it("should handle multiple record types in one selection", () => { - const selection: ResolverRecordsSelection = { - name: true, - addresses: [60], - texts: ["com.twitter"], - }; - const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); - expect(result).toEqual({ - name: "test.eth", - addresses: { - 60: "0x123", - }, - texts: { - "com.twitter": "@test", - }, - }); + it("materializes unresolved operations as 'no record' defaults", () => { + const id = "0x01020304" as InterfaceId; + const selection: ResolverRecordsSelection = { + name: true, + addresses: [60 as CoinType], + texts: ["avatar"], + contenthash: true, + pubkey: true, + dnszonehash: true, + version: true, + abi: 1n, + interfaces: [id], + }; + // operations generated from selection, every entry unresolved + const operations = makeOperations(node, selection); + expect(makeRecordsResponse(operations)).toEqual({ + name: null, + addresses: { 60: null }, + texts: { avatar: null }, + contenthash: null, + pubkey: null, + dnszonehash: null, + version: null, + abi: null, + interfaces: { [id]: null }, }); }); + + it("returns {} for an empty selection + empty operations", () => { + expect(makeRecordsResponse([])).toEqual({}); + }); }); diff --git a/apps/ensapi/src/lib/resolution/make-records-response.ts b/apps/ensapi/src/lib/resolution/make-records-response.ts index ba8f98e5b..7c70ce9c6 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.ts @@ -6,106 +6,53 @@ import type { ResolverRecordsSelection, } from "@ensnode/ensnode-sdk"; -import type { ResolveCallsAndResults } from "./resolve-calls-and-results"; - -export interface IndexedResolverRecords { - name: string | null; - addressRecords: { coinType: bigint; value: string }[]; - textRecords: { key: string; value: string }[]; -} +import type { Operation } from "./operations"; /** - * Formats IndexedResolverRecords into a ResolverRecordsResponse based on the provided selection. + * Folds a set of Operations into a ResolverRecordsResponse. * - * @param selection - The selection specifying which records to include in the response - * @param records - The indexed resolver records to format - * @returns A formatted ResolverRecordsResponse containing only the requested records + * SELECTION is a type-only argument — callers pass it explicitly (e.g. + * `makeRecordsResponse(operations)`) to shape the return type. */ -export function makeRecordsResponseFromIndexedRecords( - selection: SELECTION, - records: IndexedResolverRecords, +export function makeRecordsResponse( + operations: Operation[], ): ResolverRecordsResponse { - const response: Partial = {}; - - if (selection.name) { - response.name = records.name; - } - - if (selection.addresses) { - response.addresses = selection.addresses.reduce( - (memo, coinType) => { - memo[coinType] = - records.addressRecords.find((r) => bigintToCoinType(r.coinType) === coinType)?.value || - null; - return memo; - }, - {} as ResolverRecordsResponseBase["addresses"], - ); - } - - if (selection.texts) { - response.texts = selection.texts.reduce( - (memo, key) => { - memo[key] = records.textRecords.find((r) => r.key === key)?.value ?? null; - return memo; - }, - {} as ResolverRecordsResponseBase["texts"], - ); - } - - // cast response as the inferred type based on SELECTION - return response as ResolverRecordsResponse; -} - -export function makeRecordsResponseFromResolveResults( - selection: SELECTION, - results: ResolveCallsAndResults, -): ResolverRecordsResponse { - const response: Partial = {}; - - if (selection.name) { - const nameResult = results.find(({ call: { functionName } }) => functionName === "name"); - const name = (nameResult?.result as string | null) || null; - response.name = name; - } - - if (selection.addresses) { - response.addresses = selection.addresses.reduce( - (memo, coinType) => { - const addressRecord = results.find( - ({ call: { functionName, args } }) => - functionName === "addr" && bigintToCoinType(args[1] as bigint) === coinType, - ); - memo[coinType] = (addressRecord?.result as string | null) || null; - return memo; - }, - {} as ResolverRecordsResponseBase["addresses"], - ); - } - - if (selection.texts) { - response.texts = selection.texts.reduce( - (memo, key) => { - const textRecord = results.find( - ({ call: { functionName, args } }) => functionName === "text" && args[1] === key, - ); - memo[key] = (textRecord?.result as string | null) || null; - return memo; - }, - {} as ResolverRecordsResponseBase["texts"], - ); - } - - // cast response as the inferred type based on SELECTION - return response as ResolverRecordsResponse; -} - -export function makeEmptyResolverRecordsResponse( - selection: SELECTION, -) { - return makeRecordsResponseFromIndexedRecords(selection, { - name: null, - addressRecords: [], - textRecords: [], - }); + return operations.reduce>((memo, op) => { + switch (op.functionName) { + case "name": + memo.name = op.result ?? null; + break; + case "contenthash": + memo.contenthash = op.result ?? null; + break; + case "pubkey": + memo.pubkey = op.result ?? null; + break; + case "zonehash": + memo.dnszonehash = op.result ?? null; + break; + case "recordVersions": + memo.version = op.result ?? null; + break; + case "ABI": + memo.abi = op.result ?? null; + break; + case "addr": { + memo.addresses ??= {} as ResolverRecordsResponseBase["addresses"]; + memo.addresses[bigintToCoinType(op.args[1])] = op.result ?? null; + break; + } + case "text": { + memo.texts ??= {} as ResolverRecordsResponseBase["texts"]; + memo.texts[op.args[1]] = op.result ?? null; + break; + } + case "interfaceImplementer": { + memo.interfaces ??= {} as ResolverRecordsResponseBase["interfaces"]; + memo.interfaces[op.args[1]] = op.result ?? null; + break; + } + } + return memo; + }, {}) as ResolverRecordsResponse; } diff --git a/apps/ensapi/src/lib/resolution/operations.ts b/apps/ensapi/src/lib/resolution/operations.ts new file mode 100644 index 000000000..8780edfd0 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/operations.ts @@ -0,0 +1,118 @@ +import type { + Address, + CoinType, + ContentType, + Hex, + InterfaceId, + InterpretedName, + Node, + RecordVersion, +} from "enssdk"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +/** + * Canonical mapping from a Resolver function name to its argument tuple and semantically + * interpreted result type. Add a record type here and `Operation`, `makeOperations`, and + * `interpretOperationWithRawResult` will force you to handle it. + */ +type OperationMap = { + name: { args: readonly [Node]; result: InterpretedName | null }; + addr: { args: readonly [Node, bigint]; result: string | null }; + text: { args: readonly [Node, string]; result: string | null }; + contenthash: { args: readonly [Node]; result: Hex | null }; + pubkey: { args: readonly [Node]; result: { x: Hex; y: Hex } | null }; + zonehash: { args: readonly [Node]; result: Hex | null }; + recordVersions: { args: readonly [Node]; result: RecordVersion | null }; + ABI: { + args: readonly [Node, ContentType]; + result: { contentType: ContentType; data: Hex } | null; + }; + interfaceImplementer: { args: readonly [Node, InterfaceId]; result: Address | null }; +}; + +type FunctionName = keyof OperationMap; + +/** + * A Resolver call paired with its resolution state. Discriminated on `functionName`. + * + * `result === undefined` means unresolved; any other value (including `null`) means resolved. + * Each variant narrows `result` to the shape produced by the matching interpreter — e.g. for + * `functionName: "name"`, `result: InterpretedName | null | undefined`. + */ +export type Operation = { + [FN in FunctionName]: { + functionName: FN; + args: OperationMap[FN]["args"]; + result: OperationMap[FN]["result"] | undefined; + }; +}[FunctionName]; + +/** + * Type alias retained for documentation at callsites; the array element type is the same + * full `Operation` union regardless of SELECTION. + */ +export type Operations<_SELECTION extends ResolverRecordsSelection = ResolverRecordsSelection> = + Operation[]; + +/** + * Typed factory for a single unresolved Operation. + */ +function makeOperation( + functionName: FN, + args: OperationMap[FN]["args"], +): Extract { + return { functionName, args, result: undefined } as Extract; +} + +/** + * Builds the set of Operations specified by a ResolverRecordsSelection. Each entry is initially + * unresolved (`result: undefined`). + */ +export function makeOperations(node: Node, selection: ResolverRecordsSelection): Operation[] { + return [ + selection.name && makeOperation("name", [node]), + selection.contenthash && makeOperation("contenthash", [node]), + selection.pubkey && makeOperation("pubkey", [node]), + selection.dnszonehash && makeOperation("zonehash", [node]), + selection.version && makeOperation("recordVersions", [node]), + selection.abi !== undefined && makeOperation("ABI", [node, selection.abi]), + ...(selection.addresses ?? []).map((coinType: CoinType) => + makeOperation("addr", [node, BigInt(coinType)]), + ), + ...(selection.texts ?? []).map((key: string) => makeOperation("text", [node, key])), + ...(selection.interfaces ?? []).map((id: InterfaceId) => + makeOperation("interfaceImplementer", [node, id]), + ), + ].filter((op): op is Exclude => !!op); +} + +/** + * Whether an Operation has been resolved. `result === undefined` means unresolved; any other + * value (including `null`) means resolved. + */ +export const isOperationResolved = (op: Operation): boolean => op.result !== undefined; + +/** + * Organizes a set of Operations (resolved or unresolved) for debug logging. + */ +export function tablifyOperations(operations: Operation[]) { + return operations.map((op) => ({ + Call: `.resolve(${op.functionName}, ${op.args.join(", ")})`, + Result: op.result, + })); +} + +/** + * Pretty-prints Operations in dev (console.table) and structured-logs them in production. + */ +export function logOperations( + operations: Operation[], + logger: { debug: (obj: unknown) => void }, +): void { + if (process.env.NODE_ENV !== "production") { + console.table(tablifyOperations(operations)); + } else { + logger.debug({ operations }); + } +} diff --git a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts b/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts deleted file mode 100644 index 34ecba37f..000000000 --- a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { trace } from "@opentelemetry/api"; -import { type Address, asLiteralName, type Name, type Node } from "enssdk"; -import { - ContractFunctionExecutionError, - decodeAbiParameters, - encodeFunctionData, - getAbiItem, - type PublicClient, - size, - toHex, -} from "viem"; -import { packetToBytes } from "viem/ens"; - -import { ResolverABI } from "@ensnode/datasources"; -import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; -import { - interpretAddressRecordValue, - interpretNameRecordValue, - interpretTextRecordValue, -} from "@ensnode/ensnode-sdk/internal"; - -import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; - -const tracer = trace.getTracer("resolve-calls-and-results"); - -/** - * Represents a set of eth_call arguments to a Resolver - */ -export type ResolveCalls = ReturnType< - typeof makeResolveCalls ->; - -/** - * Represents a set of eth_calls to a Resolver and their _raw_ results from the rpc. - * - * NOTE: using conditional branches to support future calls that may not return string - */ -export type ResolveCallsAndRawResults = Array<{ - call: ResolveCalls[number]; - result: ResolveCalls[number] extends { functionName: infer FN } - ? FN extends "name" - ? string | null - : FN extends "addr" - ? string | null - : FN extends "text" - ? string | null - : unknown - : unknown; -}>; - -/** - * Represents a set of eth_calls to a Resolver and their (semantically interpreted) results. - * - * NOTE: using conditional branches to support future calls that may not result in string | null - */ -export type ResolveCallsAndResults = Array<{ - call: ResolveCalls[number]; - result: ResolveCalls[number] extends { functionName: infer FN } - ? FN extends "name" - ? string | null - : FN extends "addr" - ? string | null - : FN extends "text" - ? string | null - : unknown - : unknown; -}>; - -// builds an array of calls from a ResolverRecordsSelection -export function makeResolveCalls( - node: Node, - selection: SELECTION, -) { - return [ - selection.name && ({ functionName: "name", args: [node] } as const), - ...(selection.addresses ?? []).map( - (coinType) => - ({ - functionName: "addr", - args: [node, BigInt(coinType)], - }) as const, - ), - ...(selection.texts ?? []).map( - (key) => - ({ - functionName: "text", - args: [node, key], - }) as const, - ), - ].filter( - // filter out falsy values, excluding them from the inferred type - (call): call is Exclude => !!call, - ); -} - -/** - * Execute a set of ResolveCalls against the provided `resolverAddress`. - * - * @dev viem#readContract implements CCIP-Read, so we get that behavior for free - * TODO: CCIP-Read Gateways can fail, should likely implement retries? - */ -export async function executeResolveCalls({ - name, - resolverAddress, - useENSIP10Resolve, - calls, - publicClient, -}: { - name: Name; - resolverAddress: Address; - useENSIP10Resolve: boolean; - calls: ResolveCalls; - publicClient: PublicClient; -}): Promise> { - return withActiveSpanAsync(tracer, "executeResolveCalls", { name }, async (span) => { - const ResolverContract = { abi: ResolverABI, address: resolverAddress } as const; - - // NOTE: automatically multicalled by viem - return await Promise.all( - calls.map(async (call) => { - try { - // NOTE: ENSIP-10 — If extended resolver, resolver.resolve(name, data) - if (useENSIP10Resolve) { - return await withSpanAsync( - tracer, - `resolve(${call.functionName}, ${call.args})`, - {}, - async (span) => { - const encodedName = toHex(packetToBytes(name)); // DNS-encode `name` for resolve() - const encodedMethod = encodeFunctionData({ abi: ResolverABI, ...call }); - - span.setAttribute("encodedName", encodedName); - span.setAttribute("encodedMethod", encodedMethod); - - const value = await publicClient.readContract({ - ...ResolverContract, - functionName: "resolve", - args: [encodedName, encodedMethod], - }); - - // if resolve() returned empty bytes or reverted, coalece to null - if (size(value) === 0) { - span.setAttribute("result", "null"); - return { call, result: null, reason: "returned empty response" }; - } - - // ENSIP-10 — resolve() always returns bytes that need to be decoded - const results = decodeAbiParameters( - getAbiItem({ abi: ResolverABI, name: call.functionName, args: call.args }) - .outputs, - value, - ); - - // NOTE: results is type-guaranteed to have at least 1 result (because each abi item's outputs.length >= 1) - const result = results[0]; - - span.setAttribute("result", result); - - return { - call, - result: result, - reason: `.resolve(${call.functionName}, ${call.args})`, - }; - }, - ); - } - - // if not extended resolver, resolve directly - return withSpanAsync(tracer, `${call.functionName}(${call.args})`, {}, async () => { - // NOTE: discrimminate against the `functionName` type, otherwise typescript complains about - // `call` not matching the expected types of the `readContract` arguments. also helpfully - // infers the return type of `readContract` matches the result type of each `call` - switch (call.functionName) { - case "name": - return { - call, - result: await publicClient.readContract({ ...ResolverContract, ...call }), - reason: `.name(${call.args})`, - }; - case "addr": - return { - call, - result: await publicClient.readContract({ ...ResolverContract, ...call }), - reason: `.addr(${call.args})`, - }; - case "text": - return { - call, - result: await publicClient.readContract({ ...ResolverContract, ...call }), - reason: `.text(${call.args})`, - }; - } - }); - } catch (error) { - // in general, reverts are expected behavior - if (error instanceof ContractFunctionExecutionError) { - return { call, result: null, reason: error.shortMessage }; - } - - // log the error if it wasn't a ContractFunctionExecutionError - if (error instanceof Error) span.recordException(error); - - // then rethrow - throw error; - } - }), - ); - }); -} - -/** - * Interprets the raw rpc results into more application-specific semantic values. - * - * See interpret-record-values.ts for additional context. - */ -export function interpretRawCallsAndResults( - callsAndRawResults: ResolveCallsAndRawResults, -): ResolveCallsAndResults { - return callsAndRawResults.map(({ call, result }) => { - // pass along null results, nothing to do - if (result === null) return { call, result }; - - switch (call.functionName) { - case "addr": { - // interpret address records (see `interpretAddressRecordValue` for specific guarantees) - return { call, result: interpretAddressRecordValue(result) }; - } - case "name": { - // interpret name records (see `interpretNameRecordValue` for specific guarantees) - return { call, result: interpretNameRecordValue(asLiteralName(result)) }; - } - case "text": { - // interpret text records (see `interpretTextRecordValue` for specific guarantees) - return { call, result: interpretTextRecordValue(result) }; - } - default: { - // note: switch is exhaustive, but this makes biome happier about map always returning - return { call, result }; - } - } - }); -} - -/** - * Organizes the results of executing and interpreting resolve calls, for debug logging. - */ -export function tablifyCallResults( - rawResults: ResolveCallsAndRawResults, - results: ResolveCallsAndResults, -) { - return rawResults.map(({ call, result: rawResult }, i) => { - const interpreted = results[i].result; - - return { - Call: `.resolve(${call.functionName}, ${call.args.join(", ")})`, - "Raw Result": rawResult, - Result: interpreted, - }; - }); -} diff --git a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts deleted file mode 100644 index 0965ce984..000000000 --- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts +++ /dev/null @@ -1,96 +0,0 @@ -import config from "@/config"; - -import type { InterpretedName } from "enssdk"; -import { - bytesToHex, - ContractFunctionExecutionError, - decodeAbiParameters, - encodeFunctionData, - getAbiItem, - type PublicClient, - size, -} from "viem"; -import { packetToBytes } from "viem/ens"; - -import { DatasourceNames, ResolverABI, UniversalResolverABI } from "@ensnode/datasources"; -import { - getDatasourceContract, - maybeGetDatasourceContract, - type ResolverRecordsSelection, -} from "@ensnode/ensnode-sdk"; - -import { lazy } from "@/lib/lazy"; -import type { - ResolveCalls, - ResolveCallsAndRawResults, -} from "@/lib/resolution/resolve-calls-and-results"; - -const getUniversalResolverV1 = lazy(() => - getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), -); - -const getUniversalResolverV2 = lazy(() => - maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"), -); - -/** - * Execute a set of ResolveCalls for `name` against the UniversalResolver. - */ -export async function executeResolveCallsWithUniversalResolver< - SELECTION extends ResolverRecordsSelection, ->({ - name, - calls, - publicClient, -}: { - name: InterpretedName; - calls: ResolveCalls; - publicClient: PublicClient; -}): Promise> { - // NOTE: automatically multicalled by viem - return await Promise.all( - calls.map(async (call) => { - try { - const encodedName = bytesToHex(packetToBytes(name)); // DNS-encode `name` for resolve() - const encodedMethod = encodeFunctionData({ abi: ResolverABI, ...call }); - - const [value] = await publicClient.readContract({ - abi: UniversalResolverABI, - // NOTE(ensv2-transition): if UniversalResolverV2 is defined, prefer it over UniversalResolver - // TODO(ensv2-transition): confirm this is correct - address: getUniversalResolverV2()?.address ?? getUniversalResolverV1().address, - functionName: "resolve", - args: [encodedName, encodedMethod], - }); - - // if resolve() returned empty bytes or reverted, coalece to null - if (size(value) === 0) { - return { call, result: null, reason: "returned empty response" }; - } - - // ENSIP-10 — resolve() always returns bytes that need to be decoded - const results = decodeAbiParameters( - getAbiItem({ abi: ResolverABI, name: call.functionName, args: call.args }).outputs, - value, - ); - - // NOTE: results is type-guaranteed to have at least 1 result (because each abi item's outputs.length >= 1) - const result = results[0]; - - return { - call, - result: result, - reason: `.resolve(${call.functionName}, ${call.args})`, - }; - } catch (error) { - // in general, reverts are expected behavior - if (error instanceof ContractFunctionExecutionError) { - return { call, result: null, reason: error.shortMessage }; - } - - // otherwise, rethrow - throw error; - } - }), - ); -} diff --git a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts index a43d4d19c..75264a9e3 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts @@ -1,7 +1,7 @@ import { - type AccountId, type Address, type CoinType, + type Hex, type LiteralName, makeResolverId, makeResolverRecordsId, @@ -10,11 +10,15 @@ import { import { interpretAddressRecordValue, + interpretContenthashValue, + interpretDnszonehashValue, interpretNameRecordValue, + interpretPubkeyValue, interpretTextRecordKey, interpretTextRecordValue, } from "@ensnode/ensnode-sdk/internal"; +import { getThisAccountId } from "@/lib/get-this-account-id"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; import type { EventWithArgs } from "@/lib/ponder-helpers"; @@ -27,54 +31,27 @@ type ResolverRecordsCompositeKey = Pick< >; /** - * Constructs a ResolverRecordsCompositeKey from a provided Resolver event. - * - * @returns ResolverRecordsCompositeKey + * Ensures the Resolver + ResolverRecords entities exist for the given Resolver event, and returns + * the ResolverRecords key for further per-record updates. */ -export function makeResolverRecordsCompositeKey( - resolver: AccountId, +export async function ensureResolverAndRecords( + context: IndexingEngineContext, event: EventWithArgs<{ node: Node }>, -): ResolverRecordsCompositeKey { - return { - ...resolver, - node: event.args.node, - }; -} +): Promise { + const resolver = getThisAccountId(context, event); + const key: ResolverRecordsCompositeKey = { ...resolver, node: event.args.node }; -/** - * Ensures that the Resolver contract described by `resolver` exists. - */ -export async function ensureResolver(context: IndexingEngineContext, resolver: AccountId) { await context.ensDb .insert(ensIndexerSchema.resolver) - .values({ - id: makeResolverId(resolver), - ...resolver, - }) + .values({ id: makeResolverId(resolver), ...resolver }) .onConflictDoNothing(); -} -/** - * Ensures that the ResolverRecords entity described by `resolverRecordsKey` exists. - */ -export async function ensureResolverRecords( - context: IndexingEngineContext, - resolverRecordsKey: ResolverRecordsCompositeKey, -) { - const resolver: AccountId = { - chainId: resolverRecordsKey.chainId, - address: resolverRecordsKey.address, - }; - const resolverRecordsId = makeResolverRecordsId(resolver, resolverRecordsKey.node); - - // ensure ResolverRecords await context.ensDb .insert(ensIndexerSchema.resolverRecords) - .values({ - id: resolverRecordsId, - ...resolverRecordsKey, - }) + .values({ id: makeResolverRecordsId(resolver, event.args.node), ...key }) .onConflictDoNothing(); + + return key; } /** @@ -159,3 +136,61 @@ export async function handleResolverTextRecordUpdate( .onConflictDoUpdate({ value: interpretedValue }); } } + +/** + * Updates the `contenthash` record value for the ResolverRecords described by `resolverRecordsKey`. + */ +export async function handleResolverContenthashUpdate( + context: IndexingEngineContext, + resolverRecordsKey: ResolverRecordsCompositeKey, + rawHash: Hex, +) { + const id = makeResolverRecordsId( + { chainId: resolverRecordsKey.chainId, address: resolverRecordsKey.address }, + resolverRecordsKey.node, + ); + + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ contenthash: interpretContenthashValue(rawHash) }); +} + +/** + * Updates the PubkeyResolver (x, y) pair for the ResolverRecords described by `resolverRecordsKey`. + */ +export async function handleResolverPubkeyUpdate( + context: IndexingEngineContext, + resolverRecordsKey: ResolverRecordsCompositeKey, + x: Hex, + y: Hex, +) { + const id = makeResolverRecordsId( + { chainId: resolverRecordsKey.chainId, address: resolverRecordsKey.address }, + resolverRecordsKey.node, + ); + + const pubkey = interpretPubkeyValue(x, y); + + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ pubkeyX: pubkey?.x ?? null, pubkeyY: pubkey?.y ?? null }); +} + +/** + * Updates the IDNSZoneResolver `zonehash` record value for the ResolverRecords described + * by `resolverRecordsKey`. + */ +export async function handleResolverDnszonehashUpdate( + context: IndexingEngineContext, + resolverRecordsKey: ResolverRecordsCompositeKey, + rawHash: Hex, +) { + const id = makeResolverRecordsId( + { chainId: resolverRecordsKey.chainId, address: resolverRecordsKey.address }, + resolverRecordsKey.node, + ); + + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ dnszonehash: interpretDnszonehashValue(rawHash) }); +} diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index 21bcf0439..a3be205f2 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -1,19 +1,26 @@ -import { asLiteralName, bigintToCoinType, type CoinType, ETH_COIN_TYPE } from "enssdk"; +import { and, eq } from "drizzle-orm"; +import { + asLiteralName, + bigintToCoinType, + type CoinType, + ETH_COIN_TYPE, + makeResolverRecordsId, +} from "enssdk"; import { ResolverABI } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; import { parseDnsTxtRecordArgs } from "@/lib/dns-helpers"; -import { getThisAccountId } from "@/lib/get-this-account-id"; -import { addOnchainEventListener } from "@/lib/indexing-engines/ponder"; +import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engines/ponder"; import { namespaceContract } from "@/lib/plugin-helpers"; import { - ensureResolver, - ensureResolverRecords, + ensureResolverAndRecords, handleResolverAddressRecordUpdate, + handleResolverContenthashUpdate, + handleResolverDnszonehashUpdate, handleResolverNameUpdate, + handleResolverPubkeyUpdate, handleResolverTextRecordUpdate, - makeResolverRecordsCompositeKey, } from "@/lib/protocol-acceleration/resolver-db-helpers"; const pluginName = PluginName.ProtocolAcceleration; @@ -26,15 +33,9 @@ export default function () { addOnchainEventListener( namespaceContract(pluginName, "Resolver:AddrChanged"), async ({ context, event }) => { - const { a: address } = event.args; - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - // the Resolver#AddrChanged event is just Resolver#AddressChanged with implicit coinType of ETH - await handleResolverAddressRecordUpdate(context, resolverRecordsKey, ETH_COIN_TYPE, address); + const key = await ensureResolverAndRecords(context, event); + // Resolver#AddrChanged is Resolver#AddressChanged with implicit coinType ETH + await handleResolverAddressRecordUpdate(context, key, ETH_COIN_TYPE, event.args.a); }, ); @@ -51,28 +52,16 @@ export default function () { return; // ignore if bigint can't be coerced to known CoinType } - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverAddressRecordUpdate(context, resolverRecordsKey, coinType, newAddress); + const key = await ensureResolverAndRecords(context, event); + await handleResolverAddressRecordUpdate(context, key, coinType, newAddress); }, ); addOnchainEventListener( namespaceContract(pluginName, "Resolver:NameChanged"), async ({ context, event }) => { - const name = asLiteralName(event.args.name); - - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverNameUpdate(context, resolverRecordsKey, name); + const key = await ensureResolverAndRecords(context, event); + await handleResolverNameUpdate(context, key, asLiteralName(event.args.name)); }, ); @@ -82,11 +71,10 @@ export default function () { "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key)", ), async ({ context, event }) => { - const { node, key } = event.args; + const { node, key: textKey } = event.args; // this is a LegacyPublicResolver (DefaultPublicResolver3) event which does not emit `value`, // so we fetch it here if possible - // default record value as 'null' which will be interpreted as deletion/non-existence of record let value: string | null = null; try { @@ -94,17 +82,12 @@ export default function () { abi: ResolverABI, address: event.log.address, functionName: "text", - args: [node, key], + args: [node, textKey], }); } catch {} // no-op if readContract throws for whatever reason - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, value); }, ); @@ -114,15 +97,9 @@ export default function () { "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)", ), async ({ context, event }) => { - const { key, value } = event.args; - - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); + const { key: textKey, value } = event.args; + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, value); }, ); @@ -134,16 +111,11 @@ export default function () { "Resolver:DNSRecordChanged(bytes32 indexed node, bytes name, uint16 resource, bytes record)", ), async ({ context, event }) => { - const { key, value } = parseDnsTxtRecordArgs(event.args); - if (key === null) return; // no key to operate over? args were malformed, ignore event + const { key: textKey, value } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, value); }, ); @@ -154,32 +126,96 @@ export default function () { "Resolver:DNSRecordChanged(bytes32 indexed node, bytes name, uint16 resource, uint32 ttl, bytes record)", ), async ({ context, event }) => { - const { key, value } = parseDnsTxtRecordArgs(event.args); - if (key === null) return; // no key to operate over? args were malformed, ignore event + const { key: textKey, value } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, value); + }, + ); - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:DNSRecordDeleted"), + async ({ context, event }) => { + const { key: textKey } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, null); }, ); + // NOTE: ABIChanged and InterfaceChanged are intentionally NOT registered. + // - ABIChanged event omits data (would require a follow-up readContract per event). + // - InterfaceChanged has an ERC-165 fallback that cannot be replicated offline. + // Both remain selectable via the Resolution API but are always resolved via RPC. + // Protocol Acceleration support can be added as desired. + addOnchainEventListener( - namespaceContract(pluginName, "Resolver:DNSRecordDeleted"), + namespaceContract(pluginName, "Resolver:ContenthashChanged"), async ({ context, event }) => { - const { key } = parseDnsTxtRecordArgs(event.args); - if (key === null) return; // no key to operate over? args were malformed, ignore event + const key = await ensureResolverAndRecords(context, event); + await handleResolverContenthashUpdate(context, key, event.args.hash); + }, + ); - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:PubkeyChanged"), + async ({ context, event }) => { + const { x, y } = event.args; + const key = await ensureResolverAndRecords(context, event); + await handleResolverPubkeyUpdate(context, key, x, y); + }, + ); - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:DNSZonehashChanged"), + async ({ context, event }) => { + const key = await ensureResolverAndRecords(context, event); + await handleResolverDnszonehashUpdate(context, key, event.args.zonehash); + }, + ); - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, null); + // IVersionableResolver VersionChanged: delete all child records for (chainId, address, node) + // and reset scalar columns. Uses raw drizzle via `context.ensDb.sql` to bulk-delete — + // this flushes ponder's in-memory cache to Postgres, accepted because VersionChanged is rare. + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:VersionChanged"), + async ({ context, event }) => { + const { chainId, address, node } = await ensureResolverAndRecords(context, event); + + await context.ensDb.sql + .delete(ensIndexerSchema.resolverAddressRecord) + .where( + and( + eq(ensIndexerSchema.resolverAddressRecord.chainId, chainId), + eq(ensIndexerSchema.resolverAddressRecord.address, address), + eq(ensIndexerSchema.resolverAddressRecord.node, node), + ), + ); + + await context.ensDb.sql + .delete(ensIndexerSchema.resolverTextRecord) + .where( + and( + eq(ensIndexerSchema.resolverTextRecord.chainId, chainId), + eq(ensIndexerSchema.resolverTextRecord.address, address), + eq(ensIndexerSchema.resolverTextRecord.node, node), + ), + ); + + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { + id: makeResolverRecordsId({ chainId, address }, node), + }) + .set({ + name: null, + contenthash: null, + pubkeyX: null, + pubkeyY: null, + dnszonehash: null, + version: event.args.newVersion, + }); }, ); } diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts index ec1394616..2f51f905d 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts @@ -1,6 +1,6 @@ import config from "@/config"; -import type { Hex, Node, NormalizedAddress } from "enssdk"; +import type { Hex, InterfaceId, Node, NormalizedAddress, RecordVersion } from "enssdk"; import type { Hash } from "viem"; import { hasNullByte, stripNullBytes, uniq } from "@ensnode/ensnode-sdk"; @@ -239,7 +239,7 @@ export async function handleInterfaceChanged({ event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ node: Node; interfaceID: Hex; implementer: Hex }>; + event: EventWithArgs<{ node: Node; interfaceID: InterfaceId; implementer: Hex }>; }) { const { node, interfaceID, implementer } = event.args; const id = makeResolverId(context.chain.id, event.log.address, node); @@ -295,7 +295,7 @@ export async function handleVersionChanged({ event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ node: Node; newVersion: bigint }>; + event: EventWithArgs<{ node: Node; newVersion: RecordVersion }>; }) { const { node, newVersion } = event.args; const id = makeResolverId(context.chain.id, event.log.address, node); @@ -441,7 +441,7 @@ export async function handleZoneCreated({ event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ node: Node; version: bigint }>; + event: EventWithArgs<{ node: Node; version: RecordVersion }>; }) { // explicitly ignored / not implemented } diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 1e73071d5..e4496ea1a 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -1092,6 +1092,32 @@ { "schema": { "type": "boolean" }, "required": false, "name": "name", "in": "query" }, { "schema": { "type": "string" }, "required": false, "name": "addresses", "in": "query" }, { "schema": { "type": "string" }, "required": false, "name": "texts", "in": "query" }, + { + "schema": { "type": "boolean" }, + "required": false, + "name": "contenthash", + "in": "query" + }, + { "schema": { "type": "boolean" }, "required": false, "name": "pubkey", "in": "query" }, + { + "schema": { "type": "boolean" }, + "required": false, + "name": "dnszonehash", + "in": "query" + }, + { "schema": { "type": "boolean" }, "required": false, "name": "version", "in": "query" }, + { + "schema": { "type": "string", "example": "1" }, + "required": false, + "name": "abi", + "in": "query" + }, + { + "schema": { "type": "string" }, + "required": false, + "name": "interfaces", + "in": "query" + }, { "schema": { "type": "boolean", @@ -1134,6 +1160,26 @@ "texts": { "type": "object", "additionalProperties": { "type": ["string", "null"] } + }, + "contenthash": { "type": ["string", "null"] }, + "pubkey": { + "type": ["object", "null"], + "properties": { "x": { "type": "string" }, "y": { "type": "string" } }, + "required": ["x", "y"] + }, + "dnszonehash": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] }, + "abi": { + "type": ["object", "null"], + "properties": { + "contentType": { "type": "string" }, + "data": { "type": "string" } + }, + "required": ["contentType", "data"] + }, + "interfaces": { + "type": "object", + "additionalProperties": { "type": ["string", "null"] } } } }, diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts index a1b335f1b..ff2f6c335 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts @@ -8,6 +8,7 @@ import type { DomainId, InterpretedName, Node, + RecordVersion, ResolverId, ResolverRecordsId, } from "enssdk"; @@ -131,6 +132,31 @@ export const resolverRecords = onchainTable( * If present, the value of this field is guaranteed to be a non-empty {@link InterpretedName}. */ name: t.text().$type(), + + /** + * ENSIP-7 contenthash raw bytes or null if not set. + */ + contenthash: t.hex(), + + /** + * PubkeyResolver (x, y) pair, or null if not set. + * + * Invariant: both null together, or both set together. + */ + pubkeyX: t.hex(), + pubkeyY: t.hex(), + + /** + * IDNSZoneResolver zonehash or null if not set. + */ + dnszonehash: t.hex(), + + /** + * IVersionableResolver version. Null when no `VersionChanged` event has been seen for this + * (chainId, address, node) — the resolver may not implement `IVersionableResolver`, or simply + * may never have been version-bumped. Consumers should treat null as "unknown" rather than 0. + */ + version: t.bigint().$type(), }), (t) => ({ byId: uniqueIndex().on(t.chainId, t.address, t.node), diff --git a/packages/ensdb-sdk/src/lib/drizzle.ts b/packages/ensdb-sdk/src/lib/drizzle.ts index 3431660c9..9629a3492 100644 --- a/packages/ensdb-sdk/src/lib/drizzle.ts +++ b/packages/ensdb-sdk/src/lib/drizzle.ts @@ -193,6 +193,8 @@ function safeStringifyDrizzleSchema(schema: Record): string { const seen = new WeakSet(); return JSON.stringify(schema, (_key, value) => { + if (typeof value === "bigint") return `${value}n`; + if (typeof value === "object" && value !== null) { if (seen.has(value)) return "[circular]"; seen.add(value); diff --git a/packages/enskit/src/react/omnigraph/provider.tsx b/packages/enskit/src/react/omnigraph/provider.tsx index 49cd7c9b7..2012cd323 100644 --- a/packages/enskit/src/react/omnigraph/provider.tsx +++ b/packages/enskit/src/react/omnigraph/provider.tsx @@ -1,7 +1,7 @@ "use client"; import type { EnsNodeClient } from "enssdk/core"; -import { createElement, type ReactNode, useMemo } from "react"; +import { createElement, type ReactElement, type ReactNode, useMemo } from "react"; import { Provider } from "urql"; import { createOmnigraphUrqlClient } from "./client"; @@ -11,7 +11,7 @@ export interface OmnigraphProviderProps { children?: ReactNode; } -export function OmnigraphProvider({ client, children }: OmnigraphProviderProps) { +export function OmnigraphProvider({ client, children }: OmnigraphProviderProps): ReactElement { const urqlClient = useMemo(() => createOmnigraphUrqlClient(client.config), [client]); return createElement(Provider, { value: urqlClient }, children); diff --git a/packages/ensnode-sdk/src/ensnode/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/ensnode/api/resolution/zod-schemas.ts index e3efbec67..812de2d7d 100644 --- a/packages/ensnode-sdk/src/ensnode/api/resolution/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensnode/api/resolution/zod-schemas.ts @@ -1,13 +1,23 @@ import { z } from "zod/v4"; /** - * Schema for resolver records response (addresses, texts, name) + * Schema for resolver records response. + * + * NOTE: `version` and `abi.contentType` arrive over the wire as strings (server serializes + * bigints via `replaceBigInts(response, String)` to avoid `JSON.stringify` throwing on bigint). + * Callers that need these as `bigint` should `BigInt(...)` them. */ const makeResolverRecordsResponseSchema = () => z.object({ name: z.string().nullable().optional(), addresses: z.record(z.string(), z.string().nullable()).optional(), texts: z.record(z.string(), z.string().nullable()).optional(), + contenthash: z.string().nullable().optional(), + pubkey: z.object({ x: z.string(), y: z.string() }).nullable().optional(), + dnszonehash: z.string().nullable().optional(), + version: z.string().nullable().optional(), + abi: z.object({ contentType: z.string(), data: z.string() }).nullable().optional(), + interfaces: z.record(z.string(), z.string().nullable()).optional(), }); /** diff --git a/packages/ensnode-sdk/src/ensnode/client.ts b/packages/ensnode-sdk/src/ensnode/client.ts index eccef4fed..696241355 100644 --- a/packages/ensnode-sdk/src/ensnode/client.ts +++ b/packages/ensnode-sdk/src/ensnode/client.ts @@ -151,9 +151,12 @@ export class EnsNodeClient { const url = new URL(`/api/resolve/records/${encodeURIComponent(name)}`, this.options.url); // Add query parameters based on selection - if (selection.name) { - url.searchParams.set("name", "true"); - } + if (selection.name) url.searchParams.set("name", "true"); + if (selection.contenthash) url.searchParams.set("contenthash", "true"); + if (selection.pubkey) url.searchParams.set("pubkey", "true"); + if (selection.dnszonehash) url.searchParams.set("dnszonehash", "true"); + if (selection.version) url.searchParams.set("version", "true"); + if (selection.abi !== undefined) url.searchParams.set("abi", selection.abi.toString()); if (selection.addresses && selection.addresses.length > 0) { url.searchParams.set("addresses", selection.addresses.join(",")); @@ -163,6 +166,10 @@ export class EnsNodeClient { url.searchParams.set("texts", selection.texts.join(",")); } + if (selection.interfaces && selection.interfaces.length > 0) { + url.searchParams.set("interfaces", selection.interfaces.join(",")); + } + if (options?.trace) url.searchParams.set("trace", "true"); if (options?.accelerate) url.searchParams.set("accelerate", "true"); @@ -173,8 +180,20 @@ export class EnsNodeClient { throw ClientError.fromErrorResponse(error); } - const data = await response.json(); - return data as ResolveRecordsResponse; + const data = (await response.json()) as ResolveRecordsResponse; + + // server serializes bigints as strings to keep the wire plain JSON — coerce back here so + // `version` and `abi.contentType` match their SDK `bigint` types. + const records = data.records as { + version?: unknown; + abi?: { contentType: unknown; data: string } | null; + }; + if (typeof records.version === "string") records.version = BigInt(records.version); + if (records.abi && typeof records.abi.contentType === "string") { + records.abi.contentType = BigInt(records.abi.contentType); + } + + return data; } /** diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 1e6dff895..01743d237 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -40,7 +40,9 @@ export * from "./shared/config-templates"; export * from "./shared/datasources-with-ensv2-contracts"; export * from "./shared/datasources-with-resolvers"; export * from "./shared/devnet-accounts"; +export * from "./shared/interpretation/interpret-address"; export * from "./shared/interpretation/interpret-record-values"; +export * from "./shared/interpretation/interpret-resolver-values"; export * from "./shared/log-level"; export * from "./shared/protocol-acceleration/is-bridged-resolver"; export * from "./shared/protocol-acceleration/is-ensip-19-reverse-resolver"; diff --git a/packages/ensnode-sdk/src/resolution/resolver-records-response.ts b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts index 99577b5d8..e0d0d77f7 100644 --- a/packages/ensnode-sdk/src/resolution/resolver-records-response.ts +++ b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts @@ -1,4 +1,4 @@ -import type { CoinType, Name } from "enssdk"; +import type { Address, CoinType, ContentType, Hex, InterfaceId, Name, RecordVersion } from "enssdk"; import type { ResolverRecordsSelection } from "./resolver-records-selection"; @@ -29,6 +29,41 @@ export type ResolverRecordsResponseBase = { * Value is null if no record for the specified key is set. */ texts: Record; + + /** + * The ENSIP-7 contenthash record raw bytes, or null if not set. + */ + contenthash: Hex | null; + + /** + * The PubkeyResolver (x, y) pair, or null if not set. + */ + pubkey: { x: Hex; y: Hex } | null; + + /** + * The first stored ABI matching the requested content-type bitmask, or null if no ABI is set + * for any matching content type. + */ + abi: { contentType: ContentType; data: Hex } | null; + + /** + * Interface implementers keyed by InterfaceId. + * Value is null if no implementer is set for the given InterfaceId. + */ + interfaces: Record; + + /** + * The IDNSZoneResolver zonehash raw bytes, or null if not set. + */ + dnszonehash: Hex | null; + + /** + * The IVersionableResolver version. Null when we don't have a value — the resolver may not + * implement `IVersionableResolver` (RPC revert), or (on the accelerated path) no + * `VersionChanged` event has ever been seen for this node. `0n` is only returned when the + * resolver explicitly emitted `VersionChanged(node, 0)`. + */ + version: RecordVersion | null; }; /** @@ -54,12 +89,17 @@ export type ResolverRecordsResponseBase = { */ export type ResolverRecordsResponse = { - [K in keyof T as T[K] extends true | any[] ? K : never]: K extends "addresses" + [K in keyof T as T[K] extends true | readonly any[] | bigint ? K : never]: K extends "addresses" ? Record< `${T["addresses"] extends readonly CoinType[] ? T["addresses"][number] : never}`, string | null > : K extends "texts" ? Record - : ResolverRecordsResponseBase[K & keyof ResolverRecordsResponseBase]; + : K extends "interfaces" + ? Record< + T["interfaces"] extends readonly InterfaceId[] ? T["interfaces"][number] : never, + Address | null + > + : ResolverRecordsResponseBase[K & keyof ResolverRecordsResponseBase]; }; diff --git a/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts b/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts index ffe216c0c..9059a883b 100644 --- a/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts +++ b/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts @@ -1,4 +1,4 @@ -import type { CoinType } from "enssdk"; +import type { CoinType, ContentType, InterfaceId } from "enssdk"; /** * Encodes a selection of Resolver records in the context of a specific Name. @@ -22,8 +22,45 @@ export interface ResolverRecordsSelection { */ texts?: string[]; - // TODO: include others as/if necessary + /** + * Whether to fetch the ENSIP-7 contenthash record. + */ + contenthash?: boolean; + + /** + * Whether to fetch the PubkeyResolver (x, y) pair. + */ + pubkey?: boolean; + + /** + * Which ABI content-type bitmask to fetch. The resolver returns the first stored ABI whose + * bit is present in the mask (lowest bit first). + */ + abi?: ContentType; + + /** + * Which ERC-165 interface implementers to fetch, keyed by InterfaceId. + */ + interfaces?: InterfaceId[]; + + /** + * Whether to fetch the IDNSZoneResolver zonehash record. + */ + dnszonehash?: boolean; + + /** + * Whether to fetch the IVersionableResolver version. + */ + version?: boolean; } export const isSelectionEmpty = (selection: ResolverRecordsSelection) => - !selection.name && !selection.addresses?.length && !selection.texts?.length; + !selection.name && + !selection.addresses?.length && + !selection.texts?.length && + !selection.contenthash && + !selection.pubkey && + !selection.dnszonehash && + selection.abi === undefined && + !selection.interfaces?.length && + !selection.version; diff --git a/packages/ensnode-sdk/src/rpc/eip-165.ts b/packages/ensnode-sdk/src/rpc/eip-165.ts index c1698c98b..8a34724b9 100644 --- a/packages/ensnode-sdk/src/rpc/eip-165.ts +++ b/packages/ensnode-sdk/src/rpc/eip-165.ts @@ -1,4 +1,4 @@ -import type { Address, Hex } from "enssdk"; +import type { Address, InterfaceId } from "enssdk"; /** * EIP-165 ABI @@ -37,7 +37,7 @@ async function supportsInterface< abi: typeof EIP_165_ABI; functionName: "supportsInterface"; address: Address; - args: readonly [Hex]; + args: readonly [InterfaceId]; }) => Promise; }, >({ @@ -46,7 +46,7 @@ async function supportsInterface< address, }: { address: Address; - interfaceId: Hex; + interfaceId: InterfaceId; publicClient: TClient; }) { try { @@ -63,7 +63,8 @@ async function supportsInterface< } export const makeSupportsInterfaceReader = - (interfaceId: Hex) => (args: Omit[0], "interfaceId">) => + (interfaceId: InterfaceId) => + (args: Omit[0], "interfaceId">) => supportsInterface({ ...args, interfaceId, diff --git a/packages/ensnode-sdk/src/shared/interpretation/index.ts b/packages/ensnode-sdk/src/shared/interpretation/index.ts index 209a403b3..94807fe0d 100644 --- a/packages/ensnode-sdk/src/shared/interpretation/index.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/index.ts @@ -1,2 +1,3 @@ export * from "./interpret-address"; export * from "./interpret-record-values"; +export * from "./interpret-resolver-values"; diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.test.ts b/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.test.ts new file mode 100644 index 000000000..a84817f9f --- /dev/null +++ b/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.test.ts @@ -0,0 +1,48 @@ +import type { Hex } from "enssdk"; +import { zeroHash } from "viem"; +import { describe, expect, it } from "vitest"; + +import { + interpretContenthashValue, + interpretDnszonehashValue, + interpretPubkeyValue, +} from "./interpret-resolver-values"; + +describe("interpretContenthashValue", () => { + it("returns null for empty bytes sentinel", () => { + expect(interpretContenthashValue("0x")).toBeNull(); + }); + + it("returns the raw hex for a non-empty value", () => { + expect(interpretContenthashValue("0xdeadbeef" as Hex)).toBe("0xdeadbeef"); + }); +}); + +describe("interpretDnszonehashValue", () => { + it("returns null for empty bytes sentinel", () => { + expect(interpretDnszonehashValue("0x")).toBeNull(); + }); + + it("returns the raw hex for a non-empty value", () => { + expect(interpretDnszonehashValue("0xcafe" as Hex)).toBe("0xcafe"); + }); +}); + +describe("interpretPubkeyValue", () => { + it("returns null when both x and y are zeroHash", () => { + expect(interpretPubkeyValue(zeroHash, zeroHash)).toBeNull(); + }); + + it("returns { x, y } when both are set", () => { + const x = `0x${"11".repeat(32)}` as Hex; + const y = `0x${"22".repeat(32)}` as Hex; + expect(interpretPubkeyValue(x, y)).toEqual({ x, y }); + }); + + it("treats only-x-set as a present value (not a deletion)", () => { + const x = `0x${"11".repeat(32)}` as Hex; + // Invariant guards this case on the write side; the interpreter itself preserves whatever + // it's given so long as it's not the full (zeroHash, zeroHash) sentinel. + expect(interpretPubkeyValue(x, zeroHash)).toEqual({ x, y: zeroHash }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.ts b/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.ts new file mode 100644 index 000000000..4316b30e2 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.ts @@ -0,0 +1,28 @@ +import type { Hex } from "enssdk"; +import { size, zeroHash } from "viem"; + +/** + * Interprets an ENSIP-7 contenthash value. Empty bytes are interpreted as deletion. + */ +export function interpretContenthashValue(value: Hex): Hex | null { + if (size(value) === 0) return null; + return value; +} + +/** + * Interprets a PubkeyResolver (x, y) pair. A (zeroHash, zeroHash) pair is interpreted as deletion. + * + * Invariant: both null together, or both set together. + */ +export function interpretPubkeyValue(x: Hex, y: Hex): { x: Hex; y: Hex } | null { + if (x === zeroHash && y === zeroHash) return null; + return { x, y }; +} + +/** + * Interprets an IDNSZoneResolver zonehash value. Empty bytes are interpreted as deletion. + */ +export function interpretDnszonehashValue(value: Hex): Hex | null { + if (size(value) === 0) return null; + return value; +} diff --git a/packages/enssdk/src/lib/index.ts b/packages/enssdk/src/lib/index.ts index 10a039bac..b7855d70b 100644 --- a/packages/enssdk/src/lib/index.ts +++ b/packages/enssdk/src/lib/index.ts @@ -4,6 +4,7 @@ export * from "./coin-type"; export * from "./constants"; export * from "./dns-encoded-name"; export * from "./ids"; +export * from "./interface-id"; export * from "./interpret-token-id"; export * from "./interpreted-names-and-labels"; export * from "./labelhash"; diff --git a/packages/enssdk/src/lib/interface-id.ts b/packages/enssdk/src/lib/interface-id.ts new file mode 100644 index 000000000..fd0b5deda --- /dev/null +++ b/packages/enssdk/src/lib/interface-id.ts @@ -0,0 +1,10 @@ +import { isHex, size } from "viem"; + +import type { InterfaceId } from "./types"; + +/** + * Whether `maybeInterfaceId` is a valid ERC-165 {@link InterfaceId} — a 4-byte hex selector. + */ +export function isInterfaceId(maybeInterfaceId: string): maybeInterfaceId is InterfaceId { + return isHex(maybeInterfaceId) && size(maybeInterfaceId) === 4; +} diff --git a/packages/enssdk/src/lib/types/index.ts b/packages/enssdk/src/lib/types/index.ts index e4b959b91..220c1e263 100644 --- a/packages/enssdk/src/lib/types/index.ts +++ b/packages/enssdk/src/lib/types/index.ts @@ -3,4 +3,5 @@ export * from "./eac"; export * from "./ens"; export * from "./ensv2"; export * from "./evm"; +export * from "./resolver"; export * from "./shared"; diff --git a/packages/enssdk/src/lib/types/resolver.ts b/packages/enssdk/src/lib/types/resolver.ts new file mode 100644 index 000000000..fa33dd79b --- /dev/null +++ b/packages/enssdk/src/lib/types/resolver.ts @@ -0,0 +1,29 @@ +import type { Hex } from "./evm"; + +/** + * ABI content type per ENSIP-4. + * + * Single-bit values (1=JSON, 2=zlib-JSON, 4=CBOR, 8=URI) identify a stored ABI encoding. + * `setABI` requires a power-of-2 value. + * + * Bitmask unions of those bits are used when reading via `ABI(node, contentTypes)`; the + * resolver returns the first stored ABI whose bit is present in the mask (lowest bit first). + * + * @see https://github.com/ensdomains/ens-contracts/blob/91c966febd7b55494269df830fc6775f040b927b/contracts/resolvers/profiles/ABIResolver.sol + */ +export type ContentType = bigint; + +/** + * ERC-165 4-byte interface selector. + * + * @see https://github.com/ensdomains/ens-contracts/blob/91c966febd7b55494269df830fc6775f040b927b/contracts/resolvers/profiles/InterfaceResolver.sol + */ +export type InterfaceId = Hex; + +/** + * IVersionableResolver record version. Bumped by `VersionChanged`, which invalidates all prior + * records for the node. + * + * @see https://github.com/ensdomains/ens-contracts/blob/91c966febd7b55494269df830fc6775f040b927b/contracts/resolvers/profiles/IVersionableResolver.sol + */ +export type RecordVersion = bigint;