Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b0d4b95
checkpoint: next i need to edit the spec
shrugs Apr 20, 2026
d977b06
Merge branch 'main' into feat/extend-protocol-resolution
shrugs Apr 20, 2026
e05ec90
spec: linear operation passes, abi bitmask, enssdk semantic types
shrugs Apr 20, 2026
e6c4fef
checkpoint: refactor forward-resolution into a linear sequence of ope…
shrugs Apr 20, 2026
825d8ad
DRY up handlers with ensureResolverAndRecords
shrugs Apr 20, 2026
59db517
fix: update changeset
shrugs Apr 20, 2026
f859e39
fix: update changeset
shrugs Apr 20, 2026
c68236f
feat: wire new selection fields through api surface
shrugs Apr 20, 2026
a50d37b
fix: address bot review feedback
shrugs Apr 20, 2026
a552d3d
refactor: inline VersionChanged handler body
shrugs Apr 20, 2026
2fcbc1d
extract isInterfaceId to enssdk; inline bigint revival in client
shrugs Apr 20, 2026
29b3137
cleaning up
shrugs Apr 20, 2026
8e9ae23
fix: remove unnecessary executeOperationsWithUniversalResolver helper
shrugs Apr 20, 2026
514e173
version: distinguish unsupported/unseen from explicit 0n
shrugs Apr 20, 2026
5dfc020
regenerate openapi spec
shrugs Apr 20, 2026
5cd5444
accelerate-ensip19: no-op on non-name selection, don't 500
shrugs Apr 20, 2026
3fa7b15
Merge branch 'main' into feat/extend-protocol-resolution
shrugs Apr 21, 2026
1cb6b47
enskit: fix intermittent TS2742 on OmnigraphProvider
shrugs Apr 21, 2026
9c76ac5
ResolverRecordsResponse: accept readonly tuple selections
shrugs Apr 21, 2026
466fef9
fix: add back integration test for universal resolver encoded labelha…
shrugs Apr 21, 2026
6dd481d
Merge remote-tracking branch 'origin/main' into feat/extend-protocol-…
shrugs Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/extend-resolver-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@ensnode/ensdb-sdk": minor
"@ensnode/ensnode-sdk": minor
"ensindexer": minor
"ensapi": minor
"enssdk": minor
---

Resolution API: support `contenthash`, `pubkey`, `abi`, `interfaces`, `dnszonehash`, and `version` selection. Protocol acceleration indexes `contenthash`, `pubkey`, `dnszonehash`, and handles `VersionChanged` (clears records for the node, bumps version). `ABI` (bitmask query, contract-equivalent) and `interface` records are selectable but always resolved via RPC. Adds `ContentType` / `InterfaceId` / `RecordVersion` semantic types to `enssdk`.
Comment thread
shrugs marked this conversation as resolved.
7 changes: 6 additions & 1 deletion apps/ensapi/src/handlers/api/resolution/resolution-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { replaceBigInts } from "@ponder/utils";
Comment thread
shrugs marked this conversation as resolved.
import type { Duration } from "enssdk";

import type {
Expand Down Expand Up @@ -64,7 +65,11 @@ app.openapi(resolveRecordsRoute, async (c) => {
...(showTrace && { trace }),
} satisfies ResolveRecordsResponse<typeof selection>;

return c.json(response, 200);
// serialize bigints (e.g. `records.version`, `records.abi.contentType`) as strings
// NOTE: this matches the openapi wire format
// NOTE: the ts client, which uses the ResolverRecordsResponse type, must parse the bigint fields
// into native bigints (see packages/ensnode-sdk/src/ensnode/client.ts)
return c.json(replaceBigInts(response, String), 200);
});

