From b0d4b95131f9841de8a77730a3782e131015ad44 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 10:59:50 -0500 Subject: [PATCH 01/18] checkpoint: next i need to edit the spec --- SPEC-resolver-records.md | 562 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 SPEC-resolver-records.md diff --git a/SPEC-resolver-records.md b/SPEC-resolver-records.md new file mode 100644 index 000000000..3ac480ed2 --- /dev/null +++ b/SPEC-resolver-records.md @@ -0,0 +1,562 @@ +# Spec: Extend IResolver Record Types in Protocol Acceleration & Resolution API + +## Scope + +**Indexed and accelerated:** `contenthash`, `pubkey`, `dnszonehash`, `version` +**Selectable but always RPC'd:** `abi`, `interfaces` +**Special:** `VersionChanged` invalidates all indexed child records for the node + +**Constraint:** Changes limited to protocol-acceleration plugin handlers, Resolution API, SDK types, and ensdb-sdk schema. Subgraph shared-handlers are NOT touched. + +## Design decisions + +- **Versioning (Option A):** on `VersionChanged`, delete all child records for the node and reset scalar columns. Bulk delete via raw drizzle forces a Ponder cache flush — accepted because `VersionChanged` is rare. If ENSv2 emits it frequently, revisit (migrate to version-keyed child PKs). +- **Hybrid acceleration:** `resolveCallByIndex(call, indexed)` returns `{ accelerated: true, result } | { accelerated: false }`. Accelerated results flow through; unaccelerated calls batch-RPC'd in one round trip. `makeRecordsResponseFromResolveResults` shapes the merged result. +- **ABI / interfaces not indexed.** ABI event omits data (would require follow-up readContract), interface getter has ERC-165 fallback that can't be replicated offline. Selection-level support preserved via the RPC tail of the hybrid path. +- **Pubkey:** flat `pubkeyX` / `pubkeyY` columns in DB, invariant both-null-or-both-set, `{ x, y } | null` shape in API. +- **`makeRecordsResponseFromIndexedRecords` deleted** this PR — hybrid path routes everything through `makeRecordsResponseFromResolveResults`. + +--- + +## 1. Semantic types (`packages/enssdk/src/lib/types/`) + +Create `content-type.ts`: +```ts +/** Power-of-2 ABI content type per ENSIP-4 (1=JSON, 2=zlib-JSON, 4=CBOR, 8=URI). */ +export type ContentType = bigint; +``` + +Create `interface-id.ts`: +```ts +import type { Hex } from "viem"; +/** ERC-165 4-byte interface selector. */ +export type InterfaceId = Hex; +``` + +Re-export both from `types/index.ts`. + +--- + +## 2. Schema (`packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts`) + +Extend `resolverRecords`. No new tables. No migrations. + +```ts +export const resolverRecords = onchainTable( + "resolver_records", + (t) => ({ + id: t.text().primaryKey().$type(), + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + node: t.hex().notNull().$type(), + + /** ENSIP-3 name() record, InterpretedName. */ + name: t.text().$type(), + + /** ENSIP-7 contenthash raw bytes. Null iff unset (empty bytes sentinel normalized). */ + contenthash: t.hex(), + + /** + * PubkeyResolver (x, y) pair. + * INVARIANT: both null together, or both set together. + * (0x00…, 0x00…) sentinel stored as (null, null). Exposed to API as { x, y } | null. + */ + pubkeyX: t.hex(), + pubkeyY: t.hex(), + + /** IDNSZoneResolver zonehash. Null iff unset. */ + dnszonehash: t.hex(), + + /** + * IVersionableResolver version. Default 0. + * Strategy: on VersionChanged, delete all child records for (chainId, address, node) + * and reset scalars. Future: may migrate to version-keyed child PKs if ENSv2 emits + * VersionChanged frequently. + */ + version: t.bigint().notNull().default(0n), + }), + (t) => ({ byId: uniqueIndex().on(t.chainId, t.address, t.node) }), +); +``` + +`resolverRecords_relations` unchanged. No abi/interface tables. + +--- + +## 3. Interpreters (`@ensnode/ensnode-sdk/internal`) + +Add to existing interpretation module: + +```ts +export function interpretContenthashValue(raw: Hex): Hex | null { + return raw === "0x" ? null : raw; +} + +export function interpretPubkeyValue(x: Hex, y: Hex): { x: Hex; y: Hex } | null { + const ZERO = "0x" + "00".repeat(32); + if (x === ZERO && y === ZERO) return null; + return { x, y }; +} + +export function interpretDnszonehashValue(raw: Hex): Hex | null { + return raw === "0x" ? null : raw; +} +``` + +--- + +## 4. Indexer DB helpers (`apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts`) + +```ts +export async function handleResolverContenthashUpdate( + context: IndexingEngineContext, + key: ResolverRecordsCompositeKey, + rawHash: Hex, +) { + const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ contenthash: interpretContenthashValue(rawHash) }); +} + +export async function handleResolverPubkeyUpdate( + context: IndexingEngineContext, + key: ResolverRecordsCompositeKey, + x: Hex, + y: Hex, +) { + const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); + const pubkey = interpretPubkeyValue(x, y); + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ pubkeyX: pubkey?.x ?? null, pubkeyY: pubkey?.y ?? null }); +} + +export async function handleResolverDnszonehashUpdate( + context: IndexingEngineContext, + key: ResolverRecordsCompositeKey, + rawHash: Hex, +) { + const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ dnszonehash: interpretDnszonehashValue(rawHash) }); +} + +/** + * IVersionableResolver VersionChanged: deletes all child records for (chainId, address, node) + * and resets scalar columns. Uses raw drizzle via `context.db.sql` — this flushes Ponder's + * local cache to Postgres, accepted because VersionChanged is rare. + */ +export async function handleResolverVersionChange( + context: IndexingEngineContext, + key: ResolverRecordsCompositeKey, + newVersion: bigint, +) { + const { chainId, address, node } = key; + + await context.db.sql + .delete(ensIndexerSchema.resolverAddressRecord) + .where(and( + eq(ensIndexerSchema.resolverAddressRecord.chainId, chainId), + eq(ensIndexerSchema.resolverAddressRecord.address, address), + eq(ensIndexerSchema.resolverAddressRecord.node, node), + )); + + await context.db.sql + .delete(ensIndexerSchema.resolverTextRecord) + .where(and( + eq(ensIndexerSchema.resolverTextRecord.chainId, chainId), + eq(ensIndexerSchema.resolverTextRecord.address, address), + eq(ensIndexerSchema.resolverTextRecord.node, node), + )); + + const id = makeResolverRecordsId({ chainId, address }, node); + await context.ensDb + .update(ensIndexerSchema.resolverRecords, { id }) + .set({ + name: null, + contenthash: null, + pubkeyX: null, + pubkeyY: null, + dnszonehash: null, + version: newVersion, + }); +} +``` + +--- + +## 5. Handler registrations (`apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts`) + +Append four new listeners. `ABIChanged` and `InterfaceChanged` are intentionally NOT registered. + +```ts +addOnchainEventListener( + namespaceContract(pluginName, "Resolver:ContenthashChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + const key = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, key); + await handleResolverContenthashUpdate(context, key, event.args.hash); + }, +); + +addOnchainEventListener( + namespaceContract(pluginName, "Resolver:PubkeyChanged"), + async ({ context, event }) => { + const { x, y } = event.args; + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + const key = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, key); + await handleResolverPubkeyUpdate(context, key, x, y); + }, +); + +addOnchainEventListener( + namespaceContract(pluginName, "Resolver:DNSZonehashChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + const key = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, key); + await handleResolverDnszonehashUpdate(context, key, event.args.zonehash); + }, +); + +addOnchainEventListener( + namespaceContract(pluginName, "Resolver:VersionChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + const key = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, key); + await handleResolverVersionChange(context, key, event.args.newVersion); + }, +); +``` + +--- + +## 6. SDK selection & response (`packages/ensnode-sdk/src/resolution/`) + +### `resolver-records-selection.ts` + +```ts +import type { CoinType, ContentType, InterfaceId } from "enssdk"; + +export interface ResolverRecordsSelection { + name?: boolean; + addresses?: CoinType[]; + texts?: string[]; + contenthash?: boolean; + pubkey?: boolean; + /** Always resolved via RPC (never accelerated). */ + abis?: ContentType[]; + /** Always resolved via RPC (never accelerated). */ + interfaces?: InterfaceId[]; + dnszonehash?: boolean; + version?: boolean; +} + +export const isSelectionEmpty = (s: ResolverRecordsSelection) => + !s.name + && !s.addresses?.length + && !s.texts?.length + && !s.contenthash + && !s.pubkey + && !s.dnszonehash + && !s.abis?.length + && !s.interfaces?.length + && !s.version; +``` + +### `resolver-records-response.ts` + +Extend `ResolverRecordsResponseBase`: +```ts +contenthash: Hex | null; +pubkey: { x: Hex; y: Hex } | null; +/** Keyed by stringified bigint ContentType at the API surface (Record). */ +abis: Record; +interfaces: Record; +dnszonehash: Hex | null; +version: bigint; +``` + +Extend `ResolverRecordsResponse` mapped type with branches: +- `K extends "abis"` → `Record<\`${T["abis"][number]}\`, Hex | null>` +- `K extends "interfaces"` → `Record` +- `contenthash` / `pubkey` / `dnszonehash` / `version` pass through from base. + +Internal module types may use `Record` where convenient, but the API response type uses `Record` for bigint keys. + +--- + +## 7. Resolution API: calls & interpretation (`apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts`) + +Extend `makeResolveCalls`: +```ts +return [ + selection.name && ({ functionName: "name", args: [node] } as const), + selection.contenthash && ({ functionName: "contenthash", args: [node] } as const), + selection.pubkey && ({ functionName: "pubkey", args: [node] } as const), + selection.dnszonehash && ({ functionName: "zonehash", args: [node] } as const), + selection.version && ({ functionName: "recordVersions", args: [node] } as const), + ...(selection.addresses ?? []).map(ct => ({ functionName: "addr", args: [node, BigInt(ct)] } as const)), + ...(selection.texts ?? []).map(k => ({ functionName: "text", args: [node, k] } as const)), + ...(selection.abis ?? []).map(ct => ({ functionName: "ABI", args: [node, ct] } as const)), + ...(selection.interfaces ?? []).map(id => ({ functionName: "interfaceImplementer", args: [node, id] } as const)), +].filter((c): c is Exclude => !!c); +``` + +Extend `interpretRawCallsAndResults` switch: +```ts +case "contenthash": return { call, result: interpretContenthashValue(result as Hex) }; +case "pubkey": { + const [x, y] = result as [Hex, Hex]; + return { call, result: interpretPubkeyValue(x, y) }; +} +case "zonehash": return { call, result: interpretDnszonehashValue(result as Hex) }; +case "recordVersions": return { call, result: result as bigint }; +case "ABI": { + const [contentType, data] = result as [bigint, Hex]; + return { call, result: data === "0x" ? null : { contentType, data } }; +} +case "interfaceImplementer": { + return { call, result: result === "0x0000000000000000000000000000000000000000" ? null : result }; +} +``` + +Update `ResolveCallsAndRawResults` / `ResolveCallsAndResults` conditional type tables to include every new function-name → result-shape mapping. + +### ABI semantics + +On-chain `ABI(node, contentTypes)` takes a bitmask and returns the first matching ABI. Our API treats each selected `ContentType` as a single-bit mask, producing one call per selection entry. Predictable, wire-compatible for the common "query single content type" case. Document in selection JSDoc. + +--- + +## 8. New: `apps/ensapi/src/lib/resolution/resolve-call-by-index.ts` + +```ts +import { bigintToCoinType } from "enssdk"; +import type { IndexedResolverRecords } from "./make-records-response"; +import type { ResolveCall } from "./resolve-calls-and-results"; + +type ResolveByIndex = + | { call: C; accelerated: true; result: unknown } + | { call: C; accelerated: false }; + +export function resolveCallByIndex( + call: C, + indexed: IndexedResolverRecords | null, +): ResolveByIndex { + switch (call.functionName) { + case "name": + return { call, accelerated: true, result: indexed?.name ?? null }; + case "addr": { + const ct = bigintToCoinType(call.args[1] as bigint); + const found = indexed?.addressRecords.find(r => bigintToCoinType(r.coinType) === ct); + return { call, accelerated: true, result: found?.value ?? null }; + } + case "text": { + const key = call.args[1] as string; + const found = indexed?.textRecords.find(r => r.key === key); + return { call, accelerated: true, result: found?.value ?? null }; + } + case "contenthash": return { call, accelerated: true, result: indexed?.contenthash ?? null }; + case "pubkey": return { call, accelerated: true, result: indexed?.pubkey ?? null }; + case "zonehash": return { call, accelerated: true, result: indexed?.dnszonehash ?? null }; + case "recordVersions": return { call, accelerated: true, result: indexed?.version ?? 0n }; + case "ABI": + case "interfaceImplementer": + return { call, accelerated: false }; + } +} +``` + +--- + +## 9. Index read (`apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts`) + +Shape-only change. Existing address-record defaulting logic preserved. + +```ts +const row = await ensDb.query.resolverRecords.findFirst({ + where: (t, { and, eq }) => and( + eq(t.chainId, resolver.chainId), + eq(t.address, resolver.address), + eq(t.node, node), + ), + columns: { + name: true, + contenthash: true, + pubkeyX: true, + pubkeyY: true, + dnszonehash: true, + version: true, + }, + with: { addressRecords: true, textRecords: true }, +}); +if (!row) return null; + +const records: IndexedResolverRecords = { + name: row.name, + addressRecords: row.addressRecords, + textRecords: row.textRecords, + contenthash: row.contenthash, + pubkey: row.pubkeyX && row.pubkeyY ? { x: row.pubkeyX, y: row.pubkeyY } : null, + dnszonehash: row.dnszonehash, + version: row.version, +}; + +// existing address-record defaulting block unchanged + +return records; +``` + +--- + +## 10. Response shaping (`apps/ensapi/src/lib/resolution/make-records-response.ts`) + +Extend `IndexedResolverRecords`: +```ts +export interface IndexedResolverRecords { + name: string | null; + addressRecords: { coinType: bigint; value: string }[]; + textRecords: { key: string; value: string }[]; + contenthash: Hex | null; + pubkey: { x: Hex; y: Hex } | null; + dnszonehash: Hex | null; + version: bigint; +} +``` + +**Delete** `makeRecordsResponseFromIndexedRecords`. + +Rewrite `makeEmptyResolverRecordsResponse`: +```ts +export function makeEmptyResolverRecordsResponse(selection: S) { + return makeRecordsResponseFromResolveResults(selection, []); +} +``` + +Extend `makeRecordsResponseFromResolveResults` with branches per new selection field: +- `selection.contenthash` → find `functionName === "contenthash"`; null if absent. +- `selection.pubkey` → find `functionName === "pubkey"`; null if absent. +- `selection.dnszonehash` → find `functionName === "zonehash"`; null if absent. +- `selection.version` → find `functionName === "recordVersions"`; default `0n` if absent. +- `selection.abis` → for each selected `ContentType`, find `ABI` result where `args[1] === ct`. Key response by `String(ct)`. +- `selection.interfaces` → for each selected `InterfaceId`, find `interfaceImplementer` result where `args[1] === id`. Key by `id`. + +--- + +## 11. Hybrid acceleration (`apps/ensapi/src/lib/resolution/forward-resolution.ts`) + +Rework the accelerated branch (current lines 257–375): + +- ENSIP-19 reverse acceleration: unchanged. +- Bridged resolver acceleration: unchanged. +- **Hoist `isExtendedResolver` check** above the static-resolver acceleration block so `extended` is available to both the hybrid-RPC tail and the downstream pure-RPC path. Remove the duplicated block below. +- Static-resolver accelerated branch → hybrid flow: + +```ts +if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { + return withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, + {}, + async () => { + const indexed = await getRecordsFromIndex({ resolver, node, selection }); + const resolved = calls.map(c => resolveCallByIndex(c, indexed)); + + const acceleratedResults = resolved + .filter((r): r is Extract => r.accelerated) + .map(({ call, result }) => ({ call, result })); + + const rpcCalls = resolved.filter(r => !r.accelerated).map(r => r.call); + const rpcResults = rpcCalls.length + ? interpretRawCallsAndResults( + await executeResolveCalls({ + name, resolverAddress: activeResolver, useENSIP10Resolve: extended, + calls: rpcCalls, publicClient, + }), + ) + : []; + + return makeRecordsResponseFromResolveResults( + selection, + [...acceleratedResults, ...rpcResults], + ); + }, + ); +} +``` + +Tracing events: preserve the existing `AccelerateKnownOnchainStaticResolver` step around the hybrid block (success when we enter it, false when we fall through). + +--- + +## 12. Changeset + +Add one changeset: + +```md +--- +"@ensnode/ensdb-sdk": minor +"@ensnode/ensnode-sdk": minor +"@ensnode/ensindexer": minor +"@ensnode/ensapi": minor +"enssdk": minor +--- + +Resolution API: support contenthash, pubkey, abis, interfaces, dnszonehash, and version +selection. Protocol acceleration indexes contenthash, pubkey, dnszonehash, and handles +VersionChanged (clears records for the node, bumps version). ABI and interface records +are selectable but always resolved via RPC. +``` + +--- + +## 13. Tests + +### Unit +- `packages/ensnode-sdk/src/internal/interpret-*.test.ts` — sentinel → null for contenthash, pubkey, dnszonehash. +- `apps/ensapi/src/lib/resolution/resolve-call-by-index.test.ts` — every `functionName` returns correct `accelerated` tagging, correct result shape from indexed data, correct null fallbacks when `indexed === null` or fields missing. +- `apps/ensapi/src/lib/resolution/resolve-calls-and-results.test.ts` — `makeResolveCalls` emits correct calls for every new selection field; `interpretRawCallsAndResults` covers every new case. +- `apps/ensapi/src/lib/resolution/make-records-response.test.ts` — `makeRecordsResponseFromResolveResults` shapes match every selection combination; `makeEmptyResolverRecordsResponse` returns correct nulls. + +### Integration +- Indexer: fixture resolver emitting `ContenthashChanged` / `PubkeyChanged` / `DNSZonehashChanged` / `AddressChanged` / `TextChanged` / `VersionChanged` in order. Assert final DB state including reset scalars, cleared child rows, bumped version. +- Resolution API: real static resolver with records set for each type. Run with `{ accelerate: true }` and `{ accelerate: false }`. Assert identical responses (parity). +- Resolution API: mixed selection `{ name, addresses, texts, contenthash, abis, interfaces }` exercises both legs of hybrid path in one request. + +### Commands +``` +pnpm -F @ensnode/ensindexer -F @ensnode/ensapi -F @ensnode/ensnode-sdk -F @ensnode/ensdb-sdk -F enssdk typecheck +pnpm lint +pnpm test --project ensindexer --project ensapi --project ensnode-sdk --project ensdb-sdk --project enssdk +pnpm test:integration +``` + +--- + +## 14. PR ordering + +1. Semantic types in `enssdk` (§1). +2. Interpreters in `@ensnode/ensnode-sdk/internal` (§3). +3. SDK selection + response types (§6). +4. Schema update (§2) + indexer DB helpers (§4) + handler registrations (§5). +5. Resolution API plumbing: `resolve-calls-and-results` (§7), `resolve-call-by-index` (§8), `get-records-from-index` (§9), `make-records-response` (§10), `forward-resolution` (§11). +6. Delete `makeRecordsResponseFromIndexedRecords` once no callers remain. +7. Tests (§13), changeset (§12). + +Single PR. Branch from current `fix/name-index` parent or `main` per dependency. + +--- + +## Unresolved questions + +_(none — all prior questions resolved)_ From e05ec90ce7baaa6fc5e9f96865b393cf94e7d484 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 12:37:16 -0500 Subject: [PATCH 02/18] spec: linear operation passes, abi bitmask, enssdk semantic types - operations array with optional `result` sentinel; passes layer transforms - split ENSIP-19 and static-indexed accelerators into dedicated pass helpers - abi becomes a bitmask (contract-equivalent), single `{ contentType, data } | null` response - ContentType + InterfaceId land in enssdk - resolveCallByIndex returns `unknown | undefined` (undefined = not accelerable) - executeResolveCalls becomes terminal pass over operations, per-call interpret Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC-resolver-records.md | 382 +++++++++++++++++++++++++++------------ 1 file changed, 267 insertions(+), 115 deletions(-) diff --git a/SPEC-resolver-records.md b/SPEC-resolver-records.md index 3ac480ed2..f130b6b52 100644 --- a/SPEC-resolver-records.md +++ b/SPEC-resolver-records.md @@ -6,11 +6,11 @@ **Selectable but always RPC'd:** `abi`, `interfaces` **Special:** `VersionChanged` invalidates all indexed child records for the node -**Constraint:** Changes limited to protocol-acceleration plugin handlers, Resolution API, SDK types, and ensdb-sdk schema. Subgraph shared-handlers are NOT touched. +**Constraint:** Changes limited to protocol-acceleration plugin handlers, Resolution API, SDK types, and ensdb-sdk schema. Subgraph shared-handlers and assocaited subgraph.schema.ts are NOT touched. ## Design decisions -- **Versioning (Option A):** on `VersionChanged`, delete all child records for the node and reset scalar columns. Bulk delete via raw drizzle forces a Ponder cache flush — accepted because `VersionChanged` is rare. If ENSv2 emits it frequently, revisit (migrate to version-keyed child PKs). +- **Versioning (Option A):** on `VersionChanged`, delete all child records for the node and reset scalar columns. Bulk delete via raw drizzle forces a Ponder cache flush — accepted because `VersionChanged` is rare. - **Hybrid acceleration:** `resolveCallByIndex(call, indexed)` returns `{ accelerated: true, result } | { accelerated: false }`. Accelerated results flow through; unaccelerated calls batch-RPC'd in one round trip. `makeRecordsResponseFromResolveResults` shapes the merged result. - **ABI / interfaces not indexed.** ABI event omits data (would require follow-up readContract), interface getter has ERC-165 fallback that can't be replicated offline. Selection-level support preserved via the RPC tail of the hybrid path. - **Pubkey:** flat `pubkeyX` / `pubkeyY` columns in DB, invariant both-null-or-both-set, `{ x, y } | null` shape in API. @@ -20,20 +20,34 @@ ## 1. Semantic types (`packages/enssdk/src/lib/types/`) -Create `content-type.ts`: +Create `resolver.ts`: ```ts -/** Power-of-2 ABI content type per ENSIP-4 (1=JSON, 2=zlib-JSON, 4=CBOR, 8=URI). */ +import type { Hex } from "viem"; + +/** + * 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; -``` -Create `interface-id.ts`: -```ts -import type { Hex } from "viem"; -/** ERC-165 4-byte interface selector. */ +/** + * ERC-165 4-byte interface selector. + * + * @see https://github.com/ensdomains/ens-contracts/blob/91c966febd7b55494269df830fc6775f040b927b/contracts/resolvers/profiles/InterfaceResolver.sol + */ export type InterfaceId = Hex; ``` -Re-export both from `types/index.ts`. +Re-export from `types/index.ts`. + +Update `packages/ensnode-sdk/src/rpc/eip-165.ts` and `apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts` (`handleInterfaceChanged`) to use the new `InterfaceId` type. --- @@ -50,28 +64,30 @@ export const resolverRecords = onchainTable( address: t.hex().notNull().$type
(), node: t.hex().notNull().$type(), - /** ENSIP-3 name() record, InterpretedName. */ + /** + * ENSIP-3 name() record, guaranteed to be an InterpretedName or null if not set. + */ name: t.text().$type(), - /** ENSIP-7 contenthash raw bytes. Null iff unset (empty bytes sentinel normalized). */ + /** + * ENSIP-7 contenthash raw bytes or null if not set. + */ contenthash: t.hex(), /** - * PubkeyResolver (x, y) pair. - * INVARIANT: both null together, or both set together. - * (0x00…, 0x00…) sentinel stored as (null, null). Exposed to API as { x, y } | null. + * 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. Null iff unset. */ + /** + * IDNSZoneResolver zonehash or null if not set. + */ dnszonehash: t.hex(), /** - * IVersionableResolver version. Default 0. - * Strategy: on VersionChanged, delete all child records for (chainId, address, node) - * and reset scalars. Future: may migrate to version-keyed child PKs if ENSv2 emits - * VersionChanged frequently. + * IVersionableResolver version, defaulting to 0. */ version: t.bigint().notNull().default(0n), }), @@ -88,18 +104,17 @@ export const resolverRecords = onchainTable( Add to existing interpretation module: ```ts -export function interpretContenthashValue(raw: Hex): Hex | null { - return raw === "0x" ? null : raw; +export function interpretContenthashValue(value: Hex): Hex | null { + return value === "0x" ? null : value; } export function interpretPubkeyValue(x: Hex, y: Hex): { x: Hex; y: Hex } | null { - const ZERO = "0x" + "00".repeat(32); - if (x === ZERO && y === ZERO) return null; + if (x === zeroHash && y === zeroHash) return null; return { x, y }; } -export function interpretDnszonehashValue(raw: Hex): Hex | null { - return raw === "0x" ? null : raw; +export function interpretDnszonehashValue(value: Hex): Hex | null { + return value === "0x" ? null : value; } ``` @@ -253,9 +268,7 @@ export interface ResolverRecordsSelection { texts?: string[]; contenthash?: boolean; pubkey?: boolean; - /** Always resolved via RPC (never accelerated). */ - abis?: ContentType[]; - /** Always resolved via RPC (never accelerated). */ + abi?: ContentType; interfaces?: InterfaceId[]; dnszonehash?: boolean; version?: boolean; @@ -268,7 +281,7 @@ export const isSelectionEmpty = (s: ResolverRecordsSelection) => && !s.contenthash && !s.pubkey && !s.dnszonehash - && !s.abis?.length + && !s.abi && !s.interfaces?.length && !s.version; ``` @@ -279,19 +292,15 @@ Extend `ResolverRecordsResponseBase`: ```ts contenthash: Hex | null; pubkey: { x: Hex; y: Hex } | null; -/** Keyed by stringified bigint ContentType at the API surface (Record). */ -abis: Record; +abi: { contentType: ContentType; data: Hex } | null; interfaces: Record; dnszonehash: Hex | null; version: bigint; ``` Extend `ResolverRecordsResponse` mapped type with branches: -- `K extends "abis"` → `Record<\`${T["abis"][number]}\`, Hex | null>` - `K extends "interfaces"` → `Record` -- `contenthash` / `pubkey` / `dnszonehash` / `version` pass through from base. - -Internal module types may use `Record` where convenient, but the API response type uses `Record` for bigint keys. +- `contenthash` / `pubkey` / `abi` / `dnszonehash` / `version` pass through from base. --- @@ -305,9 +314,9 @@ return [ selection.pubkey && ({ functionName: "pubkey", args: [node] } as const), selection.dnszonehash && ({ functionName: "zonehash", args: [node] } as const), selection.version && ({ functionName: "recordVersions", args: [node] } as const), + selection.abi && ({ functionName: "ABI", args: [node, selection.abi] } as const), ...(selection.addresses ?? []).map(ct => ({ functionName: "addr", args: [node, BigInt(ct)] } as const)), ...(selection.texts ?? []).map(k => ({ functionName: "text", args: [node, k] } as const)), - ...(selection.abis ?? []).map(ct => ({ functionName: "ABI", args: [node, ct] } as const)), ...(selection.interfaces ?? []).map(id => ({ functionName: "interfaceImplementer", args: [node, id] } as const)), ].filter((c): c is Exclude => !!c); ``` @@ -322,20 +331,16 @@ case "pubkey": { case "zonehash": return { call, result: interpretDnszonehashValue(result as Hex) }; case "recordVersions": return { call, result: result as bigint }; case "ABI": { - const [contentType, data] = result as [bigint, Hex]; + const [contentType, data] = result as [ContentType, Hex]; return { call, result: data === "0x" ? null : { contentType, data } }; } case "interfaceImplementer": { - return { call, result: result === "0x0000000000000000000000000000000000000000" ? null : result }; + return { call, result: interpretAddress(result) }; } ``` Update `ResolveCallsAndRawResults` / `ResolveCallsAndResults` conditional type tables to include every new function-name → result-shape mapping. -### ABI semantics - -On-chain `ABI(node, contentTypes)` takes a bitmask and returns the first matching ABI. Our API treats each selected `ContentType` as a single-bit mask, producing one call per selection entry. Predictable, wire-compatible for the common "query single content type" case. Document in selection JSDoc. - --- ## 8. New: `apps/ensapi/src/lib/resolution/resolve-call-by-index.ts` @@ -345,34 +350,33 @@ import { bigintToCoinType } from "enssdk"; import type { IndexedResolverRecords } from "./make-records-response"; import type { ResolveCall } from "./resolve-calls-and-results"; -type ResolveByIndex = - | { call: C; accelerated: true; result: unknown } - | { call: C; accelerated: false }; - +/** + * (undefined means not accelerated) + */ export function resolveCallByIndex( call: C, - indexed: IndexedResolverRecords | null, -): ResolveByIndex { + records: IndexedResolverRecords | null, +): unknown | null | undefined { switch (call.functionName) { case "name": - return { call, accelerated: true, result: indexed?.name ?? null }; + return records?.name ?? null; case "addr": { const ct = bigintToCoinType(call.args[1] as bigint); - const found = indexed?.addressRecords.find(r => bigintToCoinType(r.coinType) === ct); - return { call, accelerated: true, result: found?.value ?? null }; + const found = records?.addressRecords.find(r => bigintToCoinType(r.coinType) === ct); + return found?.value ?? null; } case "text": { const key = call.args[1] as string; - const found = indexed?.textRecords.find(r => r.key === key); - return { call, accelerated: true, result: found?.value ?? null }; + const found = records?.textRecords.find(r => r.key === key); + return found?.value ?? null } - case "contenthash": return { call, accelerated: true, result: indexed?.contenthash ?? null }; - case "pubkey": return { call, accelerated: true, result: indexed?.pubkey ?? null }; - case "zonehash": return { call, accelerated: true, result: indexed?.dnszonehash ?? null }; - case "recordVersions": return { call, accelerated: true, result: indexed?.version ?? 0n }; + case "contenthash": return records?.contenthash ?? null; + case "pubkey": return (records?.pubkeyX && records?.pubkeyY) ? {x: records.pubkeyX, y: records.pubkeyY} : null; + case "zonehash": return records?.dnszonehash ?? null; + case "recordVersions": return records?.version ?? 0n; case "ABI": case "interfaceImplementer": - return { call, accelerated: false }; + return undefined; } } ``` @@ -384,7 +388,7 @@ export function resolveCallByIndex( Shape-only change. Existing address-record defaulting logic preserved. ```ts -const row = await ensDb.query.resolverRecords.findFirst({ +const row = (await ensDb.query.resolverRecords.findFirst({ where: (t, { and, eq }) => and( eq(t.chainId, resolver.chainId), eq(t.address, resolver.address), @@ -399,18 +403,9 @@ const row = await ensDb.query.resolverRecords.findFirst({ version: true, }, with: { addressRecords: true, textRecords: true }, -}); -if (!row) return null; +})) as IndexedResolverRecords | undefined; -const records: IndexedResolverRecords = { - name: row.name, - addressRecords: row.addressRecords, - textRecords: row.textRecords, - contenthash: row.contenthash, - pubkey: row.pubkeyX && row.pubkeyY ? { x: row.pubkeyX, y: row.pubkeyY } : null, - dnszonehash: row.dnszonehash, - version: row.version, -}; +if (!row) return null; // existing address-record defaulting block unchanged @@ -428,7 +423,8 @@ export interface IndexedResolverRecords { addressRecords: { coinType: bigint; value: string }[]; textRecords: { key: string; value: string }[]; contenthash: Hex | null; - pubkey: { x: Hex; y: Hex } | null; + pubkeyX: Hex; + pubkeyY: Hex; dnszonehash: Hex | null; version: bigint; } @@ -448,54 +444,212 @@ Extend `makeRecordsResponseFromResolveResults` with branches per new selection f - `selection.pubkey` → find `functionName === "pubkey"`; null if absent. - `selection.dnszonehash` → find `functionName === "zonehash"`; null if absent. - `selection.version` → find `functionName === "recordVersions"`; default `0n` if absent. -- `selection.abis` → for each selected `ContentType`, find `ABI` result where `args[1] === ct`. Key response by `String(ct)`. +- `selection.abi` → find `functionName === "ABI"`; the interpreted result is already `{ contentType, data } | null`. Pass through. - `selection.interfaces` → for each selected `InterfaceId`, find `interfaceImplementer` result where `args[1] === id`. Key by `id`. --- -## 11. Hybrid acceleration (`apps/ensapi/src/lib/resolution/forward-resolution.ts`) +## 11. Linear flow with a single RPC callsite (`apps/ensapi/src/lib/resolution/forward-resolution.ts`) + +Restructure `_resolveForward` into a linear pipeline of **passes over an `operations` array**. Each entry is either unresolved (`{ call }`) or resolved (`{ call, result }`). Each pass takes `operations`, touches only unresolved entries, and returns the new `operations`. The terminal RPC pass resolves anything still unresolved. + +1. **Identify resolver** (findResolver). +2. **Accelerate** — one pass per strategy, each layering onto `operations`. +3. **RPC anything left** — single `executeResolveCalls` callsite resolves unresolved entries. + +Early returns (kept — distinct resolution models, not per-call passes): +- ENSv2 UniversalResolver bailout (unchanged; uses `executeResolveCallsWithUniversalResolver`; TODO to re-integrate once acceleration supports ENSv2). +- Empty selection → `makeEmptyResolverRecordsResponse`. +- No active resolver → `makeEmptyResolverRecordsResponse`. +- Bridged resolver → recursive `_resolveForward` with redirected registry (still one RPC per outer frame). + +### Operation type + +Shared across passes. Lives next to `ResolveCall` (e.g. `resolve-calls-and-results.ts`): + +```ts +export type Operation = { call: ResolveCall; result?: unknown }; +// `result === undefined` (or absent) = unresolved; any other value (including null) = resolved. +``` + +### Skeleton + +```ts +// (validation, empty-selection short-circuit, ENSv2 bailout — unchanged) + +// 1. Identify resolver +const { activeName, activeResolver, requiresWildcardSupport } = await findResolver(...); +if (!activeResolver) return makeEmptyResolverRecordsResponse(selection); + +// Bridged resolver → redirect +if (accelerate && canAccelerate) { + const bridgesTo = isBridgedResolver(config.namespace, { chainId, address: activeResolver }); + if (bridgesTo) { + return withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, + {}, + () => _resolveForward(name, selection, { ...options, registry: bridgesTo }), + ); + } +} + +// Initialize operations as unresolved +let operations: Operation[] = calls.map(call => ({ call })); + +// 2. Accelerate if possible — each strategy is its own pass +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)) { + operations = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, + {}, + () => accelerateKnownOnchainStaticResolver({ operations, resolver, node, selection }), + ); + } +} + +// Early return if every operation is resolved — no RPC needed +if (operations.every(op => op.result !== undefined)) { + return makeRecordsResponseFromResolveResults(selection, operations); +} + +// 3. Determine Resolver ENSIP-10 support + requirement +const extended = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.RequireResolver, + { chainId, activeResolver, requiresWildcardSupport }, + () => isExtendedResolver({ address: activeResolver, publicClient }), +); +if (requiresWildcardSupport && !extended) return makeEmptyResolverRecordsResponse(selection); + +// 4. Resolve remaining unresolved operations via RPC +operations = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.ExecuteResolveCalls, + {}, + () => executeResolveCalls({ + name, + resolverAddress: activeResolver, + useENSIP10Resolve: extended, + operations, + publicClient, + }), +); + +return makeRecordsResponseFromResolveResults(selection, operations); +``` + +### `executeResolveCalls` (updated signature) + +Now a pass over `operations` — only RPCs entries that remain unresolved. + +```ts +return Promise.all(operations.map(async (op) => { + if (op.result !== undefined) return op; + + // existing rpc logic for op.call, producing raw result + const rawResult = await /* ... */; + + return interpretRawRpcCallAndResult(op.call, rawResult); +})); +``` -Rework the accelerated branch (current lines 257–375): +`interpretRawRpcCallAndResult(call, raw): Operation` is a per-call interpreter — split from the existing batch `interpretRawCallsAndResults` so it fits naturally inside the per-operation `Promise.all`. -- ENSIP-19 reverse acceleration: unchanged. -- Bridged resolver acceleration: unchanged. -- **Hoist `isExtendedResolver` check** above the static-resolver acceleration block so `extended` is available to both the hybrid-RPC tail and the downstream pure-RPC path. Remove the duplicated block below. -- Static-resolver accelerated branch → hybrid flow: +### New helper: `apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts` ```ts -if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { - return withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, - {}, - async () => { - const indexed = await getRecordsFromIndex({ resolver, node, selection }); - const resolved = calls.map(c => resolveCallByIndex(c, indexed)); - - const acceleratedResults = resolved - .filter((r): r is Extract => r.accelerated) - .map(({ call, result }) => ({ call, result })); - - const rpcCalls = resolved.filter(r => !r.accelerated).map(r => r.call); - const rpcResults = rpcCalls.length - ? interpretRawCallsAndResults( - await executeResolveCalls({ - name, resolverAddress: activeResolver, useENSIP10Resolve: extended, - calls: rpcCalls, publicClient, - }), - ) - : []; - - return makeRecordsResponseFromResolveResults( - selection, - [...acceleratedResults, ...rpcResults], +export async function accelerateENSIP19ReverseResolver({ + operations, name, selection, +}: { + operations: Operation[]; + name: InterpretedName; + selection: ResolverRecordsSelection; +}): Promise { + // Invariant: ENSIP-19 reverse resolvers only answer `name`. + if (selection.name !== true) { + throw new Error( + `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(selection)}.`, + ); + } + + // Sanity: any non-`name` selection would fail on a reverse resolver anyway. + const { name: _n, ...extras } = selection; + if (!isSelectionEmpty(extras)) { + logger.warn( + `Sanity Check(ENSIP-19 Reverse Resolver): expected '{ name: true }' only, got ${JSON.stringify(selection)}.`, + ); + } + + return Promise.all(operations.map(async (op) => { + // Passthrough resolved entries and non-`name` calls. + if (op.result !== undefined) return op; + if (op.call.functionName !== "name") return op; + + // Parse + index lookup happen only for the `name` call. + 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); + return { call: op.call, result }; + })); } ``` -Tracing events: preserve the existing `AccelerateKnownOnchainStaticResolver` step around the hybrid block (success when we enter it, false when we fall through). +### New helper: `apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts` + +```ts +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 => { + // Passthrough resolved entries. + if (op.result !== undefined) return op; + + const result = resolveCallByIndex(op.call, records); + // `undefined` means this call type isn't accelerable (e.g. ABI, interfaceImplementer). + return result === undefined ? op : { call: op.call, result }; + }); +} +``` + +### Properties + +- **One RPC callsite** in `_resolveForward` (excluding the ENSv2 bailout). +- **Composable passes**: adding a new acceleration strategy = one more `operations = await (operations, ...)` line. No partitioning bookkeeping. +- **Tracing preserved**: `AccelerateENSIP19ReverseResolver`, `AccelerateKnownOnchainStaticResolver`, `AccelerateKnownOffchainLookupResolver`, `RequireResolver`, `ExecuteResolveCalls` steps fire in positions equivalent to today. +- **No acceleration possible**: every operation stays unresolved through the accel passes and flows to the single RPC callsite — same behavior as today's pure-fallback branch. + +### Future (out of scope) + +The ENSv2 `executeResolveCallsWithUniversalResolver` bailout can be collapsed into the same linear flow as another terminal `Operation[]`-shaped pass (UniversalResolverV2 vs direct resolver calls). Follow-up once ENSv2 acceleration re-lands. --- @@ -512,10 +666,10 @@ Add one changeset: "enssdk": minor --- -Resolution API: support contenthash, pubkey, abis, interfaces, dnszonehash, and version +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 and interface records -are selectable but always resolved via RPC. +VersionChanged (clears records for the node, bumps version). ABI (bitmask query, +contract-equivalent) and interface records are selectable but always resolved via RPC. ``` --- @@ -524,14 +678,12 @@ are selectable but always resolved via RPC. ### Unit - `packages/ensnode-sdk/src/internal/interpret-*.test.ts` — sentinel → null for contenthash, pubkey, dnszonehash. -- `apps/ensapi/src/lib/resolution/resolve-call-by-index.test.ts` — every `functionName` returns correct `accelerated` tagging, correct result shape from indexed data, correct null fallbacks when `indexed === null` or fields missing. -- `apps/ensapi/src/lib/resolution/resolve-calls-and-results.test.ts` — `makeResolveCalls` emits correct calls for every new selection field; `interpretRawCallsAndResults` covers every new case. - `apps/ensapi/src/lib/resolution/make-records-response.test.ts` — `makeRecordsResponseFromResolveResults` shapes match every selection combination; `makeEmptyResolverRecordsResponse` returns correct nulls. ### Integration - Indexer: fixture resolver emitting `ContenthashChanged` / `PubkeyChanged` / `DNSZonehashChanged` / `AddressChanged` / `TextChanged` / `VersionChanged` in order. Assert final DB state including reset scalars, cleared child rows, bumped version. - Resolution API: real static resolver with records set for each type. Run with `{ accelerate: true }` and `{ accelerate: false }`. Assert identical responses (parity). -- Resolution API: mixed selection `{ name, addresses, texts, contenthash, abis, interfaces }` exercises both legs of hybrid path in one request. +- Resolution API: mixed selection `{ name, addresses, texts, contenthash, abi, interfaces }` exercises both legs of hybrid path in one request. ABI-specific case: set JSON + CBOR for a node, query with `abi: 1n | 4n`, assert the returned `contentType` is `1n` (lowest matching bit wins). ### Commands ``` @@ -549,7 +701,7 @@ pnpm test:integration 2. Interpreters in `@ensnode/ensnode-sdk/internal` (§3). 3. SDK selection + response types (§6). 4. Schema update (§2) + indexer DB helpers (§4) + handler registrations (§5). -5. Resolution API plumbing: `resolve-calls-and-results` (§7), `resolve-call-by-index` (§8), `get-records-from-index` (§9), `make-records-response` (§10), `forward-resolution` (§11). +5. Resolution API plumbing: `resolve-calls-and-results` (§7, includes new `Operation` type + split `interpretRawRpcCallAndResult`), `resolve-call-by-index` (§8), `get-records-from-index` (§9), `make-records-response` (§10), new `accelerate-ensip19-reverse-resolver.ts` + `accelerate-known-onchain-static-resolver.ts` + restructured `forward-resolution.ts` with `let operations` passes (§11). 6. Delete `makeRecordsResponseFromIndexedRecords` once no callers remain. 7. Tests (§13), changeset (§12). From e6c4fef2c05211c04668c4398a47283741a0fa6e Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:02:17 -0500 Subject: [PATCH 03/18] checkpoint: refactor forward-resolution into a linear sequence of operations --- .changeset/extend-resolver-records.md | 13 + SPEC-resolver-records.md | 714 ------------------ .../get-primary-name-from-index.ts | 4 +- .../get-records-from-index.ts | 52 +- .../accelerate-ensip19-reverse-resolver.ts | 45 ++ ...ccelerate-known-onchain-static-resolver.ts | 79 ++ ...ute-operations-with-universal-resolver.ts} | 55 +- .../src/lib/resolution/execute-operations.ts | 168 +++++ .../src/lib/resolution/forward-resolution.ts | 270 ++----- .../resolution/make-records-response.test.ts | 155 ++-- .../lib/resolution/make-records-response.ts | 142 ++-- apps/ensapi/src/lib/resolution/operations.ts | 118 +++ .../resolution/resolve-calls-and-results.ts | 260 ------- ...ith-universal-resolver.integration.test.ts | 73 -- .../resolver-db-helpers.ts | 109 +++ .../handlers/Resolver.ts | 63 ++ .../subgraph/shared-handlers/Resolver.ts | 8 +- .../protocol-acceleration.schema.ts | 24 + packages/ensdb-sdk/src/lib/drizzle.ts | 2 + packages/ensnode-sdk/src/internal.ts | 2 + .../resolution/resolver-records-response.ts | 43 +- .../resolution/resolver-records-selection.ts | 43 +- packages/ensnode-sdk/src/rpc/eip-165.ts | 9 +- .../src/shared/interpretation/index.ts | 1 + .../interpret-resolver-values.test.ts | 48 ++ .../interpret-resolver-values.ts | 28 + packages/enssdk/src/lib/types/index.ts | 1 + packages/enssdk/src/lib/types/resolver.ts | 29 + 28 files changed, 1084 insertions(+), 1474 deletions(-) create mode 100644 .changeset/extend-resolver-records.md delete mode 100644 SPEC-resolver-records.md create mode 100644 apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts create mode 100644 apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts rename apps/ensapi/src/lib/resolution/{resolve-with-universal-resolver.ts => execute-operations-with-universal-resolver.ts} (56%) create mode 100644 apps/ensapi/src/lib/resolution/execute-operations.ts create mode 100644 apps/ensapi/src/lib/resolution/operations.ts delete mode 100644 apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts delete mode 100644 apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts create mode 100644 packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.test.ts create mode 100644 packages/ensnode-sdk/src/shared/interpretation/interpret-resolver-values.ts create mode 100644 packages/enssdk/src/lib/types/resolver.ts diff --git a/.changeset/extend-resolver-records.md b/.changeset/extend-resolver-records.md new file mode 100644 index 000000000..10c33eb46 --- /dev/null +++ b/.changeset/extend-resolver-records.md @@ -0,0 +1,13 @@ +--- +"@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` semantic types to `enssdk`. diff --git a/SPEC-resolver-records.md b/SPEC-resolver-records.md deleted file mode 100644 index f130b6b52..000000000 --- a/SPEC-resolver-records.md +++ /dev/null @@ -1,714 +0,0 @@ -# Spec: Extend IResolver Record Types in Protocol Acceleration & Resolution API - -## Scope - -**Indexed and accelerated:** `contenthash`, `pubkey`, `dnszonehash`, `version` -**Selectable but always RPC'd:** `abi`, `interfaces` -**Special:** `VersionChanged` invalidates all indexed child records for the node - -**Constraint:** Changes limited to protocol-acceleration plugin handlers, Resolution API, SDK types, and ensdb-sdk schema. Subgraph shared-handlers and assocaited subgraph.schema.ts are NOT touched. - -## Design decisions - -- **Versioning (Option A):** on `VersionChanged`, delete all child records for the node and reset scalar columns. Bulk delete via raw drizzle forces a Ponder cache flush — accepted because `VersionChanged` is rare. -- **Hybrid acceleration:** `resolveCallByIndex(call, indexed)` returns `{ accelerated: true, result } | { accelerated: false }`. Accelerated results flow through; unaccelerated calls batch-RPC'd in one round trip. `makeRecordsResponseFromResolveResults` shapes the merged result. -- **ABI / interfaces not indexed.** ABI event omits data (would require follow-up readContract), interface getter has ERC-165 fallback that can't be replicated offline. Selection-level support preserved via the RPC tail of the hybrid path. -- **Pubkey:** flat `pubkeyX` / `pubkeyY` columns in DB, invariant both-null-or-both-set, `{ x, y } | null` shape in API. -- **`makeRecordsResponseFromIndexedRecords` deleted** this PR — hybrid path routes everything through `makeRecordsResponseFromResolveResults`. - ---- - -## 1. Semantic types (`packages/enssdk/src/lib/types/`) - -Create `resolver.ts`: -```ts -import type { Hex } from "viem"; - -/** - * 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; -``` - -Re-export from `types/index.ts`. - -Update `packages/ensnode-sdk/src/rpc/eip-165.ts` and `apps/ensindexer/src/plugins/subgraph/shared-handlers/Resolver.ts` (`handleInterfaceChanged`) to use the new `InterfaceId` type. - ---- - -## 2. Schema (`packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts`) - -Extend `resolverRecords`. No new tables. No migrations. - -```ts -export const resolverRecords = onchainTable( - "resolver_records", - (t) => ({ - id: t.text().primaryKey().$type(), - chainId: t.integer().notNull().$type(), - address: t.hex().notNull().$type
(), - node: t.hex().notNull().$type(), - - /** - * ENSIP-3 name() record, guaranteed to be an InterpretedName or null if not set. - */ - 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, defaulting to 0. - */ - version: t.bigint().notNull().default(0n), - }), - (t) => ({ byId: uniqueIndex().on(t.chainId, t.address, t.node) }), -); -``` - -`resolverRecords_relations` unchanged. No abi/interface tables. - ---- - -## 3. Interpreters (`@ensnode/ensnode-sdk/internal`) - -Add to existing interpretation module: - -```ts -export function interpretContenthashValue(value: Hex): Hex | null { - return value === "0x" ? null : value; -} - -export function interpretPubkeyValue(x: Hex, y: Hex): { x: Hex; y: Hex } | null { - if (x === zeroHash && y === zeroHash) return null; - return { x, y }; -} - -export function interpretDnszonehashValue(value: Hex): Hex | null { - return value === "0x" ? null : value; -} -``` - ---- - -## 4. Indexer DB helpers (`apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts`) - -```ts -export async function handleResolverContenthashUpdate( - context: IndexingEngineContext, - key: ResolverRecordsCompositeKey, - rawHash: Hex, -) { - const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); - await context.ensDb - .update(ensIndexerSchema.resolverRecords, { id }) - .set({ contenthash: interpretContenthashValue(rawHash) }); -} - -export async function handleResolverPubkeyUpdate( - context: IndexingEngineContext, - key: ResolverRecordsCompositeKey, - x: Hex, - y: Hex, -) { - const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); - const pubkey = interpretPubkeyValue(x, y); - await context.ensDb - .update(ensIndexerSchema.resolverRecords, { id }) - .set({ pubkeyX: pubkey?.x ?? null, pubkeyY: pubkey?.y ?? null }); -} - -export async function handleResolverDnszonehashUpdate( - context: IndexingEngineContext, - key: ResolverRecordsCompositeKey, - rawHash: Hex, -) { - const id = makeResolverRecordsId({ chainId: key.chainId, address: key.address }, key.node); - await context.ensDb - .update(ensIndexerSchema.resolverRecords, { id }) - .set({ dnszonehash: interpretDnszonehashValue(rawHash) }); -} - -/** - * IVersionableResolver VersionChanged: deletes all child records for (chainId, address, node) - * and resets scalar columns. Uses raw drizzle via `context.db.sql` — this flushes Ponder's - * local cache to Postgres, accepted because VersionChanged is rare. - */ -export async function handleResolverVersionChange( - context: IndexingEngineContext, - key: ResolverRecordsCompositeKey, - newVersion: bigint, -) { - const { chainId, address, node } = key; - - await context.db.sql - .delete(ensIndexerSchema.resolverAddressRecord) - .where(and( - eq(ensIndexerSchema.resolverAddressRecord.chainId, chainId), - eq(ensIndexerSchema.resolverAddressRecord.address, address), - eq(ensIndexerSchema.resolverAddressRecord.node, node), - )); - - await context.db.sql - .delete(ensIndexerSchema.resolverTextRecord) - .where(and( - eq(ensIndexerSchema.resolverTextRecord.chainId, chainId), - eq(ensIndexerSchema.resolverTextRecord.address, address), - eq(ensIndexerSchema.resolverTextRecord.node, node), - )); - - const id = makeResolverRecordsId({ chainId, address }, node); - await context.ensDb - .update(ensIndexerSchema.resolverRecords, { id }) - .set({ - name: null, - contenthash: null, - pubkeyX: null, - pubkeyY: null, - dnszonehash: null, - version: newVersion, - }); -} -``` - ---- - -## 5. Handler registrations (`apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts`) - -Append four new listeners. `ABIChanged` and `InterfaceChanged` are intentionally NOT registered. - -```ts -addOnchainEventListener( - namespaceContract(pluginName, "Resolver:ContenthashChanged"), - async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - const key = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, key); - await handleResolverContenthashUpdate(context, key, event.args.hash); - }, -); - -addOnchainEventListener( - namespaceContract(pluginName, "Resolver:PubkeyChanged"), - async ({ context, event }) => { - const { x, y } = event.args; - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - const key = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, key); - await handleResolverPubkeyUpdate(context, key, x, y); - }, -); - -addOnchainEventListener( - namespaceContract(pluginName, "Resolver:DNSZonehashChanged"), - async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - const key = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, key); - await handleResolverDnszonehashUpdate(context, key, event.args.zonehash); - }, -); - -addOnchainEventListener( - namespaceContract(pluginName, "Resolver:VersionChanged"), - async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - const key = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, key); - await handleResolverVersionChange(context, key, event.args.newVersion); - }, -); -``` - ---- - -## 6. SDK selection & response (`packages/ensnode-sdk/src/resolution/`) - -### `resolver-records-selection.ts` - -```ts -import type { CoinType, ContentType, InterfaceId } from "enssdk"; - -export interface ResolverRecordsSelection { - name?: boolean; - addresses?: CoinType[]; - texts?: string[]; - contenthash?: boolean; - pubkey?: boolean; - abi?: ContentType; - interfaces?: InterfaceId[]; - dnszonehash?: boolean; - version?: boolean; -} - -export const isSelectionEmpty = (s: ResolverRecordsSelection) => - !s.name - && !s.addresses?.length - && !s.texts?.length - && !s.contenthash - && !s.pubkey - && !s.dnszonehash - && !s.abi - && !s.interfaces?.length - && !s.version; -``` - -### `resolver-records-response.ts` - -Extend `ResolverRecordsResponseBase`: -```ts -contenthash: Hex | null; -pubkey: { x: Hex; y: Hex } | null; -abi: { contentType: ContentType; data: Hex } | null; -interfaces: Record; -dnszonehash: Hex | null; -version: bigint; -``` - -Extend `ResolverRecordsResponse` mapped type with branches: -- `K extends "interfaces"` → `Record` -- `contenthash` / `pubkey` / `abi` / `dnszonehash` / `version` pass through from base. - ---- - -## 7. Resolution API: calls & interpretation (`apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts`) - -Extend `makeResolveCalls`: -```ts -return [ - selection.name && ({ functionName: "name", args: [node] } as const), - selection.contenthash && ({ functionName: "contenthash", args: [node] } as const), - selection.pubkey && ({ functionName: "pubkey", args: [node] } as const), - selection.dnszonehash && ({ functionName: "zonehash", args: [node] } as const), - selection.version && ({ functionName: "recordVersions", args: [node] } as const), - selection.abi && ({ functionName: "ABI", args: [node, selection.abi] } as const), - ...(selection.addresses ?? []).map(ct => ({ functionName: "addr", args: [node, BigInt(ct)] } as const)), - ...(selection.texts ?? []).map(k => ({ functionName: "text", args: [node, k] } as const)), - ...(selection.interfaces ?? []).map(id => ({ functionName: "interfaceImplementer", args: [node, id] } as const)), -].filter((c): c is Exclude => !!c); -``` - -Extend `interpretRawCallsAndResults` switch: -```ts -case "contenthash": return { call, result: interpretContenthashValue(result as Hex) }; -case "pubkey": { - const [x, y] = result as [Hex, Hex]; - return { call, result: interpretPubkeyValue(x, y) }; -} -case "zonehash": return { call, result: interpretDnszonehashValue(result as Hex) }; -case "recordVersions": return { call, result: result as bigint }; -case "ABI": { - const [contentType, data] = result as [ContentType, Hex]; - return { call, result: data === "0x" ? null : { contentType, data } }; -} -case "interfaceImplementer": { - return { call, result: interpretAddress(result) }; -} -``` - -Update `ResolveCallsAndRawResults` / `ResolveCallsAndResults` conditional type tables to include every new function-name → result-shape mapping. - ---- - -## 8. New: `apps/ensapi/src/lib/resolution/resolve-call-by-index.ts` - -```ts -import { bigintToCoinType } from "enssdk"; -import type { IndexedResolverRecords } from "./make-records-response"; -import type { ResolveCall } from "./resolve-calls-and-results"; - -/** - * (undefined means not accelerated) - */ -export function resolveCallByIndex( - call: C, - records: IndexedResolverRecords | null, -): unknown | null | undefined { - switch (call.functionName) { - case "name": - return records?.name ?? null; - case "addr": { - const ct = bigintToCoinType(call.args[1] as bigint); - const found = records?.addressRecords.find(r => bigintToCoinType(r.coinType) === ct); - return found?.value ?? null; - } - case "text": { - const key = call.args[1] as string; - const found = records?.textRecords.find(r => r.key === key); - return found?.value ?? null - } - case "contenthash": return records?.contenthash ?? null; - case "pubkey": return (records?.pubkeyX && records?.pubkeyY) ? {x: records.pubkeyX, y: records.pubkeyY} : null; - case "zonehash": return records?.dnszonehash ?? null; - case "recordVersions": return records?.version ?? 0n; - case "ABI": - case "interfaceImplementer": - return undefined; - } -} -``` - ---- - -## 9. Index read (`apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts`) - -Shape-only change. Existing address-record defaulting logic preserved. - -```ts -const row = (await ensDb.query.resolverRecords.findFirst({ - where: (t, { and, eq }) => and( - eq(t.chainId, resolver.chainId), - eq(t.address, resolver.address), - eq(t.node, node), - ), - columns: { - name: true, - contenthash: true, - pubkeyX: true, - pubkeyY: true, - dnszonehash: true, - version: true, - }, - with: { addressRecords: true, textRecords: true }, -})) as IndexedResolverRecords | undefined; - -if (!row) return null; - -// existing address-record defaulting block unchanged - -return records; -``` - ---- - -## 10. Response shaping (`apps/ensapi/src/lib/resolution/make-records-response.ts`) - -Extend `IndexedResolverRecords`: -```ts -export interface IndexedResolverRecords { - name: string | null; - addressRecords: { coinType: bigint; value: string }[]; - textRecords: { key: string; value: string }[]; - contenthash: Hex | null; - pubkeyX: Hex; - pubkeyY: Hex; - dnszonehash: Hex | null; - version: bigint; -} -``` - -**Delete** `makeRecordsResponseFromIndexedRecords`. - -Rewrite `makeEmptyResolverRecordsResponse`: -```ts -export function makeEmptyResolverRecordsResponse(selection: S) { - return makeRecordsResponseFromResolveResults(selection, []); -} -``` - -Extend `makeRecordsResponseFromResolveResults` with branches per new selection field: -- `selection.contenthash` → find `functionName === "contenthash"`; null if absent. -- `selection.pubkey` → find `functionName === "pubkey"`; null if absent. -- `selection.dnszonehash` → find `functionName === "zonehash"`; null if absent. -- `selection.version` → find `functionName === "recordVersions"`; default `0n` if absent. -- `selection.abi` → find `functionName === "ABI"`; the interpreted result is already `{ contentType, data } | null`. Pass through. -- `selection.interfaces` → for each selected `InterfaceId`, find `interfaceImplementer` result where `args[1] === id`. Key by `id`. - ---- - -## 11. Linear flow with a single RPC callsite (`apps/ensapi/src/lib/resolution/forward-resolution.ts`) - -Restructure `_resolveForward` into a linear pipeline of **passes over an `operations` array**. Each entry is either unresolved (`{ call }`) or resolved (`{ call, result }`). Each pass takes `operations`, touches only unresolved entries, and returns the new `operations`. The terminal RPC pass resolves anything still unresolved. - -1. **Identify resolver** (findResolver). -2. **Accelerate** — one pass per strategy, each layering onto `operations`. -3. **RPC anything left** — single `executeResolveCalls` callsite resolves unresolved entries. - -Early returns (kept — distinct resolution models, not per-call passes): -- ENSv2 UniversalResolver bailout (unchanged; uses `executeResolveCallsWithUniversalResolver`; TODO to re-integrate once acceleration supports ENSv2). -- Empty selection → `makeEmptyResolverRecordsResponse`. -- No active resolver → `makeEmptyResolverRecordsResponse`. -- Bridged resolver → recursive `_resolveForward` with redirected registry (still one RPC per outer frame). - -### Operation type - -Shared across passes. Lives next to `ResolveCall` (e.g. `resolve-calls-and-results.ts`): - -```ts -export type Operation = { call: ResolveCall; result?: unknown }; -// `result === undefined` (or absent) = unresolved; any other value (including null) = resolved. -``` - -### Skeleton - -```ts -// (validation, empty-selection short-circuit, ENSv2 bailout — unchanged) - -// 1. Identify resolver -const { activeName, activeResolver, requiresWildcardSupport } = await findResolver(...); -if (!activeResolver) return makeEmptyResolverRecordsResponse(selection); - -// Bridged resolver → redirect -if (accelerate && canAccelerate) { - const bridgesTo = isBridgedResolver(config.namespace, { chainId, address: activeResolver }); - if (bridgesTo) { - return withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, - {}, - () => _resolveForward(name, selection, { ...options, registry: bridgesTo }), - ); - } -} - -// Initialize operations as unresolved -let operations: Operation[] = calls.map(call => ({ call })); - -// 2. Accelerate if possible — each strategy is its own pass -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)) { - operations = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, - {}, - () => accelerateKnownOnchainStaticResolver({ operations, resolver, node, selection }), - ); - } -} - -// Early return if every operation is resolved — no RPC needed -if (operations.every(op => op.result !== undefined)) { - return makeRecordsResponseFromResolveResults(selection, operations); -} - -// 3. Determine Resolver ENSIP-10 support + requirement -const extended = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.RequireResolver, - { chainId, activeResolver, requiresWildcardSupport }, - () => isExtendedResolver({ address: activeResolver, publicClient }), -); -if (requiresWildcardSupport && !extended) return makeEmptyResolverRecordsResponse(selection); - -// 4. Resolve remaining unresolved operations via RPC -operations = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.ExecuteResolveCalls, - {}, - () => executeResolveCalls({ - name, - resolverAddress: activeResolver, - useENSIP10Resolve: extended, - operations, - publicClient, - }), -); - -return makeRecordsResponseFromResolveResults(selection, operations); -``` - -### `executeResolveCalls` (updated signature) - -Now a pass over `operations` — only RPCs entries that remain unresolved. - -```ts -return Promise.all(operations.map(async (op) => { - if (op.result !== undefined) return op; - - // existing rpc logic for op.call, producing raw result - const rawResult = await /* ... */; - - return interpretRawRpcCallAndResult(op.call, rawResult); -})); -``` - -`interpretRawRpcCallAndResult(call, raw): Operation` is a per-call interpreter — split from the existing batch `interpretRawCallsAndResults` so it fits naturally inside the per-operation `Promise.all`. - -### New helper: `apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts` - -```ts -export async function accelerateENSIP19ReverseResolver({ - operations, name, selection, -}: { - operations: Operation[]; - name: InterpretedName; - selection: ResolverRecordsSelection; -}): Promise { - // Invariant: ENSIP-19 reverse resolvers only answer `name`. - if (selection.name !== true) { - throw new Error( - `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(selection)}.`, - ); - } - - // Sanity: any non-`name` selection would fail on a reverse resolver anyway. - const { name: _n, ...extras } = selection; - if (!isSelectionEmpty(extras)) { - logger.warn( - `Sanity Check(ENSIP-19 Reverse Resolver): expected '{ name: true }' only, got ${JSON.stringify(selection)}.`, - ); - } - - return Promise.all(operations.map(async (op) => { - // Passthrough resolved entries and non-`name` calls. - if (op.result !== undefined) return op; - if (op.call.functionName !== "name") return op; - - // Parse + index lookup happen only for the `name` call. - 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); - return { call: op.call, result }; - })); -} -``` - -### New helper: `apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts` - -```ts -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 => { - // Passthrough resolved entries. - if (op.result !== undefined) return op; - - const result = resolveCallByIndex(op.call, records); - // `undefined` means this call type isn't accelerable (e.g. ABI, interfaceImplementer). - return result === undefined ? op : { call: op.call, result }; - }); -} -``` - -### Properties - -- **One RPC callsite** in `_resolveForward` (excluding the ENSv2 bailout). -- **Composable passes**: adding a new acceleration strategy = one more `operations = await (operations, ...)` line. No partitioning bookkeeping. -- **Tracing preserved**: `AccelerateENSIP19ReverseResolver`, `AccelerateKnownOnchainStaticResolver`, `AccelerateKnownOffchainLookupResolver`, `RequireResolver`, `ExecuteResolveCalls` steps fire in positions equivalent to today. -- **No acceleration possible**: every operation stays unresolved through the accel passes and flows to the single RPC callsite — same behavior as today's pure-fallback branch. - -### Future (out of scope) - -The ENSv2 `executeResolveCallsWithUniversalResolver` bailout can be collapsed into the same linear flow as another terminal `Operation[]`-shaped pass (UniversalResolverV2 vs direct resolver calls). Follow-up once ENSv2 acceleration re-lands. - ---- - -## 12. Changeset - -Add one changeset: - -```md ---- -"@ensnode/ensdb-sdk": minor -"@ensnode/ensnode-sdk": minor -"@ensnode/ensindexer": minor -"@ensnode/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. -``` - ---- - -## 13. Tests - -### Unit -- `packages/ensnode-sdk/src/internal/interpret-*.test.ts` — sentinel → null for contenthash, pubkey, dnszonehash. -- `apps/ensapi/src/lib/resolution/make-records-response.test.ts` — `makeRecordsResponseFromResolveResults` shapes match every selection combination; `makeEmptyResolverRecordsResponse` returns correct nulls. - -### Integration -- Indexer: fixture resolver emitting `ContenthashChanged` / `PubkeyChanged` / `DNSZonehashChanged` / `AddressChanged` / `TextChanged` / `VersionChanged` in order. Assert final DB state including reset scalars, cleared child rows, bumped version. -- Resolution API: real static resolver with records set for each type. Run with `{ accelerate: true }` and `{ accelerate: false }`. Assert identical responses (parity). -- Resolution API: mixed selection `{ name, addresses, texts, contenthash, abi, interfaces }` exercises both legs of hybrid path in one request. ABI-specific case: set JSON + CBOR for a node, query with `abi: 1n | 4n`, assert the returned `contentType` is `1n` (lowest matching bit wins). - -### Commands -``` -pnpm -F @ensnode/ensindexer -F @ensnode/ensapi -F @ensnode/ensnode-sdk -F @ensnode/ensdb-sdk -F enssdk typecheck -pnpm lint -pnpm test --project ensindexer --project ensapi --project ensnode-sdk --project ensdb-sdk --project enssdk -pnpm test:integration -``` - ---- - -## 14. PR ordering - -1. Semantic types in `enssdk` (§1). -2. Interpreters in `@ensnode/ensnode-sdk/internal` (§3). -3. SDK selection + response types (§6). -4. Schema update (§2) + indexer DB helpers (§4) + handler registrations (§5). -5. Resolution API plumbing: `resolve-calls-and-results` (§7, includes new `Operation` type + split `interpretRawRpcCallAndResult`), `resolve-call-by-index` (§8), `get-records-from-index` (§9), `make-records-response` (§10), new `accelerate-ensip19-reverse-resolver.ts` + `accelerate-known-onchain-static-resolver.ts` + restructured `forward-resolution.ts` with `let operations` passes (§11). -6. Delete `makeRecordsResponseFromIndexedRecords` once no callers remain. -7. Tests (§13), changeset (§12). - -Single PR. Branch from current `fix/name-index` parent or `main` per dependency. - ---- - -## Unresolved questions - -_(none — all prior questions resolved)_ 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..8380bc6ec --- /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. + */ +export async function accelerateENSIP19ReverseResolver({ + operations, + name, + selection, +}: { + operations: Operation[]; + name: InterpretedName; + selection: ResolverRecordsSelection; +}): Promise { + // Invariant: consumer must be selecting the `name` record at this point + if (selection.name !== true) { + throw new Error( + `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(selection)}.`, + ); + } + + return Promise.all( + operations.map(async (op) => { + if (isOperationResolved(op)) return op; + if (op.functionName !== "name") return op; + + 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); + return { ...op, result }; + }), + ); +} 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..e1769cf02 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -0,0 +1,79 @@ +import { type AccountId, bigintToCoinType, type Node } from "enssdk"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; +import { interpretPubkeyValue } from "@ensnode/ensnode-sdk/internal"; + +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 ct = bigintToCoinType(op.args[1]); + const found = records?.addressRecords.find((r) => bigintToCoinType(r.coinType) === ct); + 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 + ? interpretPubkeyValue(records.pubkeyX, records.pubkeyY) + : null, + }; + case "zonehash": + return { ...op, result: records?.dnszonehash ?? null }; + case "recordVersions": + return { ...op, result: records?.version ?? 0n }; + case "ABI": + case "interfaceImplementer": + return op; + } +} diff --git a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts b/apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts similarity index 56% rename from apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts rename to apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts index 0965ce984..61b4acd76 100644 --- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts @@ -20,10 +20,8 @@ import { } from "@ensnode/ensnode-sdk"; import { lazy } from "@/lib/lazy"; -import type { - ResolveCalls, - ResolveCallsAndRawResults, -} from "@/lib/resolution/resolve-calls-and-results"; +import { interpretOperationWithRawResult } from "@/lib/resolution/execute-operations"; +import { isOperationResolved, type Operations } from "@/lib/resolution/operations"; const getUniversalResolverV1 = lazy(() => getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), @@ -34,25 +32,36 @@ const getUniversalResolverV2 = lazy(() => ); /** - * Execute a set of ResolveCalls for `name` against the UniversalResolver. + * Execute a set of Operations for `name` against the UniversalResolver. + * + * NOTE: this exists just for the ENSv2 bailout, will be removed once forward-resolution is updated + * for ENSv2 (and interpretOperationWithRawResult can be un-exported). */ -export async function executeResolveCallsWithUniversalResolver< +export async function executeOperationsWithUniversalResolver< SELECTION extends ResolverRecordsSelection, >({ name, - calls, + operations, publicClient, }: { name: InterpretedName; - calls: ResolveCalls; + operations: Operations; publicClient: PublicClient; -}): Promise> { +}): Promise> { // NOTE: automatically multicalled by viem return await Promise.all( - calls.map(async (call) => { + operations.map(async (op) => { + if (isOperationResolved(op)) return op; + try { const encodedName = bytesToHex(packetToBytes(name)); // DNS-encode `name` for resolve() - const encodedMethod = encodeFunctionData({ abi: ResolverABI, ...call }); + // 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]); const [value] = await publicClient.readContract({ abi: UniversalResolverABI, @@ -63,32 +72,20 @@ export async function executeResolveCallsWithUniversalResolver< 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" }; - } + if (size(value) === 0) return interpretOperationWithRawResult(op, null); // ENSIP-10 — resolve() always returns bytes that need to be decoded const results = decodeAbiParameters( - getAbiItem({ abi: ResolverABI, name: call.functionName, args: call.args }).outputs, + getAbiItem({ abi: ResolverABI, name: op.functionName, args: op.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})`, - }; + // Some calls (ABI, pubkey) return a tuple; single-output calls unwrap. + const raw = results.length === 1 ? results[0] : results; + return interpretOperationWithRawResult(op, raw); } catch (error) { - // in general, reverts are expected behavior if (error instanceof ContractFunctionExecutionError) { - return { call, result: null, reason: error.shortMessage }; + return interpretOperationWithRawResult(op, null); } - - // otherwise, rethrow throw error; } }), 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..2556b6996 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/execute-operations.ts @@ -0,0 +1,168 @@ +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 is interpreted as "no record" for record-style calls; `recordVersions` defaults to + * `0n` because IVersionableResolver treats an uninitialized version as zero. + */ +export function interpretOperationWithRawResult(call: Operation, raw: unknown): Operation { + if (raw === null) { + if (call.functionName === "recordVersions") return { ...call, result: 0n }; + 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..48fddb18b 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -10,7 +10,6 @@ import { isNormalizedName, type Node, namehashInterpretedName, - parseReverseName, } from "enssdk"; import { @@ -18,9 +17,7 @@ import { ForwardResolutionProtocolStep, type ForwardResolutionResult, getENSv1Registry, - isSelectionEmpty, PluginName, - type ResolverRecordsResponse, type ResolverRecordsSelection, TraceableENSProtocol, } from "@ensnode/ensnode-sdk"; @@ -34,22 +31,14 @@ import { import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; 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 { executeOperationsWithUniversalResolver } from "@/lib/resolution/execute-operations-with-universal-resolver"; +import { makeRecordsResponse } from "@/lib/resolution/make-records-response"; +import { isOperationResolved, logOperations, makeOperations } from "@/lib/resolution/operations"; import { addEnsProtocolStepEvent, withEnsProtocolStep, @@ -66,26 +55,6 @@ const tracer = trace.getTracer("forward-resolution"); * @param options Optional settings * @param options.accelerate Whether acceleration is requested (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) - * - * @example - * await resolveForward("jesse.base.eth", { - * name: true, - * addresses: [evmChainIdToCoinType(mainnet.id), evmChainIdToCoinType(base.id)], - * texts: ["com.twitter", "description"], - * }) - * - * // results in - * { - * name: 'jesse.base.eth', - * addresses: { - * 60: '0x849151d7D0bF1F34b70d5caD5149D28CC2308bf1', - * 2147492101: null - * }, - * texts: { - * 'com.twitter': 'jessepollak', - * description: 'base.eth builder #001' - * } - * } */ export async function resolveForward( name: ForwardResolutionArgs["name"], @@ -144,14 +113,10 @@ async function _resolveForward( // TODO: technically InterpretedNames are not resolvable, since ENS contracts are not // encoded-labelhash-aware; so we add a temporary additional constraint on name that it // must be fully normalized (and therefore not contain encoded labelhash segments) - // (this will be improved in a future pr https://github.com/namehash/ensnode/issues/1920) if (!isNormalizedName(name)) { throw new Error(`'${name}' must be normalized to be resolvable.`); } - // TODO: technically we could support resolving records for the root node, but because there - // are so many edge cases, this is something we should explicitly declare support for - // after we have test cases if (name === ENS_ROOT_NAME) { throw new Error( `Resolving records for the ENS Root Node ('') is not currently supported.`, @@ -161,57 +126,40 @@ 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( + operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, {}, () => - executeResolveCallsWithUniversalResolver({ + executeOperationsWithUniversalResolver({ name, - calls, + operations, publicClient, }), ); - // additional semantic interpretation of the raw results from the chain - const results = interpretRawCallsAndResults(rawResults); + logOperations(operations, logger); - if (process.env.NODE_ENV !== "production") { - console.table(tablifyCallResults(rawResults, results)); - } else { - logger.debug({ rawResults, results }); - } - - // return record values - return makeRecordsResponseFromResolveResults(selection, results); + 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 +182,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 +195,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 +216,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 +280,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(foward-resolution): Not all operations were resolved at the end of resolution!\n${JSON.stringify(operations)}`, + ); } // 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..bcc015853 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: 0n, + 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..a1898458c 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.ts @@ -6,106 +6,54 @@ 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": + // NOTE: recordVersions defaults to 0n + memo.version = op.result ?? 0n; + 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..20190d12a --- /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 + * `interpretRawRpcCallAndResult` 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 }; + 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.integration.test.ts b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts deleted file mode 100644 index 36f46ad58..000000000 --- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.integration.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 -vi.mock("@/config", () => ({ - default: { - namespace: ENSNamespaceIds.EnsTestEnv, - rpcConfigs: new Map([[ensTestEnvChain.id, { httpRPCs: [new URL("http://localhost:8545")] }]]), - }, -})); - -import { - asInterpretedLabel, - asInterpretedName, - asLiteralLabel, - encodeLabelHash, - interpretedLabelsToInterpretedName, - labelhashLiteralLabel, - namehashInterpretedName, -} from "enssdk"; -import { describe, expect, it } from "vitest"; - -import { getPublicClient } from "@/lib/public-client"; -import { makeResolveCalls } from "@/lib/resolution/resolve-calls-and-results"; - -import { executeResolveCallsWithUniversalResolver } from "./resolve-with-universal-resolver"; - -const NAME = asInterpretedName("example.eth"); -const NAME_WITH_ENCODED_LABELHASHES = interpretedLabelsToInterpretedName([ - asInterpretedLabel(encodeLabelHash(labelhashLiteralLabel(asLiteralLabel("example")))), - asInterpretedLabel("eth"), -]); - -const EXPECTED_DESCRIPTION = "example.eth"; - -const publicClient = getPublicClient(ensTestEnvChain.id); - -describe("executeResolveCallsWithUniversalResolver", () => { - it("should resolve interpreted name without encoded labelhashes", async () => { - await expect( - executeResolveCallsWithUniversalResolver({ - name: NAME, - calls: makeResolveCalls(namehashInterpretedName(NAME), { texts: ["description"] }), - publicClient, - }), - ).resolves.toMatchObject([{ result: EXPECTED_DESCRIPTION }]); - }); - - /** - * NOTE(shrugs): This was contrary to my expectations, but the NameCoder (in both ENSv1 and ENSv2) - * is NOT EncodedLabelHash-aware: all label segments are hashed indiscriminately as LiteralLabels - * to traverse the nametree, meaning that InterpretedNames (which may include EncodedLabelHash - * segments for labels that are unknown, too long, or unnormalized) are explicitly unresolvable! - * - * Or, more technically, they resolve to an incorrect name, one addressed by, for example: - * [root, labelhash("eth"), labelhash("[6fd43e7cffc31bb581d7421c8698e29aa2bd8e7186a394b85299908b4eb9b175]")] - * - * Which likely doesn't have the appropriate records set. - */ - it("should NOT resolve interpreted name with encoded labelhashes", async () => { - await expect( - executeResolveCallsWithUniversalResolver({ - name: NAME_WITH_ENCODED_LABELHASHES, - calls: makeResolveCalls(namehashInterpretedName(NAME_WITH_ENCODED_LABELHASHES), { - texts: ["description"], - }), - publicClient, - }), - ).resolves.toMatchObject([{ result: null }]); - }); -}); 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..8d9c93ffe 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts @@ -1,16 +1,22 @@ +import { and, eq } from "drizzle-orm"; import { type AccountId, type Address, type CoinType, + type Hex, type LiteralName, makeResolverId, makeResolverRecordsId, type Node, + type RecordVersion, } from "enssdk"; import { interpretAddressRecordValue, + interpretContenthashValue, + interpretDnszonehashValue, interpretNameRecordValue, + interpretPubkeyValue, interpretTextRecordKey, interpretTextRecordValue, } from "@ensnode/ensnode-sdk/internal"; @@ -159,3 +165,106 @@ 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) }); +} + +/** + * IVersionableResolver VersionChanged: deletes all child records for (chainId, address, node) + * and resets scalar columns. + * + * Uses raw drizzle via `context.ensDb.sql` to perform a bulk delete — this flushes Ponder's + * in-memory cache to Postgres, accepted because VersionChanged is rare. + */ +export async function handleResolverVersionChange( + context: IndexingEngineContext, + resolverRecordsKey: ResolverRecordsCompositeKey, + newVersion: RecordVersion, +) { + const { chainId, address, node } = resolverRecordsKey; + + 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), + ), + ); + + const id = makeResolverRecordsId({ chainId, address }, node); + await context.ensDb.update(ensIndexerSchema.resolverRecords, { id }).set({ + name: null, + contenthash: null, + pubkeyX: null, + pubkeyY: null, + dnszonehash: null, + version: newVersion, + }); +} diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index 21bcf0439..9ddd23bd0 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -11,8 +11,12 @@ import { ensureResolver, ensureResolverRecords, handleResolverAddressRecordUpdate, + handleResolverContenthashUpdate, + handleResolverDnszonehashUpdate, handleResolverNameUpdate, + handleResolverPubkeyUpdate, handleResolverTextRecordUpdate, + handleResolverVersionChange, makeResolverRecordsCompositeKey, } from "@/lib/protocol-acceleration/resolver-db-helpers"; @@ -182,4 +186,63 @@ export default function () { await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, 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 hybrid RPC tail in the Resolution API. + + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:ContenthashChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverContenthashUpdate(context, resolverRecordsKey, event.args.hash); + }, + ); + + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:PubkeyChanged"), + async ({ context, event }) => { + const { x, y } = event.args; + + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverPubkeyUpdate(context, resolverRecordsKey, x, y); + }, + ); + + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:DNSZonehashChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverDnszonehashUpdate(context, resolverRecordsKey, event.args.zonehash); + }, + ); + + addOnchainEventListener( + namespaceContract(pluginName, "Resolver:VersionChanged"), + async ({ context, event }) => { + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverVersionChange(context, resolverRecordsKey, 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/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts index a1b335f1b..3429fd947 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,29 @@ 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, defaulting to 0. + */ + version: t.bigint().notNull().default(0n).$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/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 163ff7545..086aebf8b 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -36,7 +36,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..2d72278fc 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,38 @@ 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, defaulting to 0n. + */ + version: RecordVersion; }; /** @@ -54,12 +86,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 | 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..bddb7d134 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 && + !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/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..95c126f1b --- /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. `0n` is the uninitialized default; `VersionChanged` + * bumps this value and 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; From 825d8ade52024b15592a599d770c479e25cdb142 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:10:39 -0500 Subject: [PATCH 04/18] DRY up handlers with ensureResolverAndRecords --- .../resolver-db-helpers.ts | 51 ++----- .../handlers/Resolver.ts | 139 +++++------------- 2 files changed, 47 insertions(+), 143 deletions(-) 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 8d9c93ffe..daa591a23 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts @@ -1,6 +1,5 @@ import { and, eq } from "drizzle-orm"; import { - type AccountId, type Address, type CoinType, type Hex, @@ -21,6 +20,7 @@ import { 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"; @@ -33,54 +33,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; } /** diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index 9ddd23bd0..877cc73b6 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -4,12 +4,10 @@ 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 { namespaceContract } from "@/lib/plugin-helpers"; import { - ensureResolver, - ensureResolverRecords, + ensureResolverAndRecords, handleResolverAddressRecordUpdate, handleResolverContenthashUpdate, handleResolverDnszonehashUpdate, @@ -17,7 +15,6 @@ import { handleResolverPubkeyUpdate, handleResolverTextRecordUpdate, handleResolverVersionChange, - makeResolverRecordsCompositeKey, } from "@/lib/protocol-acceleration/resolver-db-helpers"; const pluginName = PluginName.ProtocolAcceleration; @@ -30,15 +27,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); }, ); @@ -55,28 +46,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)); }, ); @@ -86,11 +65,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 { @@ -98,17 +76,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); }, ); @@ -118,15 +91,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); }, ); @@ -138,16 +105,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 resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); + const { key: textKey, value } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - 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); }, ); @@ -158,32 +120,22 @@ 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 resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); + const { key: textKey, value } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - 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); }, ); addOnchainEventListener( namespaceContract(pluginName, "Resolver:DNSRecordDeleted"), async ({ context, event }) => { - const { key } = parseDnsTxtRecordArgs(event.args); - if (key === null) return; // no key to operate over? args were malformed, ignore event - - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); + const { key: textKey } = parseDnsTxtRecordArgs(event.args); + if (textKey === null) return; // no key to operate over? args were malformed, ignore event - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, null); + const recordsKey = await ensureResolverAndRecords(context, event); + await handleResolverTextRecordUpdate(context, recordsKey, textKey, null); }, ); @@ -195,13 +147,8 @@ export default function () { addOnchainEventListener( namespaceContract(pluginName, "Resolver:ContenthashChanged"), async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverContenthashUpdate(context, resolverRecordsKey, event.args.hash); + const key = await ensureResolverAndRecords(context, event); + await handleResolverContenthashUpdate(context, key, event.args.hash); }, ); @@ -209,40 +156,24 @@ export default function () { namespaceContract(pluginName, "Resolver:PubkeyChanged"), async ({ context, event }) => { const { x, y } = event.args; - - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverPubkeyUpdate(context, resolverRecordsKey, x, y); + const key = await ensureResolverAndRecords(context, event); + await handleResolverPubkeyUpdate(context, key, x, y); }, ); addOnchainEventListener( namespaceContract(pluginName, "Resolver:DNSZonehashChanged"), async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverDnszonehashUpdate(context, resolverRecordsKey, event.args.zonehash); + const key = await ensureResolverAndRecords(context, event); + await handleResolverDnszonehashUpdate(context, key, event.args.zonehash); }, ); addOnchainEventListener( namespaceContract(pluginName, "Resolver:VersionChanged"), async ({ context, event }) => { - const resolver = getThisAccountId(context, event); - await ensureResolver(context, resolver); - - const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); - await ensureResolverRecords(context, resolverRecordsKey); - - await handleResolverVersionChange(context, resolverRecordsKey, event.args.newVersion); + const key = await ensureResolverAndRecords(context, event); + await handleResolverVersionChange(context, key, event.args.newVersion); }, ); } From 59db5172b9cebdba7e14abfd40b2c6cc0b711864 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:12:32 -0500 Subject: [PATCH 05/18] fix: update changeset --- .changeset/extend-resolver-records.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/extend-resolver-records.md b/.changeset/extend-resolver-records.md index 10c33eb46..06c13176d 100644 --- a/.changeset/extend-resolver-records.md +++ b/.changeset/extend-resolver-records.md @@ -10,4 +10,4 @@ Resolution API: support `contenthash`, `pubkey`, `abi`, `interfaces`, `dnszoneha 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` semantic types to `enssdk`. +`ContentType` / `InterfaceId` / `RecordVersion` semantic types to `enssdk`. From f859e39ffa24ba53e55640fdc6f2af64b4a454bc Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:12:42 -0500 Subject: [PATCH 06/18] fix: update changeset --- .changeset/extend-resolver-records.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/extend-resolver-records.md b/.changeset/extend-resolver-records.md index 06c13176d..98495ea5e 100644 --- a/.changeset/extend-resolver-records.md +++ b/.changeset/extend-resolver-records.md @@ -6,8 +6,4 @@ "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`. +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`. From c68236f741eded660f929c4d2e66cfd3675da416 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:32:57 -0500 Subject: [PATCH 07/18] feat: wire new selection fields through api surface - params.schema.ts: parse contenthash/pubkey/dnszonehash/version/abi/interfaces from query - zod-schemas.ts: describe wire shape (bigints serialized as strings) - client.ts: encode new selection fields into URL - resolution-api.ts: replaceBigInts on response to avoid JSON.stringify crash Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/api/resolution/resolution-api.ts | 4 +- apps/ensapi/src/lib/handlers/params.schema.ts | 48 +++++++++++++++++++ .../handlers/Resolver.ts | 3 +- .../src/ensapi/api/resolution/zod-schemas.ts | 12 ++++- packages/ensnode-sdk/src/ensapi/client.ts | 13 +++-- 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index edfce5611..0231de85d 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,8 @@ app.openapi(resolveRecordsRoute, async (c) => { ...(showTrace && { trace }), } satisfies ResolveRecordsResponse; - return c.json(response); + // serialize bigints (e.g. `records.version`, `records.abi.contentType`) as strings + return c.json(replaceBigInts(response, String)); }); /** diff --git a/apps/ensapi/src/lib/handlers/params.schema.ts b/apps/ensapi/src/lib/handlers/params.schema.ts index 489ad728f..04be0877e 100644 --- a/apps/ensapi/src/lib/handlers/params.schema.ts +++ b/apps/ensapi/src/lib/handlers/params.schema.ts @@ -44,16 +44,58 @@ const chainIdsWithoutDefaultChainId = z.optional( stringarray.pipe(z.array(defaultableChainId.pipe(excludingDefaultChainId))), ); +const contentTypeBitmask = z + .string() + .transform((val, ctx) => { + try { + const n = BigInt(val); + if (n <= 0n) { + ctx.issues.push({ + code: "custom", + message: "Must be a positive integer.", + input: val, + }); + return z.NEVER; + } + return n; + } 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() + .regex(/^0x[0-9a-f]{8}$/i, "Must be a 4-byte hex (0x + 8 hex chars)") + .transform((val) => val.toLowerCase() as `0x${string}`); + const rawSelectionParams = z.object({ name: z.string().optional(), addresses: z.string().optional(), texts: z.string().optional(), + 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; @@ -66,6 +108,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/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index 877cc73b6..f12c6bcc4 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -142,7 +142,8 @@ export default function () { // 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 hybrid RPC tail in the Resolution API. + // 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:ContenthashChanged"), diff --git a/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts index e3efbec67..9e50eda01 100644 --- a/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensapi/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().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/ensapi/client.ts b/packages/ensnode-sdk/src/ensapi/client.ts index f8822f519..423df3784 100644 --- a/packages/ensnode-sdk/src/ensapi/client.ts +++ b/packages/ensnode-sdk/src/ensapi/client.ts @@ -162,9 +162,12 @@ export class EnsApiClient { 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(",")); @@ -174,6 +177,10 @@ export class EnsApiClient { 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"); From a50d37baecb393d48102be02983eb051e56f38db Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 15:36:56 -0500 Subject: [PATCH 08/18] fix: address bot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forward-resolution: typo foward→forward, bigint-safe stringify in invariant error message and tracing selectionString - accelerate-ensip19: bigint-safe stringify on selection; hoist parseReverseName out of the per-op loop - isSelectionEmpty: abi uses === undefined to match makeOperations - accelerate-known-onchain-static-resolver: compare addr coinType bigints directly (bigintToCoinType throws on non-standard coinTypes stored in the index, would crash the accelerator) - operations.ts: fix stale JSDoc reference to interpretRawRpcCallAndResult Co-Authored-By: Claude Opus 4.7 (1M context) --- .../accelerate-ensip19-reverse-resolver.ts | 21 +++++++++++-------- ...ccelerate-known-onchain-static-resolver.ts | 6 +++--- .../src/lib/resolution/forward-resolution.ts | 5 +++-- apps/ensapi/src/lib/resolution/operations.ts | 2 +- .../resolution/resolver-records-selection.ts | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts index 8380bc6ec..e415a2891 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts @@ -1,3 +1,4 @@ +import { replaceBigInts } from "@ponder/utils"; import type { InterpretedName } from "enssdk"; import { parseReverseName } from "enssdk"; @@ -19,10 +20,19 @@ export async function accelerateENSIP19ReverseResolver({ name: InterpretedName; selection: ResolverRecordsSelection; }): Promise { - // Invariant: consumer must be selecting the `name` record at this point + // Invariant: consumer must be selecting the `name` record at this point. + // `selection` may contain bigints (e.g. `abi: ContentType`); stringify safely. if (selection.name !== true) { throw new Error( - `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(selection)}.`, + `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(replaceBigInts(selection, String))}.`, + ); + } + + // parse once up-front — a reverse-resolver selection only ever produces one `name` op + const parsed = parseReverseName(name); + if (!parsed) { + throw new Error( + `Invariant(ENSIP-19 Reverse Resolver): expected a valid reverse name, got '${name}'.`, ); } @@ -31,13 +41,6 @@ export async function accelerateENSIP19ReverseResolver({ if (isOperationResolved(op)) return op; if (op.functionName !== "name") return op; - 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); return { ...op, result }; }), 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 index e1769cf02..d1312b388 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -1,4 +1,4 @@ -import { type AccountId, bigintToCoinType, type Node } from "enssdk"; +import type { AccountId, Node } from "enssdk"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { interpretPubkeyValue } from "@ensnode/ensnode-sdk/internal"; @@ -49,8 +49,8 @@ function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Oper case "name": return { ...op, result: records?.name ?? null }; case "addr": { - const ct = bigintToCoinType(op.args[1]); - const found = records?.addressRecords.find((r) => bigintToCoinType(r.coinType) === ct); + const coinType = op.args[1]; + const found = records?.addressRecords.find((r) => r.coinType === coinType); return { ...op, result: found?.value ?? null }; } case "text": { diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 48fddb18b..0b55e3e0c 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -87,7 +87,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( @@ -315,7 +316,7 @@ async function _resolveForward( // Invariant: all operations must be resolved if (!operations.every(isOperationResolved)) { throw new Error( - `Invariant(foward-resolution): Not all operations were resolved at the end of resolution!\n${JSON.stringify(operations)}`, + `Invariant(forward-resolution): Not all operations were resolved at the end of resolution!\n${JSON.stringify(replaceBigInts(operations, String))}`, ); } diff --git a/apps/ensapi/src/lib/resolution/operations.ts b/apps/ensapi/src/lib/resolution/operations.ts index 20190d12a..e49febd39 100644 --- a/apps/ensapi/src/lib/resolution/operations.ts +++ b/apps/ensapi/src/lib/resolution/operations.ts @@ -14,7 +14,7 @@ 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 - * `interpretRawRpcCallAndResult` will force you to handle it. + * `interpretOperationWithRawResult` will force you to handle it. */ type OperationMap = { name: { args: readonly [Node]; result: InterpretedName | null }; diff --git a/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts b/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts index bddb7d134..9059a883b 100644 --- a/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts +++ b/packages/ensnode-sdk/src/resolution/resolver-records-selection.ts @@ -61,6 +61,6 @@ export const isSelectionEmpty = (selection: ResolverRecordsSelection) => !selection.contenthash && !selection.pubkey && !selection.dnszonehash && - !selection.abi && + selection.abi === undefined && !selection.interfaces?.length && !selection.version; From a552d3d0eb80340f08105bb9782d9089b004bd34 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:18:36 -0500 Subject: [PATCH 09/18] refactor: inline VersionChanged handler body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit single call site — the helper didn't pull its weight. scalar reset + raw sql bulk deletes now live directly next to the event registration. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensapi/src/lib/handlers/params.schema.ts | 22 ++++---- .../accelerate-ensip19-reverse-resolver.ts | 14 ++--- .../resolver-db-helpers.ts | 47 ----------------- .../handlers/Resolver.ts | 51 +++++++++++++++++-- 4 files changed, 64 insertions(+), 70 deletions(-) diff --git a/apps/ensapi/src/lib/handlers/params.schema.ts b/apps/ensapi/src/lib/handlers/params.schema.ts index 04be0877e..b00df7ce6 100644 --- a/apps/ensapi/src/lib/handlers/params.schema.ts +++ b/apps/ensapi/src/lib/handlers/params.schema.ts @@ -1,5 +1,11 @@ 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, + isNormalizedName, + type Name, +} from "enssdk"; import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { @@ -49,21 +55,15 @@ const contentTypeBitmask = z .transform((val, ctx) => { try { const n = BigInt(val); - if (n <= 0n) { - ctx.issues.push({ - code: "custom", - message: "Must be a positive integer.", - input: val, - }); - return z.NEVER; - } - return n; + 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; } }) @@ -72,7 +72,7 @@ const contentTypeBitmask = z const interfaceId = z .string() .regex(/^0x[0-9a-f]{8}$/i, "Must be a 4-byte hex (0x + 8 hex chars)") - .transform((val) => val.toLowerCase() as `0x${string}`); + .transform((val) => val.toLowerCase() as InterfaceId); const rawSelectionParams = z.object({ name: z.string().optional(), diff --git a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts index e415a2891..f6c3c016c 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts @@ -5,7 +5,7 @@ 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"; +import type { Operation } from "@/lib/resolution/operations"; /** * Acceleration pass for a Known ENSIP-19 Reverse Resolver, retrieving the Primary Name from @@ -28,7 +28,7 @@ export async function accelerateENSIP19ReverseResolver({ ); } - // parse once up-front — a reverse-resolver selection only ever produces one `name` op + // parse the Reverse Name into { address, coinType } const parsed = parseReverseName(name); if (!parsed) { throw new Error( @@ -36,13 +36,13 @@ export async function accelerateENSIP19ReverseResolver({ ); } + const result = await getENSIP19ReverseNameRecordFromIndex(parsed.address, parsed.coinType); + + // resolve the 'name' operation with the indexed result, passing other along as-is return Promise.all( operations.map(async (op) => { - if (isOperationResolved(op)) return op; - if (op.functionName !== "name") return op; - - const result = await getENSIP19ReverseNameRecordFromIndex(parsed.address, parsed.coinType); - return { ...op, result }; + if (op.functionName === "name") return { ...op, result }; + return op; }), ); } 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 daa591a23..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,4 +1,3 @@ -import { and, eq } from "drizzle-orm"; import { type Address, type CoinType, @@ -7,7 +6,6 @@ import { makeResolverId, makeResolverRecordsId, type Node, - type RecordVersion, } from "enssdk"; import { @@ -196,48 +194,3 @@ export async function handleResolverDnszonehashUpdate( .update(ensIndexerSchema.resolverRecords, { id }) .set({ dnszonehash: interpretDnszonehashValue(rawHash) }); } - -/** - * IVersionableResolver VersionChanged: deletes all child records for (chainId, address, node) - * and resets scalar columns. - * - * Uses raw drizzle via `context.ensDb.sql` to perform a bulk delete — this flushes Ponder's - * in-memory cache to Postgres, accepted because VersionChanged is rare. - */ -export async function handleResolverVersionChange( - context: IndexingEngineContext, - resolverRecordsKey: ResolverRecordsCompositeKey, - newVersion: RecordVersion, -) { - const { chainId, address, node } = resolverRecordsKey; - - 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), - ), - ); - - const id = makeResolverRecordsId({ chainId, address }, node); - await context.ensDb.update(ensIndexerSchema.resolverRecords, { id }).set({ - name: null, - contenthash: null, - pubkeyX: null, - pubkeyY: null, - dnszonehash: null, - version: newVersion, - }); -} diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index f12c6bcc4..a3be205f2 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -1,10 +1,17 @@ -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 { addOnchainEventListener } from "@/lib/indexing-engines/ponder"; +import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engines/ponder"; import { namespaceContract } from "@/lib/plugin-helpers"; import { ensureResolverAndRecords, @@ -14,7 +21,6 @@ import { handleResolverNameUpdate, handleResolverPubkeyUpdate, handleResolverTextRecordUpdate, - handleResolverVersionChange, } from "@/lib/protocol-acceleration/resolver-db-helpers"; const pluginName = PluginName.ProtocolAcceleration; @@ -170,11 +176,46 @@ export default function () { }, ); + // 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 key = await ensureResolverAndRecords(context, event); - await handleResolverVersionChange(context, key, event.args.newVersion); + 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, + }); }, ); } From 2fcbc1d9f9d07204f94dcb74057252d533b67dbd Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:28:19 -0500 Subject: [PATCH 10/18] extract isInterfaceId to enssdk; inline bigint revival in client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new packages/enssdk/src/lib/interface-id.ts#isInterfaceId type guard (viem isHex + size check, single source of truth) - params.schema.ts: refine uses isInterfaceId, drops local viem imports - client.ts#resolveRecords: inline the 4-line bigint revival, drop the named helper — single callsite, name was more noise than signal Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensapi/src/lib/handlers/params.schema.ts | 3 ++- .../accelerate-ensip19-reverse-resolver.ts | 3 ++- .../accelerate-known-onchain-static-resolver.ts | 4 +--- packages/ensnode-sdk/src/ensapi/client.ts | 16 ++++++++++++++-- packages/enssdk/src/lib/index.ts | 1 + packages/enssdk/src/lib/interface-id.ts | 12 ++++++++++++ 6 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 packages/enssdk/src/lib/interface-id.ts diff --git a/apps/ensapi/src/lib/handlers/params.schema.ts b/apps/ensapi/src/lib/handlers/params.schema.ts index b00df7ce6..980c8e70a 100644 --- a/apps/ensapi/src/lib/handlers/params.schema.ts +++ b/apps/ensapi/src/lib/handlers/params.schema.ts @@ -3,6 +3,7 @@ import { type ContentType, DEFAULT_EVM_CHAIN_ID, type InterfaceId, + isInterfaceId, isNormalizedName, type Name, } from "enssdk"; @@ -71,7 +72,7 @@ const contentTypeBitmask = z const interfaceId = z .string() - .regex(/^0x[0-9a-f]{8}$/i, "Must be a 4-byte hex (0x + 8 hex chars)") + .refine(isInterfaceId, "Must be a 4-byte hex (0x + 8 hex chars)") .transform((val) => val.toLowerCase() as InterfaceId); const rawSelectionParams = z.object({ diff --git a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts index f6c3c016c..694b35c5e 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts @@ -5,7 +5,7 @@ import { parseReverseName } from "enssdk"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleration/get-primary-name-from-index"; -import type { Operation } from "@/lib/resolution/operations"; +import { isOperationResolved, type Operation } from "@/lib/resolution/operations"; /** * Acceleration pass for a Known ENSIP-19 Reverse Resolver, retrieving the Primary Name from @@ -41,6 +41,7 @@ export async function accelerateENSIP19ReverseResolver({ // resolve the 'name' operation with the indexed result, passing other along as-is return Promise.all( operations.map(async (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 index d1312b388..76b13b13c 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -64,9 +64,7 @@ function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Oper return { ...op, result: - records?.pubkeyX && records?.pubkeyY - ? interpretPubkeyValue(records.pubkeyX, records.pubkeyY) - : null, + records?.pubkeyX && records?.pubkeyY ? { x: records.pubkeyX, y: records.pubkeyY } : null, }; case "zonehash": return { ...op, result: records?.dnszonehash ?? null }; diff --git a/packages/ensnode-sdk/src/ensapi/client.ts b/packages/ensnode-sdk/src/ensapi/client.ts index 423df3784..ab6ee4866 100644 --- a/packages/ensnode-sdk/src/ensapi/client.ts +++ b/packages/ensnode-sdk/src/ensapi/client.ts @@ -191,8 +191,20 @@ export class EnsApiClient { 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/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..d0f0e619f --- /dev/null +++ b/packages/enssdk/src/lib/interface-id.ts @@ -0,0 +1,12 @@ +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: unknown): maybeInterfaceId is InterfaceId { + return ( + typeof maybeInterfaceId === "string" && isHex(maybeInterfaceId) && size(maybeInterfaceId) === 4 + ); +} From 29b3137903e943003aedf9cf4441fd8e25e80057 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:30:32 -0500 Subject: [PATCH 11/18] cleaning up --- .../resolution/accelerate-known-onchain-static-resolver.ts | 5 ++++- packages/enssdk/src/lib/interface-id.ts | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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 index 76b13b13c..97814e7be 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -1,7 +1,6 @@ import type { AccountId, Node } from "enssdk"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; -import { interpretPubkeyValue } from "@ensnode/ensnode-sdk/internal"; import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index"; import { isOperationResolved, type Operation } from "@/lib/resolution/operations"; @@ -69,7 +68,11 @@ function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Oper case "zonehash": return { ...op, result: records?.dnszonehash ?? null }; case "recordVersions": + // NOTE: recordVersions defaults to 0 return { ...op, result: records?.version ?? 0n }; + /** + * The following return the Operation as-is, instructing forward-resolution to resolve them via RPC. + */ case "ABI": case "interfaceImplementer": return op; diff --git a/packages/enssdk/src/lib/interface-id.ts b/packages/enssdk/src/lib/interface-id.ts index d0f0e619f..fd0b5deda 100644 --- a/packages/enssdk/src/lib/interface-id.ts +++ b/packages/enssdk/src/lib/interface-id.ts @@ -5,8 +5,6 @@ import type { InterfaceId } from "./types"; /** * Whether `maybeInterfaceId` is a valid ERC-165 {@link InterfaceId} — a 4-byte hex selector. */ -export function isInterfaceId(maybeInterfaceId: unknown): maybeInterfaceId is InterfaceId { - return ( - typeof maybeInterfaceId === "string" && isHex(maybeInterfaceId) && size(maybeInterfaceId) === 4 - ); +export function isInterfaceId(maybeInterfaceId: string): maybeInterfaceId is InterfaceId { + return isHex(maybeInterfaceId) && size(maybeInterfaceId) === 4; } From 8e9ae2341ff126f6905c0450d46c9088856b420a Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:38:57 -0500 Subject: [PATCH 12/18] fix: remove unnecessary executeOperationsWithUniversalResolver helper --- .../handlers/api/resolution/resolution-api.ts | 3 + ...cute-operations-with-universal-resolver.ts | 93 ------------------- .../src/lib/resolution/forward-resolution.ts | 20 +++- 3 files changed, 20 insertions(+), 96 deletions(-) delete mode 100644 apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index 0231de85d..e86a7a6b3 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -66,6 +66,9 @@ app.openapi(resolveRecordsRoute, async (c) => { } satisfies ResolveRecordsResponse; // 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/ensapi/client.ts) return c.json(replaceBigInts(response, String)); }); diff --git a/apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts b/apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts deleted file mode 100644 index 61b4acd76..000000000 --- a/apps/ensapi/src/lib/resolution/execute-operations-with-universal-resolver.ts +++ /dev/null @@ -1,93 +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 { interpretOperationWithRawResult } from "@/lib/resolution/execute-operations"; -import { isOperationResolved, type Operations } from "@/lib/resolution/operations"; - -const getUniversalResolverV1 = lazy(() => - getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), -); - -const getUniversalResolverV2 = lazy(() => - maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"), -); - -/** - * Execute a set of Operations for `name` against the UniversalResolver. - * - * NOTE: this exists just for the ENSv2 bailout, will be removed once forward-resolution is updated - * for ENSv2 (and interpretOperationWithRawResult can be un-exported). - */ -export async function executeOperationsWithUniversalResolver< - SELECTION extends ResolverRecordsSelection, ->({ - name, - operations, - publicClient, -}: { - name: InterpretedName; - operations: Operations; - publicClient: PublicClient; -}): Promise> { - // NOTE: automatically multicalled by viem - return await Promise.all( - operations.map(async (op) => { - if (isOperationResolved(op)) return op; - - try { - const encodedName = bytesToHex(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]); - - 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 (size(value) === 0) return interpretOperationWithRawResult(op, null); - - // ENSIP-10 — resolve() always returns bytes that need to be decoded - 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); - } catch (error) { - if (error instanceof ContractFunctionExecutionError) { - return interpretOperationWithRawResult(op, null); - } - throw error; - } - }), - ); -} diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 0b55e3e0c..c866dd575 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -12,11 +12,14 @@ import { namehashInterpretedName, } from "enssdk"; +import { DatasourceNames } from "@ensnode/datasources"; import { type ForwardResolutionArgs, ForwardResolutionProtocolStep, type ForwardResolutionResult, + getDatasourceContract, getENSv1Registry, + maybeGetDatasourceContract, PluginName, type ResolverRecordsSelection, TraceableENSProtocol, @@ -29,6 +32,7 @@ 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 { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; @@ -36,7 +40,6 @@ import { getPublicClient } from "@/lib/public-client"; 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 { executeOperationsWithUniversalResolver } from "@/lib/resolution/execute-operations-with-universal-resolver"; import { makeRecordsResponse } from "@/lib/resolution/make-records-response"; import { isOperationResolved, logOperations, makeOperations } from "@/lib/resolution/operations"; import { @@ -47,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. * @@ -141,20 +152,23 @@ async function _resolveForward( //////////////////////////// // TODO: re-enable protocol acceleration for ENSv2 if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + const UniversalResolverAddress = + getUniversalResolverV2()?.address ?? getUniversalResolverV1().address; operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, {}, () => - executeOperationsWithUniversalResolver({ + executeOperations({ name, + resolverAddress: UniversalResolverAddress, operations, publicClient, + useENSIP10Resolve: true, }), ); logOperations(operations, logger); - return makeRecordsResponse(operations); } From 514e17368adde5f65868858834fd6fed1ccb245b Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:46:04 -0500 Subject: [PATCH 13/18] version: distinguish unsupported/unseen from explicit 0n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previously \`version\` conflated three states: (1) resolver reverts (doesn't implement IVersionableResolver), (2) resolver returns 0n, (3) uninitialized storage slot. all three came through as 0n, hiding (1) from consumers. now: - type: \`RecordVersion | null\` - schema: column nullable, no default — null means "no VersionChanged event seen for this node" - interpretOperationWithRawResult: null raw → result: null for all calls (no special case for recordVersions) - resolveOperationWithIndex / makeRecordsResponse: \`?? null\` instead of \`?? 0n\` - zod response schema: version is nullable string on the wire restores the @example block on resolveForward that got dropped during the linear-pipeline refactor. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ccelerate-known-onchain-static-resolver.ts | 4 ++-- .../src/lib/resolution/execute-operations.ts | 10 ++++---- .../src/lib/resolution/forward-resolution.ts | 24 +++++++++++++++++++ .../resolution/make-records-response.test.ts | 2 +- .../lib/resolution/make-records-response.ts | 3 +-- apps/ensapi/src/lib/resolution/operations.ts | 2 +- .../protocol-acceleration.schema.ts | 6 +++-- .../src/ensapi/api/resolution/zod-schemas.ts | 2 +- .../resolution/resolver-records-response.ts | 7 ++++-- packages/enssdk/src/lib/types/resolver.ts | 4 ++-- 10 files changed, 45 insertions(+), 19 deletions(-) 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 index 97814e7be..98b8d461c 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-known-onchain-static-resolver.ts @@ -68,8 +68,8 @@ function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Oper case "zonehash": return { ...op, result: records?.dnszonehash ?? null }; case "recordVersions": - // NOTE: recordVersions defaults to 0 - return { ...op, result: records?.version ?? 0n }; + // 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. */ diff --git a/apps/ensapi/src/lib/resolution/execute-operations.ts b/apps/ensapi/src/lib/resolution/execute-operations.ts index 2556b6996..9d4381624 100644 --- a/apps/ensapi/src/lib/resolution/execute-operations.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations.ts @@ -132,14 +132,12 @@ export async function executeOperations({ /** * Interprets a single raw RPC result into its semantic value, producing a resolved Operation. * - * A `null` raw is interpreted as "no record" for record-style calls; `recordVersions` defaults to - * `0n` because IVersionableResolver treats an uninitialized version as zero. + * 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) { - if (call.functionName === "recordVersions") return { ...call, result: 0n }; - return { ...call, result: null } as Operation; - } + if (raw === null) return { ...call, result: null } as Operation; switch (call.functionName) { case "name": diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index c866dd575..2cf615d15 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -66,6 +66,26 @@ const getUniversalResolverV2 = lazy(() => * @param options Optional settings * @param options.accelerate Whether acceleration is requested (default: true) * @param options.canAccelerate Whether acceleration is currently possible (default: false) + * + * @example + * await resolveForward("jesse.base.eth", { + * name: true, + * addresses: [evmChainIdToCoinType(mainnet.id), evmChainIdToCoinType(base.id)], + * texts: ["com.twitter", "description"], + * }) + * + * // results in + * { + * name: 'jesse.base.eth', + * addresses: { + * 60: '0x849151d7D0bF1F34b70d5caD5149D28CC2308bf1', + * 2147492101: null + * }, + * texts: { + * 'com.twitter': 'jessepollak', + * description: 'base.eth builder #001' + * } + * } */ export async function resolveForward( name: ForwardResolutionArgs["name"], @@ -125,10 +145,14 @@ async function _resolveForward( // TODO: technically InterpretedNames are not resolvable, since ENS contracts are not // encoded-labelhash-aware; so we add a temporary additional constraint on name that it // must be fully normalized (and therefore not contain encoded labelhash segments) + // (this will be improved in a future pr https://github.com/namehash/ensnode/issues/1920) if (!isNormalizedName(name)) { throw new Error(`'${name}' must be normalized to be resolvable.`); } + // TODO: technically we could support resolving records for the root node, but because there + // are so many edge cases, this is something we should explicitly declare support for + // after we have test cases if (name === ENS_ROOT_NAME) { throw new Error( `Resolving records for the ENS Root Node ('') is not currently supported.`, 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 bcc015853..e9c31db5c 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.test.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.test.ts @@ -90,7 +90,7 @@ describe("makeRecordsResponse", () => { contenthash: null, pubkey: null, dnszonehash: null, - version: 0n, + version: null, abi: null, interfaces: { [id]: null }, }); diff --git a/apps/ensapi/src/lib/resolution/make-records-response.ts b/apps/ensapi/src/lib/resolution/make-records-response.ts index a1898458c..7c70ce9c6 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.ts @@ -32,8 +32,7 @@ export function makeRecordsResponse( memo.dnszonehash = op.result ?? null; break; case "recordVersions": - // NOTE: recordVersions defaults to 0n - memo.version = op.result ?? 0n; + memo.version = op.result ?? null; break; case "ABI": memo.abi = op.result ?? null; diff --git a/apps/ensapi/src/lib/resolution/operations.ts b/apps/ensapi/src/lib/resolution/operations.ts index e49febd39..8780edfd0 100644 --- a/apps/ensapi/src/lib/resolution/operations.ts +++ b/apps/ensapi/src/lib/resolution/operations.ts @@ -23,7 +23,7 @@ type OperationMap = { 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 }; + recordVersions: { args: readonly [Node]; result: RecordVersion | null }; ABI: { args: readonly [Node, ContentType]; result: { contentType: ContentType; data: Hex } | 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 3429fd947..ff2f6c335 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts @@ -152,9 +152,11 @@ export const resolverRecords = onchainTable( dnszonehash: t.hex(), /** - * IVersionableResolver version, defaulting to 0. + * 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().notNull().default(0n).$type(), + version: t.bigint().$type(), }), (t) => ({ byId: uniqueIndex().on(t.chainId, t.address, t.node), diff --git a/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts index 9e50eda01..812de2d7d 100644 --- a/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensapi/api/resolution/zod-schemas.ts @@ -15,7 +15,7 @@ const makeResolverRecordsResponseSchema = () => contenthash: z.string().nullable().optional(), pubkey: z.object({ x: z.string(), y: z.string() }).nullable().optional(), dnszonehash: z.string().nullable().optional(), - version: z.string().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/resolution/resolver-records-response.ts b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts index 2d72278fc..9c6d73f7e 100644 --- a/packages/ensnode-sdk/src/resolution/resolver-records-response.ts +++ b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts @@ -58,9 +58,12 @@ export type ResolverRecordsResponseBase = { dnszonehash: Hex | null; /** - * The IVersionableResolver version, defaulting to 0n. + * 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; + version: RecordVersion | null; }; /** diff --git a/packages/enssdk/src/lib/types/resolver.ts b/packages/enssdk/src/lib/types/resolver.ts index 95c126f1b..fa33dd79b 100644 --- a/packages/enssdk/src/lib/types/resolver.ts +++ b/packages/enssdk/src/lib/types/resolver.ts @@ -21,8 +21,8 @@ export type ContentType = bigint; export type InterfaceId = Hex; /** - * IVersionableResolver record version. `0n` is the uninitialized default; `VersionChanged` - * bumps this value and invalidates all prior records for the node. + * 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 */ From 5dfc0205c0f729b047babafb82e228e0d500178d Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 17:54:18 -0500 Subject: [PATCH 14/18] regenerate openapi spec reflects the new record-records selection query params and response fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/docs.ensnode.io/ensapi-openapi.json | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/docs.ensnode.io/ensapi-openapi.json b/docs/docs.ensnode.io/ensapi-openapi.json index e4c4dbd20..c42fae70c 100644 --- a/docs/docs.ensnode.io/ensapi-openapi.json +++ b/docs/docs.ensnode.io/ensapi-openapi.json @@ -973,6 +973,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", "default": false }, "required": false, @@ -1005,6 +1031,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"] } } } }, From 5cd5444fb5aa70f7273d701d255cd715e8bd37d7 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 20 Apr 2026 18:03:38 -0500 Subject: [PATCH 15/18] accelerate-ensip19: no-op on non-name selection, don't 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previously threw a 500 if the caller requested a non-name record (e.g. \`contenthash: true\`) and the resolver happened to be a known ENSIP-19 reverse resolver. now returns operations unchanged in that case — the non-name ops flow through to the RPC tail, which returns null per the reverse-resolver contract. matches unaccelerated behavior. drops unused replaceBigInts import. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../accelerate-ensip19-reverse-resolver.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts index 694b35c5e..6f0061501 100644 --- a/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts +++ b/apps/ensapi/src/lib/resolution/accelerate-ensip19-reverse-resolver.ts @@ -1,4 +1,3 @@ -import { replaceBigInts } from "@ponder/utils"; import type { InterpretedName } from "enssdk"; import { parseReverseName } from "enssdk"; @@ -10,6 +9,11 @@ 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, @@ -20,13 +24,7 @@ export async function accelerateENSIP19ReverseResolver({ name: InterpretedName; selection: ResolverRecordsSelection; }): Promise { - // Invariant: consumer must be selecting the `name` record at this point. - // `selection` may contain bigints (e.g. `abi: ContentType`); stringify safely. - if (selection.name !== true) { - throw new Error( - `Invariant(ENSIP-19 Reverse Resolver): expected 'name: true', got ${JSON.stringify(replaceBigInts(selection, String))}.`, - ); - } + if (selection.name !== true) return operations; // parse the Reverse Name into { address, coinType } const parsed = parseReverseName(name); @@ -38,12 +36,10 @@ export async function accelerateENSIP19ReverseResolver({ const result = await getENSIP19ReverseNameRecordFromIndex(parsed.address, parsed.coinType); - // resolve the 'name' operation with the indexed result, passing other along as-is - return Promise.all( - operations.map(async (op) => { - if (isOperationResolved(op)) return op; - if (op.functionName === "name") return { ...op, result }; - return op; - }), - ); + // 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; + }); } From 1cb6b47bc6a07fa6e82d5f7ec759bd02c3dff367 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 09:52:12 -0500 Subject: [PATCH 16/18] enskit: fix intermittent TS2742 on OmnigraphProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsup's dts rollup can't portably name the inferred ReactElement return type on OmnigraphProvider — the @types/react symlink path in pnpm is non-deterministic, so the build fails intermittently depending on link-resolution order. explicit return type makes it deterministic. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/enskit/src/react/omnigraph/provider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); From 9c76ac523f540e036aec150b64ec1aa35bdb7a53 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 09:53:47 -0500 Subject: [PATCH 17/18] ResolverRecordsResponse: accept readonly tuple selections the key-remap predicate checked \`T[K] extends true | any[] | bigint\`, which excluded \`readonly\` tuples. \`as const satisfies ResolverRecordsSelection\` selections with \`addresses: readonly [60, 1001]\` got their keys dropped from the mapped type. widen to \`readonly any[]\`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ensnode-sdk/src/resolution/resolver-records-response.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-sdk/src/resolution/resolver-records-response.ts b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts index 9c6d73f7e..e0d0d77f7 100644 --- a/packages/ensnode-sdk/src/resolution/resolver-records-response.ts +++ b/packages/ensnode-sdk/src/resolution/resolver-records-response.ts @@ -89,7 +89,7 @@ export type ResolverRecordsResponseBase = { */ export type ResolverRecordsResponse = { - [K in keyof T as T[K] extends true | any[] | bigint ? 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 From 466fef94cba923d04815fbc27231a4004f74b08a Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 21 Apr 2026 15:19:39 -0500 Subject: [PATCH 18/18] fix: add back integration test for universal resolver encoded labelhash non-support --- .../execute-operations.integration.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts diff --git a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts new file mode 100644 index 000000000..33ba525d6 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts @@ -0,0 +1,86 @@ +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 ./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, + rpcConfigs: new Map([[ensTestEnvChain.id, { httpRPCs: [new URL("http://localhost:8545")] }]]), + }, +})); + +import { + asInterpretedLabel, + asInterpretedName, + asLiteralLabel, + encodeLabelHash, + interpretedLabelsToInterpretedName, + labelhashLiteralLabel, + namehashInterpretedName, +} from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { getDatasourceContract } from "@ensnode/ensnode-sdk"; + +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([ + asInterpretedLabel(encodeLabelHash(labelhashLiteralLabel(asLiteralLabel("example")))), + asInterpretedLabel("eth"), +]); + +const EXPECTED_DESCRIPTION = "example.eth"; + +const publicClient = getPublicClient(ensTestEnvChain.id); + +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( + executeOperations({ + name: NAME, + resolverAddress: UniversalResolverV2.address, + useENSIP10Resolve: true, + operations: makeOperations(node, { texts: ["description"] }), + publicClient, + }), + ).resolves.toMatchObject([{ result: EXPECTED_DESCRIPTION }]); + }); + + /** + * NOTE(shrugs): This was contrary to my expectations, but the NameCoder (in both ENSv1 and ENSv2) + * is NOT EncodedLabelHash-aware: all label segments are hashed indiscriminately as LiteralLabels + * to traverse the nametree, meaning that InterpretedNames (which may include EncodedLabelHash + * segments for labels that are unknown, too long, or unnormalized) are explicitly unresolvable! + * + * Or, more technically, they resolve to an incorrect name, one addressed by, for example: + * [root, labelhash("eth"), labelhash("[6fd43e7cffc31bb581d7421c8698e29aa2bd8e7186a394b85299908b4eb9b175]")] + * + * 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( + executeOperations({ + name: NAME_WITH_ENCODED_LABELHASHES, + resolverAddress: UniversalResolverV2.address, + useENSIP10Resolve: true, + operations: makeOperations(node, { texts: ["description"] }), + publicClient, + }), + ).resolves.toMatchObject([{ result: null }]); + }); +});