Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/large-cameras-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
Comment thread
shrugs marked this conversation as resolved.
"ensindexer": minor
"ensapi": minor
Comment on lines +2 to +3
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset is marked as a minor bump, but the PR removes exported public API from @ensnode/datasources (e.g. ensTestEnvL1Chain/ensTestEnvL2Chain) and removes a datasource name (DatasourceNames.ENSv2ETHRegistry). Under semver this is a breaking change and should be released as a major bump for the fixed version group.

Suggested change
"ensindexer": minor
"ensapi": minor
"ensindexer": major
"ensapi": major

Copilot uses AI. Check for mistakes.
---

The `ens-test-env` namespace now functions against devnet commit `762de44`, which includes the major refactor of ENSv2 onto the ENS Root Chain, away from Namechain.
Comment thread
shrugs marked this conversation as resolved.
118 changes: 11 additions & 107 deletions apps/ensapi/src/graphql-api/lib/get-domain-by-fqdn.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,23 @@
import config from "@/config";

import { getUnixTime } from "date-fns";
import { Param, sql } from "drizzle-orm";
import { namehash } from "viem";

import { DatasourceNames } from "@ensnode/datasources";
import * as schema from "@ensnode/ensnode-schema";
import {
type DomainId,
type ENSv2DomainId,
ETH_NODE,
getENSv2RootRegistryId,
type InterpretedName,
interpretedLabelsToInterpretedName,
interpretedLabelsToLabelHashPath,
interpretedNameToInterpretedLabels,
isRegistrationFullyExpired,
type LabelHash,
type LiteralLabel,
labelhashLiteralLabel,
makeENSv1DomainId,
makeRegistryId,
makeSubdomainNode,
maybeGetDatasourceContract,
type RegistryId,
} from "@ensnode/ensnode-sdk";

import { getLatestRegistration } from "@/graphql-api/lib/get-latest-registration";
import { db } from "@/lib/db";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("get-domain-by-fqdn");

// TODO(ensv2): can make this getDatasourceContract once ENSv2 Datasources are available in all namespaces
const V2_ROOT_ETH_REGISTRY = maybeGetDatasourceContract(
Comment thread
shrugs marked this conversation as resolved.
config.namespace,
DatasourceNames.ENSv2Root,
"ETHRegistry",
);

// TODO(ensv2): can make this getDatasourceContract once ENSv2 Datasources are available in all namespaces
const V2_NAMECHAIN_ETH_REGISTRY = maybeGetDatasourceContract(
config.namespace,
DatasourceNames.ENSv2ETHRegistry,
"ETHRegistry",
);

const ETH_LABELHASH = labelhashLiteralLabel("eth" as LiteralLabel);
const ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace);

