Skip to content

Fix text record resolution and enhance CCIP-Read handling#111

Open
Douglasacost wants to merge 16 commits intomainfrom
fix/gateway-text-record-resolution
Open

Fix text record resolution and enhance CCIP-Read handling#111
Douglasacost wants to merge 16 commits intomainfrom
fix/gateway-text-record-resolution

Conversation

@Douglasacost
Copy link
Copy Markdown
Collaborator

This pull request introduces a new signed-gateway UniversalResolver integration for ENS offchain resolution, adding a /resolve endpoint that supports CCIP-Read (ERC-3668) with EIP-712 signatures, and refactors deployment scripts and configuration to support the new model. The changes include new resolver logic, EIP-712 signing, configuration wiring, and a simplified deployment script.

New ENS Gateway Resolution Functionality:

  • Added /resolve route that implements the CCIP-Read (ERC-3668) callback endpoint, decoding ENS resolution requests, routing them to the correct L2 NameService, and returning EIP-712 signed responses for use with the L1 UniversalResolver. [1] [2] [3]
  • Implemented resolveFromL2 utility to parse DNS-encoded ENS names, support addr, addr-multichain, and text lookups, and return ABI-encoded results.
  • Added signResolutionResponse for EIP-712 signing of resolution results, matching the L1 UniversalResolver contract.

Configuration and Setup Updates:

  • Extended setup.ts to wire up the new resolver signer, L1 resolver address, and signature TTL, and export them for use in the new route. [1] [2] [3] [4]
  • Updated the ENS NameService interface to include the getTextRecord function needed by the gateway.

Deployment Script Changes:

  • Refactored the L1 deployment script to support the new signed-gateway UniversalResolver, removing the StorageProofVerifier/SparseMerkleTree logic and adding support for setting the ENS resolver in a single broadcast if desired. [1] [2] [3]

These changes collectively enable secure, offchain ENS resolution via a gateway that signs responses for L1 verification, modernizing the resolver infrastructure and deployment flow.

Text records returned empty because NAME_SERVICE_INTERFACE was missing the
getTextRecord read fragment — ethers treated the method as non-existent and
the resolver's catch-all swallowed the error as "no record". CCIP-Read POSTs
from the ENS app were also rejected because they send Content-Type:
text/plain to skip CORS preflight, which express.json() ignored by default.
…olver

This commit introduces a comprehensive RFC-style documentation for the Signed-Gateway UniversalResolver, detailing its architecture, interfaces, and EIP-712 payload. The document outlines the resolver's functionality, including its integration with the L2 NameService and the trusted-gateway signature model, replacing the previous zkSync storage proof design.
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 13, 2026

LCOV of commit ad92b93 during checks #658

Summary coverage rate:
  lines......: 31.8% (771 of 2426 lines)
  functions..: 27.8% (105 of 378 functions)
  branches...: 37.2% (140 of 376 branches)

Files changed coverage rate:
                                                  |Lines       |Functions  |Branches    
  Filename                                        |Rate     Num|Rate    Num|Rate     Num
  ======================================================================================
  script/DeployL1Ens.s.sol                        | 0.0%     21| 0.0%     1| 0.0%      3
  src/nameservice/UniversalResolver.sol           | 0.0%     79| 0.0%     9| 0.0%     17

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates ENS offchain resolution to a signed-gateway CCIP-Read (ERC-3668) model: the L1 UniversalResolver reverts with OffchainLookup, the gateway resolves against L2 NameService, then returns an EIP-712 signed payload that the L1 resolver verifies.

Changes:

  • Replaced storage-proof based verification in UniversalResolver with EIP-712 signature verification and trusted signer rotation.
  • Added a new gateway /resolve endpoint plus L2 resolution + signing utilities to support addr, addr-multichain, and text.
  • Added protocol documentation and a Foundry test suite covering core success/failure paths and signer rotation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/nameservice/UniversalResolver.sol Switches resolver to CCIP-Read + EIP-712 signed responses; adds trusted signers and URL rotation.