/**
Expand Down
51 changes: 50 additions & 1 deletion apps/ensapi/src/lib/handlers/params.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { z } from "@hono/zod-openapi";
import { DEFAULT_EVM_CHAIN_ID, isNormalizedName, type Name } from "enssdk";
import {
type ContentType,
DEFAULT_EVM_CHAIN_ID,
type InterfaceId,
isInterfaceId,
isNormalizedName,
type Name,
} from "enssdk";

import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk";
import {
Expand Down Expand Up @@ -74,6 +81,30 @@ const nameParamDescription =
"to be represented as the primary name for an address. " +
"More details here: https://docs.ens.domains/web/reverse";

const contentTypeBitmask = z
.string()
.transform((val, ctx) => {
try {
const n = BigInt(val);
if (n <= 0n) throw new Error("nope");
return n as ContentType;
} catch {
ctx.issues.push({
code: "custom",
message: "Must be a valid positive integer string.",
Comment thread
shrugs marked this conversation as resolved.
input: val,
});

return z.NEVER;
}
})
.openapi({ type: "string", example: "1" });
Comment thread
shrugs marked this conversation as resolved.

const interfaceId = z
.string()
.refine(isInterfaceId, "Must be a 4-byte hex (0x + 8 hex chars)")
.transform((val) => val.toLowerCase() as InterfaceId);

const rawSelectionParams = z.object({
name: z
.string()
Expand All @@ -94,12 +125,24 @@ const rawSelectionParams = z.object({
.describe(
"Comma-separated list of text record keys to resolve (e.g. 'avatar,description,url').",
),
contenthash: z.string().optional(),
pubkey: z.string().optional(),
dnszonehash: z.string().optional(),
version: z.string().optional(),
abi: z.string().optional(),
interfaces: z.string().optional(),
});

const selectionFields = z.object({
name: z.optional(boolstring),
addresses: z.optional(stringarray.pipe(z.array(coinType))),
texts: z.optional(stringarray),
contenthash: z.optional(boolstring),
pubkey: z.optional(boolstring),
dnszonehash: z.optional(boolstring),
version: z.optional(boolstring),
abi: z.optional(contentTypeBitmask),
interfaces: z.optional(stringarray.pipe(z.array(interfaceId))),
Comment thread
shrugs marked this conversation as resolved.
});

type SelectionFields = z.output<typeof selectionFields>;
Expand All @@ -112,6 +155,12 @@ function toSelection(
...(fields.name && { name: true }),
...(fields.addresses && { addresses: fields.addresses }),
...(fields.texts && { texts: fields.texts }),
...(fields.contenthash && { contenthash: true }),
...(fields.pubkey && { pubkey: true }),
...(fields.dnszonehash && { dnszonehash: true }),
...(fields.version && { version: true }),
...(fields.abi !== undefined && { abi: fields.abi }),
...(fields.interfaces && { interfaces: fields.interfaces }),
};

if (isSelectionEmpty(sel)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type CoinType,
coinTypeReverseLabel,
DEFAULT_EVM_COIN_TYPE,
type Name,
type InterpretedName,
type NormalizedAddress,
} from "enssdk";

Expand All @@ -17,7 +17,7 @@ const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE);
export async function getENSIP19ReverseNameRecordFromIndex(
address: NormalizedAddress,
coinType: CoinType,
): Promise<Name | null> {
): Promise<InterpretedName | null> {
const _coinType = BigInt(coinType);

// retrieve from index
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -18,43 +17,48 @@ export async function getRecordsFromIndex<SELECTION extends ResolverRecordsSelec
resolver: AccountId;
node: Node;
selection: SELECTION;
}): Promise<IndexedResolverRecords | null> {
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;
Comment thread
shrugs marked this conversation as resolved.

// 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 });
}
}
Comment thread
shrugs marked this conversation as resolved.
}

return records;
return row;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { InterpretedName } from "enssdk";
import { parseReverseName } from "enssdk";

import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk";

import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleration/get-primary-name-from-index";
import { isOperationResolved, type Operation } from "@/lib/resolution/operations";

/**
* Acceleration pass for a Known ENSIP-19 Reverse Resolver, retrieving the Primary Name from
* the index if possible.
*
* If the caller didn't select `name`, this is a no-op — any other selected operations flow
* through to the RPC tail unchanged. A reverse resolver won't meaningfully answer them either
* way; letting the rpc return its natural null-per-record response preserves parity with the
* unaccelerated path.
*/
export async function accelerateENSIP19ReverseResolver({
operations,
name,
selection,
}: {
operations: Operation[];
name: InterpretedName;
selection: ResolverRecordsSelection;
}): Promise<Operation[]> {
if (selection.name !== true) return operations;

// parse the Reverse Name into { address, coinType }
const parsed = parseReverseName(name);
if (!parsed) {
throw new Error(
`Invariant(ENSIP-19 Reverse Resolver): expected a valid reverse name, got '${name}'.`,
);
}
Comment thread
shrugs marked this conversation as resolved.

const result = await getENSIP19ReverseNameRecordFromIndex(parsed.address, parsed.coinType);

// resolve the 'name' operation with the indexed result, passing others along as-is
return operations.map((op) => {
if (isOperationResolved(op)) return op;
if (op.functionName === "name") return { ...op, result };
return op;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { AccountId, Node } from "enssdk";

import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk";

import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index";
import { isOperationResolved, type Operation } from "@/lib/resolution/operations";

type IndexedRecords = Awaited<ReturnType<typeof getRecordsFromIndex>>;

/**
* 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<Operation[]> {
const records = await getRecordsFromIndex({ resolver, node, selection });

return operations.map((op) => {
if (isOperationResolved(op)) return op;
return resolveOperationWithIndex(op, records);
});
}

/**
* Attempts to resolve an Operation from indexed records.
*
* For indexable calls returns a new Operation with a resolved `result`. For calls that aren't
* indexable (ABI, interfaceImplementer) returns the input Operation unchanged — so its
* `result: undefined` flows on to the RPC tail.
*
* Pass `null` `records` when there is no indexed row for (resolver, node) — indexable calls still
* have well-defined "no record" results in that case.
*/
function resolveOperationWithIndex(op: Operation, records: IndexedRecords): Operation {
switch (op.functionName) {
case "name":
return { ...op, result: records?.name ?? null };
case "addr": {
const coinType = op.args[1];
const found = records?.addressRecords.find((r) => r.coinType === coinType);
return { ...op, result: found?.value ?? null };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "text": {
const key = op.args[1];
const found = records?.textRecords.find((r) => r.key === key);
return { ...op, result: found?.value ?? null };
}
case "contenthash":
return { ...op, result: records?.contenthash ?? null };
case "pubkey":
return {
...op,
result:
records?.pubkeyX && records?.pubkeyY ? { x: records.pubkeyX, y: records.pubkeyY } : null,
};
case "zonehash":
return { ...op, result: records?.dnszonehash ?? null };
case "recordVersions":
// null when no `VersionChanged` event has been seen for this node
return { ...op, result: records?.version ?? null };
/**
* The following return the Operation as-is, instructing forward-resolution to resolve them via RPC.
*/
case "ABI":
case "interfaceImplementer":
return op;
}
}
Loading
Loading