From ec41746416e446f3e1356bdcaf8f515b2fe4bde8 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 17:32:35 -0500 Subject: [PATCH 1/2] fix: UR delegation for sentinel --- ...-resolution-delegate-universal-resolver.md | 5 + .../src/lib/resolution/forward-resolution.ts | 182 +++++++++++------- 2 files changed, 118 insertions(+), 69 deletions(-) create mode 100644 .changeset/forward-resolution-delegate-universal-resolver.md diff --git a/.changeset/forward-resolution-delegate-universal-resolver.md b/.changeset/forward-resolution-delegate-universal-resolver.md new file mode 100644 index 000000000..c14d96b71 --- /dev/null +++ b/.changeset/forward-resolution-delegate-universal-resolver.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +Forward Resolution now fully delegates to the `UniversalResolver` whenever records cannot be accelerated, correctly implementing the [ENSv2-Readiness](https://docs.ens.domains/web/ensv2-readiness/) check for `ur.integration-test.eth`. Unaccelerated requests are always delegated to the `UniversalResolver`. diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 02bf83a5a..0eacc5a14 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -8,8 +8,9 @@ import { type Node, namehashInterpretedName, } from "enssdk"; +import type { PublicClient } from "viem"; -import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; +import { DatasourceNames, getENSRootChainId, maybeGetDatasource } from "@ensnode/datasources"; import { type ForwardResolutionArgs, ForwardResolutionProtocolStep, @@ -36,7 +37,12 @@ import { accelerateENSIP19ReverseResolver } from "@/lib/resolution/accelerate-en import { accelerateKnownOnchainStaticResolver } from "@/lib/resolution/accelerate-known-onchain-static-resolver"; import { executeOperations } from "@/lib/resolution/execute-operations"; import { makeRecordsResponse } from "@/lib/resolution/make-records-response"; -import { isOperationResolved, logOperations, makeOperations } from "@/lib/resolution/operations"; +import { + isOperationResolved, + logOperations, + makeOperations, + type Operation, +} from "@/lib/resolution/operations"; import { addEnsProtocolStepEvent, withEnsProtocolStep, @@ -45,6 +51,37 @@ import { const logger = makeLogger("forward-resolution"); const tracer = trace.getTracer("forward-resolution"); +/** + * Resolves `operations` by delegating wholesale to the UniversalResolver's ENSIP-10 `resolve()` on the + * ENS Root Chain. The UniversalResolver performs findResolver + ENSIP-10 + CCIP-Read on-chain, so this + * is the protocol-faithful path whenever ENSApi is not accelerating from indexed data. + */ +async function resolveViaUniversalResolver( + name: InterpretedName, + operations: Operation[], + publicClient: PublicClient, +): Promise { + const universalResolver = getDatasourceContract( + di.context.namespace, + DatasourceNames.ENSRoot, + "UniversalResolver", + ); + + return withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.ExecuteResolveCalls, + {}, + () => + executeOperations({ + name, + resolverAddress: universalResolver.address, + operations, + publicClient, + useENSIP10Resolve: true, + }), + ); +} + /** * Implements Forward Resolution of record values for a specified ENS Name. * @@ -158,34 +195,22 @@ async function _resolveForward( const publicClient = di.context.rootChainPublicClient; - //////////////////////////// - /// 0. Temporary ENSv2 Bailout - //////////////////////////// - // TODO: re-enable protocol acceleration for ENSv2 - // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 - // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is - // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root)) { - const universalResolver = getDatasourceContract( - di.context.namespace, - DatasourceNames.ENSRoot, - "UniversalResolver", - ); + //////////////////////////////////////////////////////////////// + /// 0 Non-Accelerated Resolution: delegate to UniversalResolver + //////////////////////////////////////////////////////////////// - operations = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.ExecuteResolveCalls, - {}, - () => - executeOperations({ - name, - resolverAddress: universalResolver.address, - operations, - publicClient, - useENSIP10Resolve: true, - }), - ); + // whether we can attempt to accelerate this resolution request + const canAttemptAcceleration = accelerate && canAccelerate; + + // TODO: re-enable protocol acceleration for ENSv2 + const temp_isENSv2Namespace = maybeGetDatasource( + di.context.namespace, + DatasourceNames.ENSv2Root, + ); + // when we cannot attempt acceleration or ENSv2 is deployed (temp), delegate to UniversalResolver + if (!canAttemptAcceleration || temp_isENSv2Namespace) { + operations = await resolveViaUniversalResolver(name, operations, publicClient); logOperations(operations, logger); return makeRecordsResponse(operations); } @@ -302,52 +327,71 @@ async function _resolveForward( } //////////////////////////////////////////////////////////////////////////// - // 4. Determine Resolver ENSIP-10 support + requirement. - // From here, we MUST execute EVM code to be compliant with ENS Protocol + // 4. Resolve remaining operations. + // 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 (stepSpan) => { - const extended = await withSpanAsync( - tracer, - "isExtendedResolver", - { chainId, address: activeResolver }, - () => isExtendedResolver({ address: activeResolver, publicClient }), - ); - stepSpan.setAttribute("isExtendedResolver", extended); + if (chainId === getENSRootChainId(di.context.namespace)) { + // On the ENS Root Chain, we have access to the UniversalResolver, so delegate to it + // rather than calling the discovered resolver directly. + // + // The reason for this is because a resolver's on-chain behavior can depend on being + // called by the canonical UniversalResolver. for example the URTestResolver gates + // IExtendedResolver support on `msg.sender == UniversalResolver.implementation()` — which + // ENSApi cannot reproduce off-chain. Delegating keeps Root Chain resolution 1:1 with + // the on-chain UniversalResolver. + operations = await resolveViaUniversalResolver(name, operations, publicClient); + } else { + // On a shadow Registry chain (e.g. Basenames/Lineanames) there is no UniversalResolver, + // so we resolve from the indicated activeResolver directly + + // 4.1 Determine Resolver ENSIP-10 support + requirement. + const extended = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.RequireResolver, + { chainId, activeResolver, requiresWildcardSupport }, + async (stepSpan) => { + const extended = await withSpanAsync( + tracer, + "isExtendedResolver", + { chainId, address: activeResolver }, + () => isExtendedResolver({ address: activeResolver, publicClient }), + ); + + stepSpan.setAttribute("isExtendedResolver", extended); + + return 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 + // https://docs.ens.domains/ensip/10/#specification + if (requiresWildcardSupport && !extended) { + return makeRecordsResponse(operations); + } - // 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 makeRecordsResponse(operations); + // 4.2 Resolve remaining operations via RPC + operations = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.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, + operations, + publicClient, + }), + ); } - /////////////////////////////////////////// - // 5. Resolve remaining operations via RPC - /////////////////////////////////////////// - operations = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.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, - operations, - publicClient, - }), - ); + //////////////////////////////////////////////////////////////////////////// + // 5. We're done! All `operations` should now be resolved. + //////////////////////////////////////////////////////////////////////////// // Invariant: all operations must be resolved if (!operations.every(isOperationResolved)) { @@ -356,7 +400,7 @@ async function _resolveForward( ); } - // return record values + // return records response from operations logOperations(operations, logger); return makeRecordsResponse(operations); }, From 62ae07d2adf62703ead1200edbe2b8e6c848a49b Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 18:03:22 -0500 Subject: [PATCH 2/2] fix: delegate all RPC fallback to UniversalResolver; address review nits - step 4 always delegates to the root-chain UniversalResolver (correctly resolves shadow-Registry names via the original input instead of calling an L2 resolver through the root-chain client) - coerce ENSv2 namespace check to boolean - remove now-orphaned imports (isExtendedResolver, withSpanAsync, getENSRootChainId) --- .../src/lib/resolution/forward-resolution.ts | 87 ++++--------------- 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 0eacc5a14..d5f12cec3 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -10,7 +10,7 @@ import { } from "enssdk"; import type { PublicClient } from "viem"; -import { DatasourceNames, getENSRootChainId, maybeGetDatasource } from "@ensnode/datasources"; +import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { type ForwardResolutionArgs, ForwardResolutionProtocolStep, @@ -23,13 +23,12 @@ import { } from "@ensnode/ensnode-sdk"; import { isBridgedResolver, - isExtendedResolver, isKnownENSIP19ReverseResolver, isStaticResolver, } from "@ensnode/ensnode-sdk/internal"; import di from "@/di"; -import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; import { findResolver } from "@/lib/protocol-acceleration/find-resolver"; import { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; @@ -203,13 +202,13 @@ async function _resolveForward( const canAttemptAcceleration = accelerate && canAccelerate; // TODO: re-enable protocol acceleration for ENSv2 - const temp_isENSv2Namespace = maybeGetDatasource( + const isENSv2Namespace = !!maybeGetDatasource( di.context.namespace, DatasourceNames.ENSv2Root, ); // when we cannot attempt acceleration or ENSv2 is deployed (temp), delegate to UniversalResolver - if (!canAttemptAcceleration || temp_isENSv2Namespace) { + if (!canAttemptAcceleration || isENSv2Namespace) { operations = await resolveViaUniversalResolver(name, operations, publicClient); logOperations(operations, logger); return makeRecordsResponse(operations); @@ -327,71 +326,23 @@ async function _resolveForward( } //////////////////////////////////////////////////////////////////////////// - // 4. Resolve remaining operations. - // From here, we MUST execute EVM code to be compliant with ENS Protocol. + // 4. Resolve remaining operations via RPC //////////////////////////////////////////////////////////////////////////// - if (chainId === getENSRootChainId(di.context.namespace)) { - // On the ENS Root Chain, we have access to the UniversalResolver, so delegate to it - // rather than calling the discovered resolver directly. - // - // The reason for this is because a resolver's on-chain behavior can depend on being - // called by the canonical UniversalResolver. for example the URTestResolver gates - // IExtendedResolver support on `msg.sender == UniversalResolver.implementation()` — which - // ENSApi cannot reproduce off-chain. Delegating keeps Root Chain resolution 1:1 with - // the on-chain UniversalResolver. - operations = await resolveViaUniversalResolver(name, operations, publicClient); - } else { - // On a shadow Registry chain (e.g. Basenames/Lineanames) there is no UniversalResolver, - // so we resolve from the indicated activeResolver directly - - // 4.1 Determine Resolver ENSIP-10 support + requirement. - const extended = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.RequireResolver, - { chainId, activeResolver, requiresWildcardSupport }, - async (stepSpan) => { - const extended = await withSpanAsync( - tracer, - "isExtendedResolver", - { chainId, address: activeResolver }, - () => isExtendedResolver({ address: activeResolver, publicClient }), - ); - - 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 - // https://docs.ens.domains/ensip/10/#specification - if (requiresWildcardSupport && !extended) { - return makeRecordsResponse(operations); - } - - // 4.2 Resolve remaining operations via RPC - operations = await withEnsProtocolStep( - TraceableENSProtocol.ForwardResolution, - ForwardResolutionProtocolStep.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, - operations, - publicClient, - }), - ); - } - - //////////////////////////////////////////////////////////////////////////// - // 5. We're done! All `operations` should now be resolved. - //////////////////////////////////////////////////////////////////////////// + // On the ENS Root Chain, we have access to the UniversalResolver, so delegate to it + // rather than calling the discovered resolver directly. + // + // The reason for this is that a resolver's on-chain behavior can depend on being + // called by the canonical UniversalResolver. for example the URTestResolver gates + // IExtendedResolver support on `msg.sender == UniversalResolver.implementation()` — which + // ENSApi cannot reproduce off-chain. Delegating keeps Root Chain resolution 1:1 with + // the on-chain UniversalResolver. + // + // Finally, if we are resolving on a shadow Registry chain (e.g. Basenames/Lineanames) for + // which we have recursed into _resolveForward AND the operations were not resolved above, + // then we need to execute EVM code, for which calling the UniversalResolver is also the + // correct approach. + operations = await resolveViaUniversalResolver(name, operations, publicClient); // Invariant: all operations must be resolved if (!operations.every(isOperationResolved)) {