clk-gateway/src/routes/resolve.ts Implements the CCIP-Read callback endpoint (/resolve) that decodes (name,data), resolves via L2, and signs responses.
clk-gateway/src/resolver/resolveFromL2.ts Adds L2 resolution dispatcher for addr, addr-multichain, and text and returns ABI-encoded results.
clk-gateway/src/resolver/signResolution.ts Adds EIP-712 signing for Resolution(name,data,result,expiresAt) and ABI-encodes the callback response payload.
clk-gateway/src/setup.ts Wires env/config for resolver signer, L1 resolver address, chainId, and signature TTL.
clk-gateway/src/index.ts Extends JSON parsing to accept text/plain and registers the new /resolve router.
clk-gateway/src/interfaces.ts Extends NameService interface with getTextRecord.
script/DeployL1Ens.s.sol Refactors deployment for signed-gateway resolver model and adds optional ENS setResolver step.
test/nameservice/UniversalResolver.t.sol Adds Foundry tests for signature verification, TTL bounds, signer rotation, and interface support.
src/nameservice/doc/signed-resolver-protocol.md Adds RFC-style protocol specification for the signed-gateway resolver model.

Comment thread clk-gateway/src/resolver/resolveFromL2.ts
Comment thread test/nameservice/UniversalResolver.t.sol Outdated
Comment thread test/nameservice/UniversalResolver.t.sol
Comment thread src/nameservice/doc/signed-resolver-protocol.md Outdated
Comment thread clk-gateway/src/routes/resolve.ts
Comment thread src/nameservice/UniversalResolver.sol
Comment thread clk-gateway/src/setup.ts
Comment thread src/nameservice/UniversalResolver.sol
Comment thread src/nameservice/UniversalResolver.sol
Comment thread src/nameservice/UniversalResolver.sol
@Douglasacost Douglasacost requested a review from aliXsed April 15, 2026 21:20
aliXsed

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator

@aliXsed aliXsed left a comment

Choose a reason for hiding this comment

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

Consolidated inline review — 10 items across the changeset, ordered by priority.

}