/**
Expand All @@ -61,7 +32,7 @@ export async function getDomainIdByInterpretedName(
v2_getDomainIdByFqdn(ROOT_REGISTRY_ID, name),
]);

// prefer v2DomainId
// prefer v2Domain over v1Domain
return v2DomainId || v1DomainId || null;
Comment thread
lightwalker-eth marked this conversation as resolved.
}

Expand All @@ -77,15 +48,12 @@ async function v1_getDomainIdByFqdn(name: InterpretedName): Promise<DomainId | n
}

/**
* Forward-traverses the ENSv2 namegraph in order to identify the Domain addressed by `name`.
*
* If the exact Domain was not found, and the path terminates at a bridging resolver, bridge to the
* indicated Registry and continue traversing.
* Forward-traverses the ENSv2 namegraph from the specified root in order to identify the Domain
* addressed by `name`.
*/
async function v2_getDomainIdByFqdn(
registryId: RegistryId,
rootRegistryId: RegistryId,
name: InterpretedName,
{ now } = { now: BigInt(getUnixTime(new Date())) },
): Promise<DomainId | null> {
const labelHashPath = interpretedLabelsToLabelHashPath(interpretedNameToInterpretedLabels(name));

Expand All @@ -102,7 +70,7 @@ async function v2_getDomainIdByFqdn(
NULL::text AS label_hash,
0 AS depth
FROM ${schema.registry} r
WHERE r.id = ${registryId}
WHERE r.id = ${rootRegistryId}

UNION ALL

Expand Down Expand Up @@ -131,80 +99,16 @@ async function v2_getDomainIdByFqdn(
depth: number;
}[];

// this was a query for a TLD and it does not exist in ENS Root Chain ENSv2
// this was a query for a TLD and it does not exist within the ENSv2 namegraph
if (rows.length === 0) return null;

// biome-ignore lint/style/noNonNullAssertion: length check above
const leaf = rows[rows.length - 1]!;

/////////////////////////////////////////////////////////////////////////////////
// 1. An exact match was found for the Domain within ENSv2 on the ENS Root Chain.
/////////////////////////////////////////////////////////////////////////////////
// the v2Domain was found iff there is an exact match within the ENSv2 namegraph
const exact = rows.length === labelHashPath.length;
if (exact) {
logger.debug(`Found '${name}' in ENSv2 from Registry ${registryId}`);
return leaf.domain_id;
}

/////////////////////////////////////////////////////////////////////////////////
// 2. ETHTLDResolver
// if the path terminates at the .eth Registry, we must implement the logic in ETHTLDResolver
// TODO: we could add an additional invariant that the .eth v2 Registry does indeed have the ETHTLDResolver
// set as its resolver, but that is unnecessary at the moment and incurs additional db requests or a join against
// domain_resolver_relationships
// TODO: generalize this into other future bridging resolvers depending on how basenames etc do it
/////////////////////////////////////////////////////////////////////////////////

if (!V2_ROOT_ETH_REGISTRY) return null;

// 2.1: if the path did not terminate at the .eth Registry, then the domain was not found
if (leaf.registry_id !== makeRegistryId(V2_ROOT_ETH_REGISTRY)) return null;

logger.debug({ name, rows });

// Invariant: must be >= 2LD
if (labelHashPath.length < 2) {
throw new Error(`Invariant: '${name}' is not >= 2LD (has depth ${labelHashPath.length})!`);
}

// Invariant: LabelHashPath must originate at 'eth'
if (labelHashPath[0] !== ETH_LABELHASH) {
throw new Error(
`Invariant: '${name}' terminated at .eth Registry but the queried labelHashPath (${JSON.stringify(labelHashPath)}) does not originate with 'eth' (${ETH_LABELHASH}).`,
);
}

// Invariant: The path must terminate at 'eth' as well.
if (leaf.label_hash !== ETH_LABELHASH) {
throw new Error(
`Invariant: the leaf identified (${leaf.label_hash}) does not match 'eth' (${ETH_LABELHASH}).`,
);
}

// construct the node of the 2ld
const dotEth2LDNode = makeSubdomainNode(labelHashPath[1], ETH_NODE);

// 2.2: if there's an active registration in ENSv1 for the .eth 2LD, then resolve from ENSv1
const ensv1DomainId = makeENSv1DomainId(dotEth2LDNode);
const registration = await getLatestRegistration(ensv1DomainId);

if (registration && !isRegistrationFullyExpired(registration, now)) {
logger.debug(
`ETHTLDResolver deferring to actively registered name ${dotEth2LDNode} in ENSv1...`,
);
return await v1_getDomainIdByFqdn(name);
}

// 2.3: otherwise, direct to Namechain ENSv2 .eth Registry
// if there's no ETHRegistry on Namechain, the domain was not found
if (!V2_NAMECHAIN_ETH_REGISTRY) return null;

const nameWithoutTld = interpretedLabelsToInterpretedName(
interpretedNameToInterpretedLabels(name).slice(0, -1),
);
logger.debug(
`ETHTLDResolver deferring '${nameWithoutTld}' to ENSv2 .eth Registry on Namechain...`,
);

return v2_getDomainIdByFqdn(makeRegistryId(V2_NAMECHAIN_ETH_REGISTRY), nameWithoutTld, { now });
if (exact) return leaf.domain_id;

// otherwise, the v2 domain was not found
return null;
Comment thread
shrugs marked this conversation as resolved.
Comment thread
shrugs marked this conversation as resolved.
}
18 changes: 1 addition & 17 deletions apps/ensapi/src/lib/public-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import config from "@/config";

import { ccipRequest, createPublicClient, fallback, http, type PublicClient } from "viem";
import { createPublicClient, fallback, http, type PublicClient } from "viem";

import { ensTestEnvL1Chain } from "@ensnode/datasources";
import type { ChainId } from "@ensnode/ensnode-sdk";

const _cache = new Map<ChainId, PublicClient>();
Expand All @@ -24,21 +23,6 @@ export function getPublicClient(chainId: ChainId): PublicClient {
// Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs
createPublicClient({
transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))),
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPublicClient() no longer enables CCIP-Read. executeResolveCallsWithUniversalResolver() uses publicClient.readContract({ functionName: "resolve" }), and UniversalResolver resolve() can trigger EIP-3668 OffchainLookup for offchain resolvers; without ccipRead enabled this will fail at runtime. Re-enable CCIP-Read (e.g. ccipRead: true or a custom ccipRead.request handler) and, if the Docker gateway fallback is still needed for local dev, reintroduce it keyed off ensTestEnvChain.id (or a configurable gateway URL).

Suggested change
transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))),
transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))),
ccipRead: true,

Copilot uses AI. Check for mistakes.
ccipRead: {
async request({ data, sender, urls }) {
// When running in Docker, ENSApi's viem should fetch the UniversalResolverGateway at
// http://devnet:8547 rather than the default of http://localhost:8547, which is unreachable
// from within the Docker container. So here, if we're handling a CCIP-Read request on
// the ens-test-env L1 Chain, we add the ens-test-env's docker-compose-specific url as
// a fallback if the default (http://localhost:8547) fails.
if (chainId === ensTestEnvL1Chain.id) {
return ccipRequest({ data, sender, urls: [...urls, "http://devnet:8547"] });
}

// otherwise, handle as normal
return ccipRequest({ data, sender, urls });
},
},
}),
);
}
Expand Down
7 changes: 3 additions & 4 deletions apps/ensindexer/src/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ensTestEnvL1Chain, ensTestEnvL2Chain } from "@ensnode/datasources";
import { ensTestEnvChain } from "@ensnode/datasources";
import { ENSNamespaceIds, PluginName } from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

Expand Down Expand Up @@ -661,8 +661,7 @@ describe("config (minimal base env)", () => {
stubEnv({ NAMESPACE: "ens-test-env", PLUGINS: "subgraph" });

const config = await getConfig();
expect(config.rpcConfigs.has(ensTestEnvL1Chain.id)).toBe(true);
expect(config.rpcConfigs.has(ensTestEnvL2Chain.id)).toBe(true);
expect(config.rpcConfigs.has(ensTestEnvChain.id)).toBe(true);
});
});

Expand Down Expand Up @@ -744,7 +743,7 @@ describe("config (minimal base env)", () => {
NAMESPACE: "ens-test-env",
LABEL_SET_ID: "ens-test-env",
LABEL_SET_VERSION: "0",
RPC_URL_15658733: VALID_RPC_URL,
[`RPC_URL_${ensTestEnvChain.id}`]: VALID_RPC_URL,
});
await expect(getConfig()).resolves.toMatchObject({
namespace: ENSNamespaceIds.EnsTestEnv,
Expand Down
11 changes: 3 additions & 8 deletions apps/ensindexer/src/lib/ponder-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import { z } from "zod/v4";
import {
type ContractConfig,
type DatasourceName,
ensTestEnvL1Chain,
ensTestEnvL2Chain,
ensTestEnvChain,
maybeGetDatasource,
} from "@ensnode/datasources";
import type { Blockrange, ChainId, ENSNamespaceId } from "@ensnode/ensnode-sdk";
Expand Down Expand Up @@ -305,12 +304,8 @@ export function chainsConnectionConfig(
);
}

// NOTE: disable cache on local chains (e.g. ens-test-env, devnet)
const disableCache =
chainId === 31337 ||
chainId === 1337 ||
chainId === ensTestEnvL1Chain.id ||
chainId === ensTestEnvL2Chain.id;
// NOTE: disable cache on local chains (e.g. ganache, anvil, ens-test-env)
const disableCache = chainId === 31337 || chainId === 1337 || chainId === ensTestEnvChain.id;

return {
[chainId.toString()]: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import config from "@/config";

import { type Context, ponder } from "ponder:registry";
import schema from "ponder:schema";
import { type Address, hexToBigInt, labelhash } from "viem";

import { DatasourceNames } from "@ensnode/datasources";
import {
type AccountId,
accountIdEqual,
getCanonicalId,
getDatasourceContract,
getENSv2RootRegistry,
interpretAddress,
isRegistrationFullyExpired,
type LiteralLabel,
labelhashLiteralLabel,
makeENSv2DomainId,
makeRegistryId,
PluginName,
Expand All @@ -32,8 +25,6 @@ import { toJson } from "@/lib/json-stringify-with-bigints";
import { namespaceContract } from "@/lib/plugin-helpers";
import type { EventWithArgs } from "@/lib/ponder-helpers";

const ETH_LABELHASH = labelhashLiteralLabel("eth" as LiteralLabel);

const pluginName = PluginName.ENSv2;

export default function () {
Expand Down Expand Up @@ -86,23 +77,7 @@ export default function () {
})
.onConflictDoNothing();

// TODO(ensv2): hoist this access once all namespaces declare ENSv2 contracts
const ENSV2_ROOT_REGISTRY = getENSv2RootRegistry(config.namespace);
const ENSV2_L2_ETH_REGISTRY = getDatasourceContract(
config.namespace,
DatasourceNames.ENSv2ETHRegistry,
"ETHRegistry",
);

// if this Registry is Bridged, we know its Canonical Domain and can set it here
// TODO(bridged-registries): generalize this to future ENSv2 Bridged Resolvers
if (accountIdEqual(registry, ENSV2_L2_ETH_REGISTRY)) {
const domainId = makeENSv2DomainId(ENSV2_ROOT_REGISTRY, getCanonicalId(ETH_LABELHASH));
await context.db
.insert(schema.registryCanonicalDomain)
.values({ registryId: registryId, domainId })
.onConflictDoUpdate({ domainId });
}
// TODO(bridged-registries): upon registry creation, write the registry's canonical domain here

// ensure discovered Label
await ensureLabel(context, label);
Expand Down Expand Up @@ -182,11 +157,6 @@ export default function () {

// update Registration
await context.db.update(schema.registration, { id: registration.id }).set({ expiry });

// if newExpiry is 0, this is an `unregister` call, related to ejecting
// https://github.com/ensdomains/namechain/blob/9e31679f4ee6d8abb4d4e840cdf06f2d653a706b/contracts/src/L1/bridge/L1BridgeController.sol#L141
// TODO(migration): maybe do something special with this state?
// if (expiry === 0n) return;
},
);

Expand Down
Loading