Fix text record resolution and enhance CCIP-Read handling#111
Fix text record resolution and enhance CCIP-Read handling#111Douglasacost wants to merge 16 commits intomainfrom
Conversation
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.
LCOV of commit
|
There was a problem hiding this comment.
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
UniversalResolverwith EIP-712 signature verification and trusted signer rotation. - Added a new gateway
/resolveendpoint plus L2 resolution + signing utilities to supportaddr,addr-multichain, andtext. - 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. |
aliXsed
left a comment
There was a problem hiding this comment.
Consolidated inline review — 10 items across the changeset, ordered by priority.
| } | ||
|
|
||
| contract UniversalResolver is IExtendedResolver, IERC165, Ownable { | ||
| contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { |
There was a problem hiding this comment.
[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"; |
There was a problem hiding this comment.
[2/10] Ownable → Ownable2Step
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; |
There was a problem hiding this comment.
[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.)
| 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 { |
There was a problem hiding this comment.
[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]); |
There was a problem hiding this comment.
[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.)
| 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) { |
There was a problem hiding this comment.
[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.)
| return parsed; | ||
| } | ||
|
|
||
| const resolutionSignatureTtlSeconds = parseResolutionSignatureTtl( |
There was a problem hiding this comment.
[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" | |||
There was a problem hiding this comment.
[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")) |
There was a problem hiding this comment.
[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)); | ||
| } | ||
| } |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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|
Tracking issue for evaluating a trustless alternative to the signed-gateway interim solution: NodleCode/meta#169 — Evaluate 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 ( |
This pull request introduces a new signed-gateway UniversalResolver integration for ENS offchain resolution, adding a
/resolveendpoint 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:
/resolveroute 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]resolveFromL2utility to parse DNS-encoded ENS names, supportaddr,addr-multichain, andtextlookups, and return ABI-encoded results.signResolutionResponsefor EIP-712 signing of resolution results, matching the L1 UniversalResolver contract.Configuration and Setup Updates:
setup.tsto 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]getTextRecordfunction needed by the gateway.Deployment Script Changes:
These changes collectively enable secure, offchain ENS resolution via a gateway that signs responses for L1 verification, modernizing the resolver infrastructure and deployment flow.