contract UniversalResolver is IExtendedResolver, IERC165, Ownable {
contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[1/10] Create a new contract — don't modify the existing UniversalResolver

The deployed contract's source should be preserved as a historical artifact. The new contract has a fundamentally different verification model (EIP-712 signatures vs. storage proofs). It should live in a separate file/contract. (Reiterating my earlier comment.)

pragma solidity ^0.8.26;

import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[2/10] OwnableOwnable2Step

Per the project's own standards: "When a contract requires an owner, prefer Ownable2Step over Ownable." This contract requires ownership for setUrl and setTrustedSigner. A single-step transferOwnership to a wrong address is irrecoverable and bricks signer management.

/// @notice Update the CCIP-Read gateway URL.
function setUrl(string memory _url) external onlyOwner {
emit UrlUpdated(url, _url);
url = _url;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[3/10] setUrl missing empty-URL validation

The constructor validates bytes(_url).length == 0 → revert EmptyUrl(), but setUrl does not. The owner can accidentally brick all subdomain resolution by setting an empty URL.

(Reiterating my earlier inline suggestion — add CEI ordering + empty check.)

Suggested change
url = _url;
function setUrl(string memory _url) external onlyOwner {
bytes memory b = bytes(_url);
if (b.length == 0) revert EmptyUrl();
string memory oldUrl = url;
url = _url;
emit UrlUpdated(oldUrl, url);
}

/// @notice Enable or disable a trusted gateway signer.
/// @dev Keeps `trustedSignerCount` in sync and enforces a floor of 1 so the
/// owner cannot brick resolution by disabling the last signer.
function setTrustedSigner(address signer, bool trusted) external onlyOwner {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[4/10] Split setTrustedSigner into trustSigner / revokeSigner

setTrustedSigner(addr, false) is semantically ambiguous — "set trusted signer" that actually removes trust. Two distinct functions simplify access-control auditing and event decoding. (Reiterating my earlier comment.)

}

// Dispatch only on supported selectors so the gateway is never asked for nonsense.
bytes4 functionSelector = bytes4(_data[:4]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[5/10] Add _data.length >= 4 check in resolve()

Short calldata causes a panic (out-of-bounds) rather than a clean CallDataTooShort revert. The custom error CallDataTooShort already exists but the guard is missing before this slice. (Copilot flagged this — verify it's addressed.)

Suggested change
bytes4 functionSelector = bytes4(_data[:4]);
if (_data.length < 4) revert CallDataTooShort();
bytes4 functionSelector = bytes4(_data[:4]);

if (functionSelector == _TEXT_SELECTOR) {
return abi.encode("");
}
if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[6/10] Fix addr(bytes32,uint256) bare-domain return type

Bare-domain handler returns abi.encode(address(0)) for multichain, but ENSIP-11 defines addr(bytes32,uint256) as returning bytes. Should return abi.encode(bytes("")) for the multichain case. (Copilot flagged this — verify it's addressed.)

Comment thread clk-gateway/src/setup.ts
return parsed;
}

const resolutionSignatureTtlSeconds = parseResolutionSignatureTtl(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[7/10] Convert parseResolutionSignatureTtl to IIFE for consistency

Every other derived config in this file is an inline const expression. The 23-line named function stands out. The validation is necessary (prevents emitting signatures guaranteed to revert on-chain), but converting to an IIFE would match the file's style:

const resolutionSignatureTtlSeconds = (() => {
  const raw = process.env.RESOLUTION_SIGNATURE_TTL_SECONDS;
  if (raw === undefined || raw === "") return 60;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || !Number.isInteger(parsed))
    throw new Error(`Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: "${raw}" is not a finite integer`);
  if (parsed <= 0)
    throw new Error(`RESOLUTION_SIGNATURE_TTL_SECONDS must be > 0, got ${parsed}`);
  if (parsed > MAX_RESOLUTION_SIGNATURE_TTL_SECONDS)
    throw new Error(`RESOLUTION_SIGNATURE_TTL_SECONDS must be <= ${MAX_RESOLUTION_SIGNATURE_TTL_SECONDS}, got ${parsed}`);
  return parsed;
})();

@@ -0,0 +1,163 @@
import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, isHexString } from "ethers"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[8/10] Unused hexlify import

hexlify is imported here but never used anywhere in this file. Remove it.

decodedData = d
}

const parsed = parseDnsDomain(Buffer.from(decodedName.slice(2), "hex"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[9/10] Consider validating parsed.tld against parentTLD

The gateway uses parsed.sub and parsed.domain for routing but never validates parsed.tld. A sanity check against the configured parentTLD would reject obviously malformed names early. Low priority since the L1 resolver already constrains which names reach the gateway.

assertTrue(resolver.isTrustedSigner(signer));
assertFalse(resolver.isTrustedSigner(backupSigner));
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[10/10] Missing fuzz tests for TTL boundaries

Per project standards (testFuzz_ required for arithmetic/functional logic): TTL boundary checks and DNS parsing have no fuzz coverage. At minimum testFuzz_ResolveWithSig_ExpiresAt for various timestamp values would strengthen confidence in the TTL/expiry arithmetic.

/// @param _data ABI-encoded ENS resolution call (addr / addr-multichain / text)
function resolve(bytes calldata _name, bytes calldata _data) external view returns (bytes memory) {
(string memory sub, string memory dom,) = _parseDnsDomain(_name);
(string memory sub,,) = _parseDnsDomain(_name);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Addendum] dom is discarded — consider using it for validation

resolve() extracts (sub,,) and discards dom entirely. The contract never validates that the domain portion of the DNS name matches its intended domain.

Impact: If the ENS registry mistakenly points a different domain at this resolver, it would happily trigger OffchainLookup for that unrelated domain with no guard. The gateway compensates (it routes by parsed.domain), but the contract itself is blind to it.

Suggested fix: At minimum, pass dom through to the gateway in the callData/extraData so it can be validated end-to-end. Better yet, store the expected domain in the contract and reject mismatches on-chain — the gas cost is trivial for a view function.

// Instead of:
(string memory sub,,) = _parseDnsDomain(_name);

// Consider:
(string memory sub, string memory dom,) = _parseDnsDomain(_name);
// ... use dom for validation or pass it through

@aliXsed
Copy link
Copy Markdown
Collaborator

aliXsed commented Apr 17, 2026

Tracking issue for evaluating a trustless alternative to the signed-gateway interim solution: NodleCode/meta#169Evaluate Unruggable Gateways ZkSync support as replacement for trusted signed-gateway.

The signed-gateway model in this PR is standards-compliant (ERC-3668, ENSIP-10) and matches what Coinbase/Uniswap/ENS reference use in production. However, Unruggable Gateways already has ZkSync support (ZKSyncRollup + ZKSyncProver + ZKSyncVerifierHooks) and could potentially restore the trustless verification model we had before the ZkSync proof format break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants