From 363926ce187949f3a8e8801f53a370f5b004266b Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 15:32:01 -0400 Subject: [PATCH 01/28] fix(gateway): resolve text records and accept text/plain CCIP-Read POSTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- clk-gateway/src/index.ts | 4 +++- clk-gateway/src/interfaces.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/clk-gateway/src/index.ts b/clk-gateway/src/index.ts index 85a563fc..ed33bb3f 100644 --- a/clk-gateway/src/index.ts +++ b/clk-gateway/src/index.ts @@ -46,7 +46,9 @@ import reservedHashes from "./reservedHashes"; import namesRouter from "./routes/names"; const app = express(); -app.use(express.json()); +// CCIP-Read clients (and the ENS app) often POST with Content-Type: text/plain +// to avoid triggering a CORS preflight. Parse JSON regardless of content type. +app.use(express.json({ type: ["application/json", "text/plain"] })); const corsOptions = { origin: "*", diff --git a/clk-gateway/src/interfaces.ts b/clk-gateway/src/interfaces.ts index e8dda5f4..4e6a2ea6 100644 --- a/clk-gateway/src/interfaces.ts +++ b/clk-gateway/src/interfaces.ts @@ -47,6 +47,7 @@ export const NAME_SERVICE_INTERFACE = new Interface([ "function expires(uint256 key) public view returns (uint256)", "function register(address to, string memory name)", "function resolve(string memory name) external view returns (address)", + "function getTextRecord(string memory name, string memory key) external view returns (string memory)", "function setTextRecord(string memory name, string memory key, string memory value) external", "error NameExpired(address oldOwner, uint256 expiredAt)", "error ERC721NonexistentToken(uint256 tokenId)", From 9bca5e938cdf6a5d26a0e06bf587defb3b983072 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 16:37:39 -0400 Subject: [PATCH 02/28] feat(resolver): replace storage-proof verification with EIP-712 signed gateway --- script/DeployL1Ens.s.sol | 59 ++--- src/nameservice/UniversalResolver.sol | 195 ++++++++------- test/nameservice/UniversalResolver.t.sol | 296 +++++++++++++++++++++++ 3 files changed, 421 insertions(+), 129 deletions(-) create mode 100644 test/nameservice/UniversalResolver.t.sol diff --git a/script/DeployL1Ens.s.sol b/script/DeployL1Ens.s.sol index e7f020a2..fe6a512c 100644 --- a/script/DeployL1Ens.s.sol +++ b/script/DeployL1Ens.s.sol @@ -2,11 +2,6 @@ pragma solidity ^0.8.18; import {Script, console} from "lib/forge-std/src/Script.sol"; -import {SparseMerkleTree} from "lib/zksync-storage-proofs/packages/zksync-storage-contracts/src/SparseMerkleTree.sol"; -import { - StorageProofVerifier, - IZkSyncDiamond -} from "lib/zksync-storage-proofs/packages/zksync-storage-contracts/src/StorageProofVerifier.sol"; import {UniversalResolver} from "../src/nameservice/UniversalResolver.sol"; interface IResolverSetter { @@ -19,51 +14,39 @@ contract DeployL1Ens is Script { string memory deployerPrivateKey = vm.envString("DEPLOYER_PRIVATE_KEY"); vm.startBroadcast(vm.parseUint(deployerPrivateKey)); - address spvAddress = vm.envOr("STORAGE_PROOF_VERIFIER_ADDR", address(0)); - - if (spvAddress == address(0)) { - address smtAddress = vm.envOr("SPARSE_MERKLE_TREE_ADDR", address(0)); - if (smtAddress == address(0)) { - console.log("Deploying SparseMerkleTree..."); - SparseMerkleTree sparseMerkleTree = new SparseMerkleTree(); - smtAddress = address(sparseMerkleTree); - console.log("Deployed SparseMerkleTree at", smtAddress); - } else { - console.log("Using SparseMerkleTree at", smtAddress); - } - - console.log("Deploying StorageProofVerifier..."); - StorageProofVerifier storageProofVerifier = new StorageProofVerifier( - IZkSyncDiamond(vm.envAddress("DIAMOND_PROXY_ADDR")), SparseMerkleTree(smtAddress) - ); - spvAddress = address(storageProofVerifier); - console.log("Deployed StorageProofVerifier at", spvAddress); - } else { - console.log("Using StorageProofVerifier at", spvAddress); - } - address resolverAddress = vm.envOr("NS_RESOLVER_ADDR", address(0)); if (resolverAddress == address(0)) { - console.log("Deploying UniversalResolver..."); + console.log("Deploying UniversalResolver (signed-gateway model)..."); UniversalResolver l1Resolver = new UniversalResolver( vm.envString("NS_OFFCHAIN_RESOLVER_URL"), vm.envAddress("NS_OWNER_ADDR"), vm.envAddress("NS_ADDR"), - StorageProofVerifier(spvAddress) + vm.envAddress("NS_TRUSTED_SIGNER_ADDR") ); resolverAddress = address(l1Resolver); console.log("Deployed UniversalResolver at", resolverAddress); } - string memory label = vm.envString("NS_DOMAIN"); - bytes32 labelHash = keccak256(abi.encodePacked(label)); - - bytes32 ETH_NODE = 0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae; - bytes32 node = keccak256(abi.encodePacked(ETH_NODE, labelHash)); - - IResolverSetter resolverSetter = IResolverSetter(vm.envAddress("NAME_WRAPPER_ADDR")); - resolverSetter.setResolver(node, resolverAddress); + // Optional: auto-repoint ENS to the new resolver in the same broadcast. + // Enable by setting SKIP_SET_RESOLVER to 0 (default is 1 = skip, so mainnet + // cutover happens as a separate owner-signed tx). Useful on testnets where + // the deployer already controls the ENS node. + uint256 skipSetResolver = vm.envOr("SKIP_SET_RESOLVER", uint256(1)); + if (skipSetResolver == 0) { + string memory label = vm.envString("NS_DOMAIN"); + bytes32 labelHash = keccak256(abi.encodePacked(label)); + + bytes32 ETH_NODE = 0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae; + bytes32 node = keccak256(abi.encodePacked(ETH_NODE, labelHash)); + + IResolverSetter resolverSetter = IResolverSetter(vm.envAddress("NAME_WRAPPER_ADDR")); + resolverSetter.setResolver(node, resolverAddress); + console.log("Repointed ENS node to new resolver"); + } else { + console.log("Skipping ENS setResolver (SKIP_SET_RESOLVER=1)"); + console.log("Run ENSRegistry.setResolver(...) separately with the node owner."); + } vm.stopBroadcast(); } diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 963802be..9ce0e19f 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -1,18 +1,23 @@ // SPDX-License-Identifier: BSD-3-Clause-Clear /** - * @title UniversalResolver for resolving ens subdomains based on names registered on L2 - * @dev This contract is based on ClaveResolver that can be found in this repository: - * https://github.com/getclave/zksync-storage-proofs + * @title UniversalResolver + * @notice ENS-compatible L1 resolver for names registered on L2 (zkSync Era). + * @dev Uses the CCIP-Read (ERC-3668) pattern with a trusted-gateway signature + * model. The off-chain gateway queries the L2 NameService directly and + * returns an EIP-712 signed response. This contract recovers the signer + * and accepts the response only if it matches a registered trusted signer. + * + * This replaces the earlier zkSync storage-proof design which depended on + * per-batch state roots being committed to L1 — that path was broken when + * zkSync Era migrated settlement to ZK Gateway (~July 30, 2025). */ pragma solidity ^0.8.26; import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import { - StorageProof, - StorageProofVerifier -} from "zksync-storage-proofs/packages/zksync-storage-contracts/src/StorageProofVerifier.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; /// @title IExtendedResolver /// @notice ENSIP-10: Wildcard Resolution @@ -20,53 +25,64 @@ interface IExtendedResolver { function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory); } -contract UniversalResolver is IExtendedResolver, IERC165, Ownable { +contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { bytes4 private constant _EXTENDED_INTERFACE_ID = 0x9061b923; // ENSIP-10 bytes4 private constant _ADDR_SELECTOR = 0x3b3b57de; // addr(bytes32) bytes4 private constant _ADDR_MULTICHAIN_SELECTOR = 0xf1cb7e06; // addr(bytes32,uint) bytes4 private constant _TEXT_SELECTOR = 0x59d1d43c; // text(bytes32,string) - uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) >>> 0 as per ENSIP11 + uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) per ENSIP-11 + + /// @notice EIP-712 typehash for the payload signed by the trusted gateway. + /// @dev Keccak of "Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)" + bytes32 private constant _RESOLUTION_TYPEHASH = + keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); - error UnsupportedChain(uint256 coinType); - error InvalidStorageProof(); - - /// @notice Storage proof verifier contract - StorageProofVerifier public storageProofVerifier; + error SignatureExpired(uint64 expiresAt); + error InvalidSigner(address recovered); - /// @notice URL of the resolver + /// @notice URL of the CCIP-Read gateway. string public url; - /// @notice Address of the register contract on L2 + /// @notice Address of the L2 NameService contract. Read by the off-chain gateway + /// to choose which L2 contract to query. Not consulted on-chain — the trust + /// anchor for resolution is the EIP-712 signer, not this field. address public immutable registry; - /// @notice Storage slot for the mapping index, specific to registry contract - uint256 public immutable addrsSlot; - uint256 public immutable textRecordsSlot; + /// @notice Trusted signers whose EIP-712 signatures this resolver will accept. + /// Mapping (rather than a single address) to allow zero-downtime key rotation. + mapping(address => bool) public isTrustedSigner; - /// @notice Address of the domain owner - address public domainOwner; + event UrlUpdated(string oldUrl, string newUrl); + event TrustedSignerUpdated(address indexed signer, bool trusted); - constructor(string memory _url, address _domainOwner, address _registry, StorageProofVerifier _storageProofVerifier) - Ownable(_domainOwner) + constructor(string memory _url, address _owner, address _registry, address _initialSigner) + Ownable(_owner) + EIP712("NodleUniversalResolver", "1") { url = _url; - domainOwner = _domainOwner; registry = _registry; - storageProofVerifier = _storageProofVerifier; - // With the current storage layout of ClickNameResolver, the mapping slot of _owners storage is 2 and the mapping slot of _textRecords storage is 9 - addrsSlot = 2; - textRecordsSlot = 9; + isTrustedSigner[_initialSigner] = true; + emit TrustedSignerUpdated(_initialSigner, true); } + /// @notice Update the CCIP-Read gateway URL. function setUrl(string memory _url) external onlyOwner { + emit UrlUpdated(url, _url); url = _url; } + /// @notice Enable or disable a trusted gateway signer. + /// @dev Keep at least one trusted signer enabled at all times or resolution will break. + function setTrustedSigner(address signer, bool trusted) external onlyOwner { + isTrustedSigner[signer] = trusted; + emit TrustedSignerUpdated(signer, trusted); + } + /// @notice Parses DNS encoded domain name /// @param name DNS encoded domain name /// @return _sub Subdomain @@ -105,87 +121,84 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable { return (first, second, third); } - /// @notice Calculates the key for the given subdomain name in the L2 registry - /// @dev Names are stored in the registry, in a mapping with slot `addrsSlot` - function getStorageKey(string memory subDomain) public view returns (bytes32) { - uint256 tokenId = uint256(keccak256(abi.encodePacked(subDomain))); - return keccak256(abi.encode(tokenId, addrsSlot)); - } - - /// @notice Calculates the storage key for a specific text record - /// @param subDomain The subdomain to get the record for - /// @param key The text record key (e.g. "avatar") - /// @return The final storage key for the text value: mapping (string => string) - function getTextRecordStorageKey(string memory subDomain, string memory key) public view returns (bytes32) { - uint256 tokenId = uint256(keccak256(abi.encodePacked(subDomain))); - bytes32 firstLevel = keccak256(abi.encode(tokenId, textRecordsSlot)); - return keccak256(abi.encodePacked(key, firstLevel)); - } - - /// @notice Resolves a name based on its subdomain part regardless of the given domain and top level - /// @param _name The name to resolve which must be a pack of length prefixed names for subdomain, domain and top. - /// example: b"\x07example\x05clave\x03eth" - /// - /// @param _data The ABI encoded data for the underlying resolution function (Eg, addr(bytes32), text(bytes32,string), etc). + /// @notice ENSIP-10 entry point. Triggers CCIP-Read lookup via OffchainLookup revert. + /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") + /// @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); - if (bytes(sub).length == 0) { - return abi.encodePacked(domainOwner); + // Dispatch only on supported selectors so the gateway is never asked for nonsense. + bytes4 functionSelector = bytes4(_data[:4]); + if ( + functionSelector != _TEXT_SELECTOR && functionSelector != _ADDR_SELECTOR + && functionSelector != _ADDR_MULTICHAIN_SELECTOR + ) { + revert UnsupportedSelector(functionSelector); + } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); + if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { + revert UnsupportedCoinType(coinType); + } } - bytes4 functionSelector = bytes4(_data[:4]); - bytes32 storageKey; - - if (functionSelector == _TEXT_SELECTOR) { - (, string memory key) = abi.decode(_data[4:], (bytes32, string)); - storageKey = getTextRecordStorageKey(sub, key); - } else if (functionSelector == _ADDR_SELECTOR || functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - storageKey = getStorageKey(sub); - if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); - if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { - revert UnsupportedCoinType(coinType); - } + // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 with + // the ENS "no record" convention: zero address for addr queries, empty string + // for text queries. The resolver only exists to answer subdomain lookups — it + // holds no state about the parent name. If a specific address needs to be + // associated with the bare domain, set it via a different resolver at the + // ENS registry level. + if (bytes(sub).length == 0) { + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(""); } - } else { - revert UnsupportedSelector(functionSelector); + return abi.encode(address(0)); } - bytes memory callData = abi.encode(storageKey, dom); - bytes memory extraData = abi.encode(storageKey, functionSelector); + // Pass the raw (name, data) to the gateway. It will query the L2 NameService, + // build the ABI-encoded result, and return it along with an EIP-712 signature. + bytes memory callData = abi.encode(_name, _data); + bytes memory extraData = abi.encode(_name, _data); string[] memory urls = new string[](1); urls[0] = url; - revert OffchainLookup(address(this), urls, callData, UniversalResolver.resolveWithProof.selector, extraData); + revert OffchainLookup(address(this), urls, callData, UniversalResolver.resolveWithSig.selector, extraData); } - /// @notice Callback used by CCIP read compatible clients to verify and parse the response. - /// @param _response ABI encoded StorageProof struct - /// @return ABI encoded value of the storage key - function resolveWithProof(bytes memory _response, bytes memory _extraData) external view returns (bytes memory) { - (StorageProof memory proof, string memory stringValue) = abi.decode(_response, (StorageProof, string)); - (uint256 storageKey, bytes4 functionSelector) = abi.decode(_extraData, (uint256, bytes4)); + /// @notice CCIP-Read callback. Verifies the gateway's EIP-712 signature and returns the result. + /// @param _response ABI-encoded (bytes result, uint64 expiresAt, bytes signature) + /// @param _extraData ABI-encoded (bytes name, bytes data) — echoed from the original resolve() call + /// @return The ABI-encoded resolution result, ready to be returned to the ENS caller. + function resolveWithSig(bytes calldata _response, bytes calldata _extraData) + external + view + returns (bytes memory) + { + (bytes memory result, uint64 expiresAt, bytes memory signature) = + abi.decode(_response, (bytes, uint64, bytes)); + (bytes memory name, bytes memory data) = abi.decode(_extraData, (bytes, bytes)); - // Replace the account in the proof with the known address of the registry - proof.account = registry; - // Replace the key in the proof with the caller's specified key. It's because the caller may obtain the response/proof from an untrusted offchain source. - proof.key = storageKey; + if (block.timestamp > expiresAt) { + revert SignatureExpired(expiresAt); + } - bool verified = storageProofVerifier.verify(proof); + bytes32 structHash = keccak256( + abi.encode(_RESOLUTION_TYPEHASH, keccak256(name), keccak256(data), keccak256(result), expiresAt) + ); + bytes32 digest = _hashTypedDataV4(structHash); + address recovered = ECDSA.recover(digest, signature); - if (!verified) { - revert InvalidStorageProof(); + if (!isTrustedSigner[recovered]) { + revert InvalidSigner(recovered); } - if (functionSelector == _TEXT_SELECTOR) { - return abi.encode(stringValue); - } else if (functionSelector == _ADDR_SELECTOR || functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - return abi.encodePacked(proof.value); - } else { - revert UnsupportedSelector(functionSelector); - } + return result; + } + + /// @notice Expose the EIP-712 domain separator so off-chain signers can verify their setup. + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); } /** diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol new file mode 100644 index 00000000..fdcbb039 --- /dev/null +++ b/test/nameservice/UniversalResolver.t.sol @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {UniversalResolver, IExtendedResolver} from "../../src/nameservice/UniversalResolver.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract UniversalResolverTest is Test { + UniversalResolver public resolver; + + address public owner; + address public registry; + address public signer; + uint256 public signerPk; + address public backupSigner; + uint256 public backupSignerPk; + + string public constant GATEWAY_URL = "https://gateway.nodle.com/resolve"; + + // ENS selectors + bytes4 private constant ADDR_SELECTOR = 0x3b3b57de; + bytes4 private constant ADDR_MULTICHAIN_SELECTOR = 0xf1cb7e06; + bytes4 private constant TEXT_SELECTOR = 0x59d1d43c; + uint256 private constant ZKSYNC_MAINNET_COIN_TYPE = 2147483972; + + bytes32 private constant RESOLUTION_TYPEHASH = + keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); + + // b"\x07example\x05clave\x03eth\x00" DNS encoding of example.clave.eth + bytes private constant DNS_FULL = hex"076578616d706c6505636c6176650365746800"; + // b"\x05clave\x03eth\x00" bare domain + bytes private constant DNS_BARE = hex"05636c6176650365746800"; + + event TrustedSignerUpdated(address indexed signer, bool trusted); + + function setUp() public { + owner = makeAddr("owner"); + registry = makeAddr("registry"); + (signer, signerPk) = makeAddrAndKey("signer"); + (backupSigner, backupSignerPk) = makeAddrAndKey("backup"); + + resolver = new UniversalResolver(GATEWAY_URL, owner, registry, signer); + } + + // --- helpers --- + + function _signResolution( + uint256 pk, + bytes memory name, + bytes memory data, + bytes memory result, + uint64 expiresAt + ) internal view returns (bytes memory) { + bytes32 structHash = keccak256( + abi.encode( + RESOLUTION_TYPEHASH, + keccak256(name), + keccak256(data), + keccak256(result), + expiresAt + ) + ); + bytes32 digest = MessageHashUtils.toTypedDataHash(resolver.domainSeparator(), structHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } + + function _addrCallData(string memory ensName) internal pure returns (bytes memory) { + bytes32 node = keccak256(bytes(ensName)); // value doesn't matter for tests + return abi.encodeWithSelector(ADDR_SELECTOR, node); + } + + function _textCallData(string memory ensName, string memory key) internal pure returns (bytes memory) { + bytes32 node = keccak256(bytes(ensName)); + return abi.encodeWithSelector(TEXT_SELECTOR, node, key); + } + + function _addrMultichainCallData(string memory ensName, uint256 coinType) internal pure returns (bytes memory) { + bytes32 node = keccak256(bytes(ensName)); + return abi.encodeWithSelector(ADDR_MULTICHAIN_SELECTOR, node, coinType); + } + + // --- resolve() — triggers OffchainLookup --- + + function test_Resolve_BareDomain_Addr_ReturnsZeroAddress() public view { + bytes memory out = resolver.resolve(DNS_BARE, _addrCallData("clave.eth")); + // abi.encode(address) is 32 bytes (left-padded) so ENS clients can decode it. + assertEq(out.length, 32); + assertEq(abi.decode(out, (address)), address(0)); + } + + function test_Resolve_BareDomain_Text_ReturnsEmptyString() public view { + bytes memory out = resolver.resolve(DNS_BARE, _textCallData("clave.eth", "com.twitter")); + assertEq(abi.decode(out, (string)), ""); + } + + function test_Resolve_BareDomain_AddrMultichain_ReturnsZeroAddress() public view { + bytes memory out = resolver.resolve(DNS_BARE, _addrMultichainCallData("clave.eth", ZKSYNC_MAINNET_COIN_TYPE)); + assertEq(abi.decode(out, (address)), address(0)); + } + + function test_Resolve_RevertsOffchainLookup_Addr() public { + bytes memory data = _addrCallData("example.clave.eth"); + vm.expectRevert(); // OffchainLookup is a custom error; just assert it reverts + resolver.resolve(DNS_FULL, data); + } + + function test_Resolve_UnsupportedSelector_Reverts() public { + bytes memory bogus = abi.encodeWithSelector(bytes4(0xdeadbeef), bytes32(0)); + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.UnsupportedSelector.selector, bytes4(0xdeadbeef))); + resolver.resolve(DNS_FULL, bogus); + } + + function test_Resolve_AddrMultichain_WrongCoinType_Reverts() public { + bytes memory data = _addrMultichainCallData("example.clave.eth", 60); // ETH mainnet coin type + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.UnsupportedCoinType.selector, uint256(60))); + resolver.resolve(DNS_FULL, data); + } + + function test_Resolve_AddrMultichain_ZkSyncCoinType_Reverts_OffchainLookup() public { + bytes memory data = _addrMultichainCallData("example.clave.eth", ZKSYNC_MAINNET_COIN_TYPE); + vm.expectRevert(); // accepted → OffchainLookup + resolver.resolve(DNS_FULL, data); + } + + // --- resolveWithSig() — happy paths --- + + function test_ResolveWithSig_Addr_HappyPath() public { + address expectedOwner = makeAddr("owner"); + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(expectedOwner); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + assertEq(abi.decode(out, (address)), expectedOwner); + } + + function test_ResolveWithSig_Text_HappyPath() public { + string memory textValue = "@nodle_network"; + bytes memory data = _textCallData("example.clave.eth", "com.twitter"); + bytes memory result = abi.encode(textValue); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(abi.decode(out, (string)), textValue); + } + + // --- resolveWithSig() — failure modes --- + + function test_ResolveWithSig_ExpiredSignature_Reverts() public { + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + vm.warp(uint256(expiresAt) + 1); + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.SignatureExpired.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } + + function test_ResolveWithSig_UntrustedSigner_Reverts() public { + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(block.timestamp + 60); + + // Sign with backup key which is NOT yet trusted. + bytes memory sig = _signResolution(backupSignerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.InvalidSigner.selector, backupSigner)); + resolver.resolveWithSig(response, extraData); + } + + function test_ResolveWithSig_TamperedResult_Reverts() public { + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory signedResult = abi.encode(makeAddr("owner")); + bytes memory tamperedResult = abi.encode(makeAddr("attacker")); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, signedResult, expiresAt); + // swap in a different result while keeping the signature + bytes memory response = abi.encode(tamperedResult, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // Signature will recover to some random address that isn't trusted. + vm.expectRevert(); // InvalidSigner with unpredictable recovered addr + resolver.resolveWithSig(response, extraData); + } + + // --- signer rotation --- + + function test_SignerRotation_AddBackup_RevokeOriginal() public { + // Enable backup signer + vm.prank(owner); + vm.expectEmit(true, false, false, true, address(resolver)); + emit TrustedSignerUpdated(backupSigner, true); + resolver.setTrustedSigner(backupSigner, true); + + // Backup signature now works + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(block.timestamp + 60); + bytes memory backupSig = _signResolution(backupSignerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, backupSig); + bytes memory extraData = abi.encode(DNS_FULL, data); + resolver.resolveWithSig(response, extraData); + + // Revoke original signer + vm.prank(owner); + resolver.setTrustedSigner(signer, false); + + // Original signer's signatures are now rejected + bytes memory oldSig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory oldResponse = abi.encode(result, expiresAt, oldSig); + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.InvalidSigner.selector, signer)); + resolver.resolveWithSig(oldResponse, extraData); + } + + function test_SetTrustedSigner_OnlyOwner() public { + vm.expectRevert(); + resolver.setTrustedSigner(backupSigner, true); + } + + // --- url setter --- + + function test_SetUrl_OnlyOwner() public { + vm.expectRevert(); + resolver.setUrl("https://evil.example"); + + vm.prank(owner); + resolver.setUrl("https://new.example"); + assertEq(resolver.url(), "https://new.example"); + } + + // --- EIP-712 domain binding --- + + function test_DomainSeparator_IsNonZero() public view { + assertTrue(resolver.domainSeparator() != bytes32(0)); + } + + function test_ResolveWithSig_SignatureFromDifferentDomainSeparator_Reverts() public { + // Simulate a signature built with a wrong domain separator (e.g. another + // resolver deployment). It should fail to recover the trusted signer. + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes32 structHash = keccak256( + abi.encode(RESOLUTION_TYPEHASH, keccak256(DNS_FULL), keccak256(data), keccak256(result), expiresAt) + ); + // Use a bogus domain separator + bytes32 badDomainSep = keccak256("wrong-domain"); + bytes32 digest = MessageHashUtils.toTypedDataHash(badDomainSep, structHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + vm.expectRevert(); // recovers some non-trusted address + resolver.resolveWithSig(response, extraData); + } + + // --- interface support --- + + function test_SupportsInterface() public view { + // IERC165 + assertTrue(resolver.supportsInterface(0x01ffc9a7)); + // ENSIP-10 extended resolver + assertTrue(resolver.supportsInterface(0x9061b923)); + // IExtendedResolver + assertTrue(resolver.supportsInterface(type(IExtendedResolver).interfaceId)); + // bogus + assertFalse(resolver.supportsInterface(0xdeadbeef)); + } + + // --- sanity: initial signer was set --- + + function test_InitialSignerIsTrusted() public view { + assertTrue(resolver.isTrustedSigner(signer)); + assertFalse(resolver.isTrustedSigner(backupSigner)); + } +} From 019599c4b5f16b740aa9568634ebc6ca6b9abd0f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 16:41:10 -0400 Subject: [PATCH 03/28] fix(resolver): block renounceOwnership to prevent bricking admin setters --- src/nameservice/UniversalResolver.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 9ce0e19f..38b0c9e8 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -83,6 +83,13 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { emit TrustedSignerUpdated(signer, trusted); } + /// @notice Ownership cannot be renounced: losing the owner bricks setUrl and + /// setTrustedSigner, which would permanently break gateway rotation and + /// signer revocation. Transfer to a new owner instead. + function renounceOwnership() public pure override { + revert("ownership cannot be renounced"); + } + /// @notice Parses DNS encoded domain name /// @param name DNS encoded domain name /// @return _sub Subdomain From 9723b9e505e3bf89804eaef3472d61f7c5f96579 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 16:41:38 -0400 Subject: [PATCH 04/28] feat(resolver): cap max signature TTL at 5 minutes to bound replay window --- src/nameservice/UniversalResolver.sol | 10 ++++++++++ test/nameservice/UniversalResolver.t.sol | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 38b0c9e8..e8aca885 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -38,10 +38,17 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { bytes32 private constant _RESOLUTION_TYPEHASH = keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); + /// @notice Hard cap on how far into the future a gateway signature may claim to be valid. + /// @dev Bounds the replay window if a signer key is compromised: even a maliciously + /// long `expiresAt` is clamped to this value on-chain. 5 minutes is comfortably + /// above L1 clock skew while keeping blast radius small. + uint64 private constant _MAX_SIGNATURE_TTL = 5 minutes; + error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); error SignatureExpired(uint64 expiresAt); + error SignatureTtlTooLong(uint64 expiresAt); error InvalidSigner(address recovered); /// @notice URL of the CCIP-Read gateway. @@ -189,6 +196,9 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { if (block.timestamp > expiresAt) { revert SignatureExpired(expiresAt); } + if (expiresAt > block.timestamp + _MAX_SIGNATURE_TTL) { + revert SignatureTtlTooLong(expiresAt); + } bytes32 structHash = keccak256( abi.encode(_RESOLUTION_TYPEHASH, keccak256(name), keccak256(data), keccak256(result), expiresAt) diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index fdcbb039..844fd1b5 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -170,6 +170,20 @@ contract UniversalResolverTest is Test { resolver.resolveWithSig(response, extraData); } + function test_ResolveWithSig_TtlTooLong_Reverts() public { + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + // 10 minutes > 5 minute max cap + uint64 expiresAt = uint64(block.timestamp + 10 minutes); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.SignatureTtlTooLong.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } + function test_ResolveWithSig_UntrustedSigner_Reverts() public { bytes memory data = _addrCallData("example.clave.eth"); bytes memory result = abi.encode(makeAddr("owner")); From 0dbeba53db5b794568ec3552c3d113e625feb3b3 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 16:52:30 -0400 Subject: [PATCH 05/28] feat(gateway): EIP-712 signed CCIP-Read endpoint for UniversalResolver --- clk-gateway/src/index.ts | 2 + clk-gateway/src/resolver/resolveFromL2.ts | 91 ++++++++++++ clk-gateway/src/resolver/signResolution.ts | 71 ++++++++++ clk-gateway/src/routes/resolve.ts | 147 +++++++++++++++++++ clk-gateway/src/setup.ts | 157 +++++++++++++-------- 5 files changed, 409 insertions(+), 59 deletions(-) create mode 100644 clk-gateway/src/resolver/resolveFromL2.ts create mode 100644 clk-gateway/src/resolver/signResolution.ts create mode 100644 clk-gateway/src/routes/resolve.ts diff --git a/clk-gateway/src/index.ts b/clk-gateway/src/index.ts index ed33bb3f..5b3807be 100644 --- a/clk-gateway/src/index.ts +++ b/clk-gateway/src/index.ts @@ -44,6 +44,7 @@ import { import { getBatchInfo, fetchZyfiSponsored } from "./helpers"; import reservedHashes from "./reservedHashes"; import namesRouter from "./routes/names"; +import resolveRouter from "./routes/resolve"; const app = express(); // CCIP-Read clients (and the ENS app) often POST with Content-Type: text/plain @@ -514,6 +515,7 @@ app.post( ); app.use('/name', namesRouter); +app.use('/resolve', resolveRouter); app.use((err: Error, req: Request, res: Response, next: NextFunction): void => { if (err instanceof HttpError) { diff --git a/clk-gateway/src/resolver/resolveFromL2.ts b/clk-gateway/src/resolver/resolveFromL2.ts new file mode 100644 index 00000000..f9d85ae7 --- /dev/null +++ b/clk-gateway/src/resolver/resolveFromL2.ts @@ -0,0 +1,91 @@ +import { AbiCoder, Contract, dataSlice, ZeroAddress } from "ethers" +import { NAME_SERVICE_INTERFACE } from "../interfaces" + +// ENS resolver selectors +export const ADDR_SELECTOR = "0x3b3b57de" // addr(bytes32) +export const ADDR_MULTICHAIN_SELECTOR = "0xf1cb7e06" // addr(bytes32,uint256) +export const TEXT_SELECTOR = "0x59d1d43c" // text(bytes32,string) +export const ZKSYNC_MAINNET_COIN_TYPE = 2147483972n // (0x80000000 | 0x144) per ENSIP-11 + +/** + * Parse a DNS-encoded ENS name into its segments. + * `example.clave.eth` → { sub: "example", domain: "clave", tld: "eth" } + * Mirrors `_parseDnsDomain` in UniversalResolver.sol. + */ +export function parseDnsDomain( + dnsName: Uint8Array, +): { sub: string; domain: string; tld: string } { + const out = { sub: "", domain: "", tld: "" } + let offset = 0 + const segments: string[] = [] + while (offset < dnsName.length) { + const len = dnsName[offset] + if (len === 0) break + segments.push(Buffer.from(dnsName.slice(offset + 1, offset + 1 + len)).toString("utf8")) + offset += 1 + len + } + if (segments.length === 1) { + out.tld = segments[0] + } else if (segments.length === 2) { + out.domain = segments[0] + out.tld = segments[1] + } else if (segments.length >= 3) { + out.sub = segments[0] + out.domain = segments[1] + out.tld = segments[2] + } + return out +} + +/** + * Resolve an ENS query against the L2 NameService and return ABI-encoded result + * bytes ready to be signed and returned via CCIP-Read. + * + * Throws on unsupported selectors / coin types. + * Returns ABI-encoded zero value (`address(0)` or empty string) if the name is + * expired or not found — the gateway does not leak per-name existence. + */ +export async function resolveFromL2({ + nameServiceContract, + subdomain, + data, +}: { + nameServiceContract: Contract + subdomain: string + data: string // hex-encoded ENS call data +}): Promise { + const selector = dataSlice(data, 0, 4).toLowerCase() + const abi = AbiCoder.defaultAbiCoder() + + if (selector === ADDR_SELECTOR || selector === ADDR_MULTICHAIN_SELECTOR) { + if (selector === ADDR_MULTICHAIN_SELECTOR) { + const [, coinType] = abi.decode(["bytes32", "uint256"], dataSlice(data, 4)) + if (BigInt(coinType) !== ZKSYNC_MAINNET_COIN_TYPE) { + throw new Error(`Unsupported coinType: ${coinType}`) + } + } + + try { + const owner: string = await nameServiceContract.resolve(subdomain) + return abi.encode(["address"], [owner]) + } catch (_e: unknown) { + // Expired or non-existent → return zero address (ENS "no record" convention) + return abi.encode(["address"], [ZeroAddress]) + } + } + + if (selector === TEXT_SELECTOR) { + const [, key] = abi.decode(["bytes32", "string"], dataSlice(data, 4)) + try { + const value: string = await nameServiceContract.getTextRecord(subdomain, key) + return abi.encode(["string"], [value]) + } catch (_e: unknown) { + return abi.encode(["string"], [""]) + } + } + + throw new Error(`Unsupported selector: ${selector}`) +} + +// Re-exported for tests / call sites that need to encode ABI directly. +export { NAME_SERVICE_INTERFACE } diff --git a/clk-gateway/src/resolver/signResolution.ts b/clk-gateway/src/resolver/signResolution.ts new file mode 100644 index 00000000..a8a52af6 --- /dev/null +++ b/clk-gateway/src/resolver/signResolution.ts @@ -0,0 +1,71 @@ +import { AbiCoder, getBytes, TypedDataDomain, Wallet } from "ethers" + +/** + * EIP-712 domain parameters that MUST match the L1 UniversalResolver deployment. + * If these diverge, signatures will not recover to the trusted signer on-chain. + * + * Contract constructor: EIP712("NodleUniversalResolver", "1") + */ +export const RESOLUTION_DOMAIN_NAME = "NodleUniversalResolver" +export const RESOLUTION_DOMAIN_VERSION = "1" + +/** + * EIP-712 types used for the signed CCIP-Read response. + * Contract typehash: keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)") + */ +const RESOLUTION_TYPES = { + Resolution: [ + { name: "name", type: "bytes" }, + { name: "data", type: "bytes" }, + { name: "result", type: "bytes" }, + { name: "expiresAt", type: "uint64" }, + ], +} + +export interface SignResolutionArgs { + signer: Wallet + verifyingContract: string + chainId: number + name: string // hex-encoded DNS-packed ENS name + data: string // hex-encoded original ENS call data + result: string // hex-encoded ABI-encoded resolution result + expiresAt: number // unix seconds +} + +/** + * Sign a CCIP-Read Resolution payload with EIP-712. + * + * Returns the ABI-encoded `(bytes result, uint64 expiresAt, bytes signature)` + * blob that the L1 UniversalResolver's `resolveWithSig` callback expects as its + * first (`_response`) argument. + */ +export async function signResolutionResponse({ + signer, + verifyingContract, + chainId, + name, + data, + result, + expiresAt, +}: SignResolutionArgs): Promise { + const domain: TypedDataDomain = { + name: RESOLUTION_DOMAIN_NAME, + version: RESOLUTION_DOMAIN_VERSION, + chainId, + verifyingContract, + } + + const message = { + name: getBytes(name), + data: getBytes(data), + result: getBytes(result), + expiresAt, + } + + const signature = await signer.signTypedData(domain, RESOLUTION_TYPES, message) + + return AbiCoder.defaultAbiCoder().encode( + ["bytes", "uint64", "bytes"], + [result, expiresAt, signature], + ) +} diff --git a/clk-gateway/src/routes/resolve.ts b/clk-gateway/src/routes/resolve.ts new file mode 100644 index 00000000..4ea7c131 --- /dev/null +++ b/clk-gateway/src/routes/resolve.ts @@ -0,0 +1,147 @@ +import { AbiCoder, dataSlice, hexlify, isHexString } from "ethers" +import { Router } from "express" +import { body, matchedData, validationResult } from "express-validator" +import { + clickNameServiceContract, + clickNSDomain, + l1ChainId, + l1ResolverAddress, + nameServiceContracts, + nodleNameServiceContract, + nodleNSDomain, + resolutionSignatureTtlSeconds, + resolverSigner, +} from "../setup" +import { HttpError } from "../types" +import { asyncHandler } from "../helpers" +import { parseDnsDomain, resolveFromL2 } from "../resolver/resolveFromL2" +import { signResolutionResponse } from "../resolver/signResolution" + +const router = Router() + +/** + * CCIP-Read (ERC-3668) callback endpoint for the signed-gateway UniversalResolver. + * + * The L1 resolver emits `OffchainLookup(this, [url], callData, resolveWithSig, extraData)` + * where `callData = abi.encode(bytes name, bytes data)`. CCIP-Read clients POST + * that blob to this URL. We: + * 1. Decode (name, data). + * 2. Parse the DNS-encoded name and pick the correct L2 NameService contract. + * 3. Dispatch the ENS call against L2 (addr / addr-multichain / text). + * 4. EIP-712 sign Resolution(name, data, result, expiresAt). + * 5. Return { data: abi.encode(result, expiresAt, signature) } so the client + * passes it verbatim to `resolveWithSig` on L1. + */ +router.post( + "/", + [ + body("sender") + .optional() + .isString() + .withMessage("sender must be a string"), + body("data") + .isString() + .custom((value: string) => isHexString(value)) + .withMessage("data must be a hex string"), + ], + asyncHandler(async (req, res) => { + if (!resolverSigner) { + throw new HttpError( + "Gateway signer not configured (RESOLVER_SIGNER_PRIVATE_KEY missing)", + 503, + ) + } + if (!l1ResolverAddress) { + throw new HttpError( + "Gateway L1 resolver address not configured (L1_RESOLVER_ADDR missing)", + 503, + ) + } + + const result = validationResult(req) + if (!result.isEmpty()) { + throw new HttpError( + result + .array() + .map((e: { msg: string }) => e.msg) + .join(", "), + 400, + ) + } + + const { data: ccipCallData } = matchedData(req) + + // callData from the OffchainLookup revert is abi.encode(bytes name, bytes data). + // The ERC-3668 spec permits the client to prepend the resolver selector. + // Strip it if present (the first 4 bytes are 0x ). + const abi = AbiCoder.defaultAbiCoder() + let payload: string = ccipCallData + // Heuristic: try decoding as (bytes,bytes) directly first; if it fails, + // drop 4 bytes and retry. The contract sends raw abi.encode(name,data) with + // no selector prefix, so the direct decode should normally succeed. + let decodedName: string + let decodedData: string + try { + const [n, d] = abi.decode(["bytes", "bytes"], payload) + decodedName = n + decodedData = d + } catch (_err: unknown) { + payload = dataSlice(ccipCallData, 4) + const [n, d] = abi.decode(["bytes", "bytes"], payload) + decodedName = n + decodedData = d + } + + const parsed = parseDnsDomain(Buffer.from(decodedName.slice(2), "hex")) + + // Route to the correct L2 NameService based on the parent domain. + let nameServiceContract + if (parsed.domain === clickNSDomain) { + nameServiceContract = clickNameServiceContract + } else if (parsed.domain === nodleNSDomain) { + nameServiceContract = nodleNameServiceContract + } else { + // Fallback: try to find a matching contract by domain key. + nameServiceContract = nameServiceContracts[parsed.domain] + } + + if (!nameServiceContract) { + throw new HttpError( + `Unknown domain: ${parsed.domain || ""}`, + 404, + ) + } + + if (!parsed.sub) { + // Bare-domain queries are short-circuited on L1 by the resolver and should + // never hit this callback. If one does, surface it clearly. + throw new HttpError( + "Bare-domain resolution is handled on L1 and should not reach the gateway", + 400, + ) + } + + const resultBytes = await resolveFromL2({ + nameServiceContract, + subdomain: parsed.sub, + data: decodedData, + }) + + const expiresAt = + Math.floor(Date.now() / 1000) + resolutionSignatureTtlSeconds + + const signedResponse = await signResolutionResponse({ + signer: resolverSigner, + verifyingContract: l1ResolverAddress, + chainId: l1ChainId, + name: decodedName, + data: decodedData, + result: resultBytes, + expiresAt, + }) + + res.status(200).send({ data: signedResponse }) + }), +) + +export default router diff --git a/clk-gateway/src/setup.ts b/clk-gateway/src/setup.ts index 9a1c6542..93f065f9 100644 --- a/clk-gateway/src/setup.ts +++ b/clk-gateway/src/setup.ts @@ -1,63 +1,86 @@ -import { Contract, JsonRpcProvider as L1Provider } from "ethers" -import admin from "firebase-admin" -import { initializeApp } from "firebase-admin/app" -import { Provider as L2Provider, Wallet } from "zksync-ethers" +import { + Contract, + JsonRpcProvider as L1Provider, + Wallet as EthersWallet, +} from "ethers"; +import admin from "firebase-admin"; +import { initializeApp } from "firebase-admin/app"; +import { Provider as L2Provider, Wallet } from "zksync-ethers"; import { CLICK_RESOLVER_INTERFACE, NAME_SERVICE_INTERFACE, ZKSYNC_DIAMOND_INTERFACE, -} from "./interfaces" -import { ZyfiSponsoredRequest } from "./types" +} from "./interfaces"; +import { ZyfiSponsoredRequest } from "./types"; + +import dotenv from "dotenv"; -import dotenv from "dotenv" +dotenv.config(); -dotenv.config() +const port = process.env.PORT || 8080; +const privateKey = process.env.REGISTRAR_PRIVATE_KEY!; +const l2Provider = new L2Provider(process.env.L2_RPC_URL!); +const l2Wallet = new Wallet(privateKey, l2Provider); +const l1Provider = new L1Provider(process.env.L1_RPC_URL!); +const diamondAddress = process.env.DIAMOND_PROXY_ADDR!; +const indexerUrl = + process.env.INDEXER_URL || "https://indexer.nodleprotocol.io"; -const port = process.env.PORT || 8080 -const privateKey = process.env.REGISTRAR_PRIVATE_KEY! -const l2Provider = new L2Provider(process.env.L2_RPC_URL!) -const l2Wallet = new Wallet(privateKey, l2Provider) -const l1Provider = new L1Provider(process.env.L1_RPC_URL!) -const diamondAddress = process.env.DIAMOND_PROXY_ADDR! -const indexerUrl = process.env.INDEXER_URL || "https://indexer.nodleprotocol.io" +const serviceAccountKey = process.env.SERVICE_ACCOUNT_KEY!; +const serviceAccount = JSON.parse(serviceAccountKey); -const serviceAccountKey = process.env.SERVICE_ACCOUNT_KEY! -const serviceAccount = JSON.parse(serviceAccountKey) initializeApp({ credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), -}) +}); const diamondContract = new Contract( diamondAddress, ZKSYNC_DIAMOND_INTERFACE, - l1Provider -) -const clickResolverAddress = process.env.RESOLVER_ADDR! + l1Provider, +); +const clickResolverAddress = process.env.RESOLVER_ADDR!; const resolverContract = new Contract( clickResolverAddress, CLICK_RESOLVER_INTERFACE, - l1Provider -) -const clickNameServiceAddress = process.env.CLICK_NS_ADDR! + l1Provider, +); +const clickNameServiceAddress = process.env.CLICK_NS_ADDR!; const clickNameServiceContract = new Contract( clickNameServiceAddress, NAME_SERVICE_INTERFACE, - l2Wallet -) -const nodleNameServiceAddress = process.env.NODLE_NS_ADDR! + l2Wallet, +); +const nodleNameServiceAddress = process.env.NODLE_NS_ADDR!; const nodleNameServiceContract = new Contract( nodleNameServiceAddress, NAME_SERVICE_INTERFACE, - l2Wallet -) -const batchQueryOffset = Number(process.env.SAFE_BATCH_QUERY_OFFSET!) + l2Wallet, +); +const batchQueryOffset = Number(process.env.SAFE_BATCH_QUERY_OFFSET!); -const clickNSDomain = process.env.CLICK_NS_DOMAIN! -const nodleNSDomain = process.env.NODLE_NS_DOMAIN! -const parentTLD = process.env.PARENT_TLD! +const clickNSDomain = process.env.CLICK_NS_DOMAIN!; +const nodleNSDomain = process.env.NODLE_NS_DOMAIN!; +const parentTLD = process.env.PARENT_TLD!; const zyfiSponsoredUrl = process.env.ZYFI_BASE_URL ? new URL(process.env.ZYFI_SPONSORED!, process.env.ZYFI_BASE_URL) - : null + : null; + +// --- Signed-gateway UniversalResolver config --- +// The gateway signs EIP-712 Resolution payloads with this key. The address of +// this key must be registered in the L1 UniversalResolver's `isTrustedSigner` +// mapping. Rotation: set a new signer as trusted on-chain, switch env, then +// disable the old one. +const resolverSignerPrivateKey = process.env.RESOLVER_SIGNER_PRIVATE_KEY; +const l1ResolverAddress = process.env.L1_RESOLVER_ADDR; +const l1ChainId = process.env.L1_CHAIN_ID ? Number(process.env.L1_CHAIN_ID) : 1; +const resolutionSignatureTtlSeconds = process.env + .RESOLUTION_SIGNATURE_TTL_SECONDS + ? Number(process.env.RESOLUTION_SIGNATURE_TTL_SECONDS) + : 60; + +const resolverSigner = resolverSignerPrivateKey + ? new EthersWallet(resolverSignerPrivateKey) + : null; const zyfiRequestTemplate: ZyfiSponsoredRequest = { chainId: Number(process.env.L2_CHAIN_ID!), @@ -73,27 +96,27 @@ const zyfiRequestTemplate: ZyfiSponsoredRequest = { }, sponsorshipRatio: 100, replayLimit: 5, -} +}; const nameServiceAddresses = { [clickNSDomain]: clickNameServiceAddress, [nodleNSDomain]: nodleNameServiceAddress, -} +}; const nameServiceContracts = { [clickNSDomain]: clickNameServiceContract, [nodleNSDomain]: nodleNameServiceContract, -} +}; const buildZyfiRegisterRequest = ( owner: string, name: string, - subdomain: keyof typeof nameServiceAddresses + subdomain: keyof typeof nameServiceAddresses, ) => { const encodedRegister = NAME_SERVICE_INTERFACE.encodeFunctionData( "register", - [owner, name] - ) + [owner, name], + ); const zyfiRequest: ZyfiSponsoredRequest = { ...zyfiRequestTemplate, @@ -102,21 +125,21 @@ const buildZyfiRegisterRequest = ( data: encodedRegister, to: nameServiceAddresses[subdomain], }, - } + }; - return zyfiRequest -} + return zyfiRequest; +}; const buildZyfiSetTextRecordRequest = ( name: string, subdomain: keyof typeof nameServiceAddresses, key: string, - value: string + value: string, ) => { const encodedSetTextRecord = NAME_SERVICE_INTERFACE.encodeFunctionData( "setTextRecord", - [name, key, value] - ) + [name, key, value], + ); const zyfiRequest: ZyfiSponsoredRequest = { ...zyfiRequestTemplate, @@ -125,20 +148,36 @@ const buildZyfiSetTextRecordRequest = ( data: encodedSetTextRecord, to: nameServiceAddresses[subdomain], }, - } + }; - return zyfiRequest -} + return zyfiRequest; +}; export { - batchQueryOffset, buildZyfiRegisterRequest, - buildZyfiSetTextRecordRequest, clickNameServiceAddress, - clickNameServiceContract, clickNSDomain, diamondAddress, - diamondContract, indexerUrl, l1Provider, + batchQueryOffset, + buildZyfiRegisterRequest, + buildZyfiSetTextRecordRequest, + clickNameServiceAddress, + clickNameServiceContract, + clickNSDomain, + diamondAddress, + diamondContract, + indexerUrl, + l1Provider, + l1ChainId, + l1ResolverAddress, l2Provider, - l2Wallet, nameServiceAddresses, - nameServiceContracts, nodleNameServiceAddress, - nodleNameServiceContract, nodleNSDomain, - parentTLD, port, resolverContract, zyfiRequestTemplate, zyfiSponsoredUrl -} - + l2Wallet, + nameServiceAddresses, + nameServiceContracts, + nodleNameServiceAddress, + nodleNameServiceContract, + nodleNSDomain, + parentTLD, + port, + resolutionSignatureTtlSeconds, + resolverContract, + resolverSigner, + zyfiRequestTemplate, + zyfiSponsoredUrl, +}; From ae6a1d04c53c84b48ce8c4d1f1aeab3da3a1217d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:31:37 -0400 Subject: [PATCH 06/28] feat(doc): add protocol specification for Signed-Gateway UniversalResolver 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. --- .../doc/signed-resolver-protocol.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 src/nameservice/doc/signed-resolver-protocol.md diff --git a/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md new file mode 100644 index 00000000..efde4372 --- /dev/null +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -0,0 +1,333 @@ +# Signed-Gateway UniversalResolver — Protocol Specification (RFC-style) + +> Describes the on-chain contract, the off-chain gateway, and the EIP-712 message + +**Last updated:** 2026-04-13 + +--- + +## 1. Overview + +`UniversalResolver` is an ENS-compatible L1 resolver that answers name-resolution queries for subdomains registered on Nodle's L2 NameService (zkSync Era). It implements the CCIP-Read pattern (ERC-3668) using a **trusted-gateway signature model**: an off-chain gateway reads the L2 NameService directly and returns an EIP-712 signed response, which the contract verifies against a set of trusted signer addresses. + +This replaces an earlier design that used zkSync storage proofs against L1-committed batch roots. That design broke when zkSync Era migrated settlement to ZK Gateway (~2025-07-30), at which point per-batch state roots stopped being committed to the L1 Diamond proxy and the proof verifier could no longer be used as a trust anchor. + +## 2. Background + +- **ENSIP-10 (wildcard resolution)** lets a single resolver answer lookups for any subdomain of a parent name. +- **ERC-3668 (CCIP-Read)** lets a resolver revert with an `OffchainLookup` error that tells ENS clients where to fetch the answer off-chain and which callback to use to verify it. +- **EIP-712** provides structured, domain-bound signatures that cannot be replayed across contracts or chains. + +The previous design used zkSync storage proofs as the verification step in the CCIP-Read callback. After the ZK Gateway migration, the batch commitment pipeline that fed those proofs was no longer available on L1; the resolver became unusable and stayed broken until this rewrite. + +## 3. Architecture + +``` + ENS client L1 UniversalResolver Gateway L2 NameService + ────────── ──────────────────── ─────── ────────────── + │ resolve(name,data) │ │ │ + │ ─────────────────────────────>│ │ │ + │ │ │ │ + │ revert OffchainLookup( │ │ │ + │ urls, callData, │ │ │ + │ resolveWithSig, │ │ │ + │ extraData) │ │ │ + │ <─────────────────────────────│ │ │ + │ │ │ + │ POST {data: callData} │ │ + │ ──────────────────────────────────────────────────> │ │ + │ │ resolve / getTextRecord│ + │ │ ────────────────────────>│ + │ │ <────────────────────────│ + │ │ EIP-712 sign │ + │ │ │ + │ { data: abi(result,expiresAt,sig) } │ │ + │ <────────────────────────────────────────────────── │ │ + │ │ │ + │ resolveWithSig(response, │ │ │ + │ extraData) │ │ │ + │ ─────────────────────────────>│ │ │ + │ │ verify EIP-712 │ │ + │ │ recover signer │ │ + │ │ check trusted │ │ + │ result bytes │ │ │ + │ <─────────────────────────────│ │ │ +``` + +**Components** + +| Component | Location | Responsibility | +|---|---|---| +| `UniversalResolver` | Ethereum L1 | ENSIP-10 entry point, EIP-712 verification, signer registry, admin surface | +| Gateway (`clk-gateway`) | Off-chain HTTPS service | Reads L2 NameService, signs EIP-712 Resolution payloads | +| L2 NameService (`NameService.sol`) | zkSync Era | Canonical source of subdomain ownership and text records | + +## 4. L1 Contract Specification + +### 4.1 Interfaces + +Implements: + +- `IExtendedResolver` (ENSIP-10): `resolve(bytes name, bytes data) returns (bytes)` +- `IERC165` +- `Ownable` (OpenZeppelin) — admin surface +- `EIP712` (OpenZeppelin) — typed-data signing primitives + +ERC-165 interface IDs reported as supported: + +- `0x01ffc9a7` — `IERC165` +- `0x9061b923` — ENSIP-10 extended resolver (equivalent to `type(IExtendedResolver).interfaceId`; the contract accepts either form as an alias) + +### 4.2 Supported ENS selectors + +| Selector | Signature | Behavior | +|---|---|---| +| `0x3b3b57de` | `addr(bytes32)` | Resolve to owner address on L2 | +| `0xf1cb7e06` | `addr(bytes32,uint256)` | Same, but only accepts `coinType == 2147483972` (zkSync mainnet, per ENSIP-11) | +| `0x59d1d43c` | `text(bytes32,string)` | Resolve text record on L2 | + +Any other selector reverts with `UnsupportedSelector(bytes4)`. Any other coin type reverts with `UnsupportedCoinType(uint256)`. + +### 4.3 Bare-domain behavior + +Queries for the parent domain itself (no subdomain, e.g. `nodl.eth`) are **not** forwarded to the gateway. They return the ENS "no record" convention on L1: + +- `addr` / `addr-multichain` → `abi.encode(address(0))` (32-byte padded, so ENS clients can decode it) +- `text` → `abi.encode("")` + +Rationale: this resolver holds no state about the parent name — it exists only to answer subdomain lookups. If a specific address must be bound to the bare domain, set a different resolver at the ENS registry level for that node. + +### 4.4 Storage + +```solidity +string public url; // CCIP-Read gateway URL +address public immutable registry; // L2 NameService address — METADATA ONLY, not trusted +mapping(address => bool) public isTrustedSigner; +``` + +**Trust anchor note:** `registry` is metadata for off-chain tooling and auditors. It is never consulted on-chain. The only trust anchor for resolution is the EIP-712 signer set. + +### 4.5 Errors + +```solidity +error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); +error UnsupportedCoinType(uint256 coinType); +error UnsupportedSelector(bytes4 selector); +error SignatureExpired(uint64 expiresAt); +error SignatureTtlTooLong(uint64 expiresAt); +error InvalidSigner(address recovered); +``` + +### 4.6 Events + +```solidity +event UrlUpdated(string oldUrl, string newUrl); +event TrustedSignerUpdated(address indexed signer, bool trusted); +``` + +### 4.7 Admin surface + +| Function | Access | Purpose | +|---|---|---| +| `setUrl(string)` | `onlyOwner` | Rotate gateway URL | +| `setTrustedSigner(address, bool)` | `onlyOwner` | Add or revoke a trusted gateway signer | +| `transferOwnership(address)` | `onlyOwner` | Standard OZ handoff | +| `renounceOwnership()` | **blocked** (reverts) | Prevents permanently bricking admin setters | + +At least one trusted signer must remain enabled at all times, or all resolution breaks. + +## 5. EIP-712 Payload + +### 5.1 Domain + +```solidity +EIP712("NodleUniversalResolver", "1") +``` + +Which produces a domain separator over: + +``` +EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) + name = "NodleUniversalResolver" + version = "1" + chainId = + verifyingContract = +``` + +Both the gateway and the contract must agree on these four fields. If the gateway uses the wrong `verifyingContract` or `chainId`, signatures will recover to an untrusted address and `resolveWithSig` will revert with `InvalidSigner`. + +### 5.2 Type + +``` +Resolution(bytes name,bytes data,bytes result,uint64 expiresAt) +``` + +Field semantics: + +| Field | Type | Description | +|---|---|---| +| `name` | `bytes` | DNS-encoded ENS name, as passed to `resolve()` | +| `data` | `bytes` | Original ABI-encoded ENS call (`addr` / `text` / etc.) | +| `result` | `bytes` | ABI-encoded resolution result the gateway is attesting to | +| `expiresAt` | `uint64` | Unix seconds after which this signature must be rejected | + +The typehash is: + +```solidity +keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)") +``` + +Dynamic `bytes` fields are hashed with `keccak256` per EIP-712 before being packed into the struct hash. + +### 5.3 Signature format + +Standard 65-byte `(r, s, v)` concatenation, recovered with OpenZeppelin `ECDSA.recover` (which rejects malleable `s` values). `v` is the last byte. + +### 5.4 Expiry cap + +```solidity +uint64 private constant _MAX_SIGNATURE_TTL = 5 minutes; +``` + +`resolveWithSig` enforces both `block.timestamp <= expiresAt` and `expiresAt <= block.timestamp + _MAX_SIGNATURE_TTL`. This bounds the replay window if a signer key is compromised: even a maliciously long `expiresAt` is rejected on-chain. + +Five minutes was chosen as comfortably above L1 clock skew (a few blocks) while keeping the compromise blast radius small. The gateway currently signs with TTL = 60 seconds, well inside the cap. + +## 6. Gateway Protocol + +### 6.1 Request + +CCIP-Read clients `POST` to the configured gateway URL: + +``` +POST +Content-Type: application/json (or text/plain — see below) + +{ + "sender": "0x", + "data": "0x" +} +``` + +The `data` field is exactly the `callData` from the contract's `OffchainLookup` revert, which is `abi.encode(name, data)` with no selector prefix. Defensive: if a misbehaving client wraps the payload with a 4-byte prefix, the gateway strips it and retries decoding. This is not spec-mandated — ERC-3668 §4 says clients forward `callData` unchanged — it is a tolerance for real-world client quirks. + +**Content-Type handling:** the ENS app (and some CCIP-Read clients) POST with `Content-Type: text/plain` to avoid triggering a CORS preflight. The gateway parses JSON on both `application/json` and `text/plain`. + +### 6.2 Response + +``` +200 OK +Content-Type: application/json + +{ + "data": "0x" +} +``` + +The client passes this blob verbatim to `UniversalResolver.resolveWithSig(response, extraData)` as the `_response` argument. `extraData` is echoed from the original `OffchainLookup` revert and is `abi.encode(name, data)`. + +### 6.3 Gateway dispatch + +The gateway: + +1. Decodes `(name, data)` from the request. +2. Parses the DNS-encoded name into `(sub, domain, tld)`. +3. Routes to the correct L2 NameService contract by parent `domain` (e.g. `nodl` → `NodleNameService`, `clk` → `ClickNameService`). +4. Dispatches on the ENS selector: + - `addr` / `addr-multichain` → `NameService.resolve(subdomain)` → ABI-encode `address` + - `text` → `NameService.getTextRecord(subdomain, key)` → ABI-encode `string` +5. On L2 revert (expired, nonexistent), returns the ENS "no record" encoding rather than leaking per-name existence. +6. Signs `Resolution(name, data, result, now + RESOLUTION_SIGNATURE_TTL_SECONDS)` with the gateway signer key. +7. Returns `abi.encode(result, expiresAt, signature)`. + +Bare-domain queries (no subdomain) are short-circuited on L1 and never reach the gateway. If one does, the gateway responds with HTTP 400. + +## 7. Trust Model + +### 7.1 Trust anchor + +The **only** trust anchor for resolution correctness is the set of addresses marked `isTrustedSigner[addr] == true`. Neither the `registry` field, the gateway URL, nor the L2 contract address is consulted on-chain. + +### 7.2 What a signer compromise allows + +An attacker with a trusted signer private key can, for each signed resolution: + +- Lie about the owner of any subdomain under any parent domain this resolver serves. +- Lie about the value of any text record. +- Cause ENS clients to display wrong addresses / avatars / profile data for **up to `_MAX_SIGNATURE_TTL` (5 minutes) per signature**. + +### 7.3 What a signer compromise does NOT allow + +- Minting, transferring, or expiring subdomains (that's L2 NameService state, untouched). +- Changing the resolver URL, adding new trusted signers, or otherwise escalating (those are `onlyOwner`). +- Replaying an old signature after `expiresAt` (cap enforced on-chain). +- Replaying a signature across a different resolver deployment or chain (EIP-712 domain binds `verifyingContract` and `chainId`). + +### 7.4 Liveness + +The gateway is a **hard dependency** of resolution. If the gateway is down: + +- Subdomain resolution fails (clients see an `OffchainLookup` revert with no reachable responder). +- Bare-domain queries for parent names pointed at this resolver still return their zero/empty "no record" response on L1 without a gateway round-trip. +- L2 state is unaffected; users can still register, transfer, and set text records on L2. + +There is no on-chain fallback and no on-chain cache. HA must be provided operationally (multiple gateway replicas, stable URL behind a load balancer). + +## 8. Rotation Procedures + +### 8.1 Signer rotation (zero downtime) + +1. Generate a new signing key in the secret manager. +2. Owner calls `setTrustedSigner(newSigner, true)`. +3. Deploy gateway with the new key (blue/green or rolling) and verify it produces valid signatures end-to-end. +4. Owner calls `setTrustedSigner(oldSigner, false)`. +5. Delete the old key material. + +At no point should the contract have zero enabled signers. + +### 8.2 Gateway URL rotation + +1. Stand up the new gateway at a new URL. +2. Owner calls `setUrl(newUrl)`. +3. Retire the old gateway after cache TTLs have expired on the client side. + +Note: the old `OffchainLookup` revert for in-flight requests still contains the old URL, so clients with a request already in progress will use the old URL. In practice, CCIP-Read requests are short-lived; a short overlap period is sufficient. + +### 8.3 Ownership handoff + +Standard `transferOwnership(newOwner)`. Production owner should be a multisig. `renounceOwnership` is intentionally blocked. + +### 8.4 Emergency: signer key compromise + +1. From the multisig, call `setTrustedSigner(compromisedSigner, false)` immediately — this is the hard kill. +2. Rotate the gateway to a new signer per §8.1. +3. Audit logs for the suspected window of compromise. +4. Communicate externally if any user-facing impact is suspected. + +The 5-minute max TTL guarantees that even signatures already in flight expire within that window — no outstanding signed response can be used after this deadline. + +## 9. Known Limitations + +- **Gateway is a liveness dependency.** See §7.4. +- **No on-chain cache.** Every resolution call triggers a gateway round-trip. Clients typically cache in ENS.js or at the CDN layer. +- **Single contract may serve multiple parent domains.** One deployment can answer for both `nodl.eth` and `clk.eth` via the gateway's domain routing. This is operationally simple but a signer compromise affects both. Blast-radius isolation requires separate deployments with separate signers. +- **Reverse resolution is not supported.** This resolver does not implement `name(bytes32)` or ENSIP-19 reverse records. +- **No on-chain record of signer identities beyond the address.** Associate human-readable labels in an off-chain rotation log. + +## 10. Non-Goals + +- **Trustless proof of L2 state.** This design is explicitly trust-minimized on the signer set, not trustless. Trustless resolution of zkSync state from L1 requires storage proofs or a ZK light client, neither of which is operationally viable today post-ZK-Gateway. +- **Multi-sig per-resolution responses.** Each response is signed by a single trusted signer. If a future threat model requires k-of-n on individual resolutions, it is a contract upgrade. +- **On-chain fallback if the gateway is down.** There is no L1 mirror of L2 state; none is planned. + +## 11. References + +- [ENSIP-10: Wildcard Resolution](https://docs.ens.domains/ensip/10) +- [ENSIP-11: EVM Compatible Chain Address Resolution](https://docs.ens.domains/ensip/11) +- [ERC-3668: CCIP Read](https://eips.ethereum.org/EIPS/eip-3668) +- [EIP-712: Typed Structured Data Hashing and Signing](https://eips.ethereum.org/EIPS/eip-712) +- [ERC-165: Standard Interface Detection](https://eips.ethereum.org/EIPS/eip-165) +- `src/nameservice/UniversalResolver.sol` +- `test/nameservice/UniversalResolver.t.sol` +- `clk-gateway/src/resolver/signResolution.ts` +- `clk-gateway/src/routes/resolve.ts` From b1fe5ee556091fb41dd38303870d0b79b1a66985 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:41:49 -0400 Subject: [PATCH 07/28] chore(cspell): allow typehash and hexlify as domain terms --- .cspell.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index e8153a66..4c9fc401 100644 --- a/.cspell.json +++ b/.cspell.json @@ -96,6 +96,8 @@ "reconstructable", "Württemberg", "delegatecall", - "sponsorable" + "sponsorable", + "typehash", + "hexlify" ] } From 1243a8cf6ef5fba84e7b3788f0a95b99ab91615c Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:46:20 -0400 Subject: [PATCH 08/28] fix(resolver): encode addr-multichain as ENSIP-11 bytes, not address --- clk-gateway/src/resolver/resolveFromL2.ts | 17 ++++++++++++-- src/nameservice/UniversalResolver.sol | 5 ++++ .../doc/signed-resolver-protocol.md | 7 ++++-- test/nameservice/UniversalResolver.t.sol | 23 +++++++++++++++++-- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/clk-gateway/src/resolver/resolveFromL2.ts b/clk-gateway/src/resolver/resolveFromL2.ts index f9d85ae7..46ed6da2 100644 --- a/clk-gateway/src/resolver/resolveFromL2.ts +++ b/clk-gateway/src/resolver/resolveFromL2.ts @@ -1,4 +1,9 @@ import { AbiCoder, Contract, dataSlice, ZeroAddress } from "ethers" + +// ENSIP-11: addr(bytes32,uint256) returns `bytes`. For an EVM chain the value is +// the raw 20-byte address; "no record" is empty bytes. We never encode this +// branch as `address` — doing so would cause ENS clients to decode the wrong +// type and break multichain resolution. import { NAME_SERVICE_INTERFACE } from "../interfaces" // ENS resolver selectors @@ -58,7 +63,8 @@ export async function resolveFromL2({ const abi = AbiCoder.defaultAbiCoder() if (selector === ADDR_SELECTOR || selector === ADDR_MULTICHAIN_SELECTOR) { - if (selector === ADDR_MULTICHAIN_SELECTOR) { + const isMultichain = selector === ADDR_MULTICHAIN_SELECTOR + if (isMultichain) { const [, coinType] = abi.decode(["bytes32", "uint256"], dataSlice(data, 4)) if (BigInt(coinType) !== ZKSYNC_MAINNET_COIN_TYPE) { throw new Error(`Unsupported coinType: ${coinType}`) @@ -67,9 +73,16 @@ export async function resolveFromL2({ try { const owner: string = await nameServiceContract.resolve(subdomain) + if (isMultichain) { + // ENSIP-11: return raw 20-byte address as `bytes`. + return abi.encode(["bytes"], [owner]) + } return abi.encode(["address"], [owner]) } catch (_e: unknown) { - // Expired or non-existent → return zero address (ENS "no record" convention) + // Expired or non-existent → ENS "no record" convention. + if (isMultichain) { + return abi.encode(["bytes"], ["0x"]) + } return abi.encode(["address"], [ZeroAddress]) } } diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index e8aca885..2ef1a07c 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -166,6 +166,11 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { if (functionSelector == _TEXT_SELECTOR) { return abi.encode(""); } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" + // is an empty bytes value, not a zero address. + return abi.encode(bytes("")); + } return abi.encode(address(0)); } diff --git a/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md index efde4372..de55448c 100644 --- a/src/nameservice/doc/signed-resolver-protocol.md +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -92,8 +92,11 @@ Any other selector reverts with `UnsupportedSelector(bytes4)`. Any other coin ty Queries for the parent domain itself (no subdomain, e.g. `nodl.eth`) are **not** forwarded to the gateway. They return the ENS "no record" convention on L1: -- `addr` / `addr-multichain` → `abi.encode(address(0))` (32-byte padded, so ENS clients can decode it) -- `text` → `abi.encode("")` +- `addr(bytes32)` → `abi.encode(address(0))` (32-byte padded `address`, per ENS `addr` return type) +- `addr(bytes32,uint256)` (multichain) → `abi.encode(bytes(""))` (empty `bytes`, per ENSIP-11 return type) +- `text(bytes32,string)` → `abi.encode("")` + +Encoding the multichain branch as `address` would cause ENS clients to decode the wrong type and break multichain resolution, so the contract and gateway must agree to encode it as `bytes`. Rationale: this resolver holds no state about the parent name — it exists only to answer subdomain lookups. If a specific address must be bound to the bare domain, set a different resolver at the ENS registry level for that node. diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index 844fd1b5..14ba2d4e 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -94,9 +94,11 @@ contract UniversalResolverTest is Test { assertEq(abi.decode(out, (string)), ""); } - function test_Resolve_BareDomain_AddrMultichain_ReturnsZeroAddress() public view { + function test_Resolve_BareDomain_AddrMultichain_ReturnsEmptyBytes() public view { + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" is empty bytes. bytes memory out = resolver.resolve(DNS_BARE, _addrMultichainCallData("clave.eth", ZKSYNC_MAINNET_COIN_TYPE)); - assertEq(abi.decode(out, (address)), address(0)); + bytes memory decoded = abi.decode(out, (bytes)); + assertEq(decoded.length, 0); } function test_Resolve_RevertsOffchainLookup_Addr() public { @@ -140,6 +142,23 @@ contract UniversalResolverTest is Test { assertEq(abi.decode(out, (address)), expectedOwner); } + function test_ResolveWithSig_AddrMultichain_HappyPath() public { + // ENSIP-11 return type is `bytes`: raw 20-byte address for EVM chains. + bytes memory expectedAddr = abi.encodePacked(makeAddr("owner")); + bytes memory data = _addrMultichainCallData("example.clave.eth", ZKSYNC_MAINNET_COIN_TYPE); + bytes memory result = abi.encode(expectedAddr); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + bytes memory out = resolver.resolveWithSig(response, extraData); + bytes memory decoded = abi.decode(out, (bytes)); + assertEq(keccak256(decoded), keccak256(expectedAddr)); + assertEq(decoded.length, 20); + } + function test_ResolveWithSig_Text_HappyPath() public { string memory textValue = "@nodle_network"; bytes memory data = _textCallData("example.clave.eth", "com.twitter"); From 8de71d293fc8fab7014df60197e1f36e70b8b982 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:49:05 -0400 Subject: [PATCH 09/28] fix(resolver): reject short calldata with CallDataTooShort instead of panic --- src/nameservice/UniversalResolver.sol | 7 +++++++ test/nameservice/UniversalResolver.t.sol | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 2ef1a07c..3a505f86 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -47,6 +47,7 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); + error CallDataTooShort(uint256 length); error SignatureExpired(uint64 expiresAt); error SignatureTtlTooLong(uint64 expiresAt); error InvalidSigner(address recovered); @@ -141,6 +142,12 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { function resolve(bytes calldata _name, bytes calldata _data) external view returns (bytes memory) { (string memory sub,,) = _parseDnsDomain(_name); + // Explicit length check so short calldata reverts with a controlled error + // instead of a panic on the slice below. + if (_data.length < 4) { + revert CallDataTooShort(_data.length); + } + // Dispatch only on supported selectors so the gateway is never asked for nonsense. bytes4 functionSelector = bytes4(_data[:4]); if ( diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index 14ba2d4e..544a2d68 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -107,6 +107,12 @@ contract UniversalResolverTest is Test { resolver.resolve(DNS_FULL, data); } + function test_Resolve_ShortCallData_Reverts() public { + bytes memory shortData = hex"deadbe"; // only 3 bytes + vm.expectRevert(abi.encodeWithSelector(UniversalResolver.CallDataTooShort.selector, uint256(3))); + resolver.resolve(DNS_FULL, shortData); + } + function test_Resolve_UnsupportedSelector_Reverts() public { bytes memory bogus = abi.encodeWithSelector(bytes4(0xdeadbeef), bytes32(0)); vm.expectRevert(abi.encodeWithSelector(UniversalResolver.UnsupportedSelector.selector, bytes4(0xdeadbeef))); From 1e654e9b5a4cf6575b9713a34803e0f658fcd39c Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:49:45 -0400 Subject: [PATCH 10/28] refactor(resolver): use OwnershipCannotBeRenounced custom error --- src/nameservice/UniversalResolver.sol | 3 ++- test/nameservice/UniversalResolver.t.sol | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 3a505f86..8ec8ea76 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -48,6 +48,7 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); error CallDataTooShort(uint256 length); + error OwnershipCannotBeRenounced(); error SignatureExpired(uint64 expiresAt); error SignatureTtlTooLong(uint64 expiresAt); error InvalidSigner(address recovered); @@ -95,7 +96,7 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { /// setTrustedSigner, which would permanently break gateway rotation and /// signer revocation. Transfer to a new owner instead. function renounceOwnership() public pure override { - revert("ownership cannot be renounced"); + revert OwnershipCannotBeRenounced(); } /// @notice Parses DNS encoded domain name diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index 544a2d68..e3ef7699 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -273,6 +273,12 @@ contract UniversalResolverTest is Test { resolver.setTrustedSigner(backupSigner, true); } + function test_RenounceOwnership_Reverts() public { + vm.prank(owner); + vm.expectRevert(UniversalResolver.OwnershipCannotBeRenounced.selector); + resolver.renounceOwnership(); + } + // --- url setter --- function test_SetUrl_OnlyOwner() public { From eadbca262896e9e0788d60bd6ef04ab097234d2d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:56:11 -0400 Subject: [PATCH 11/28] fix(gateway): validate RESOLUTION_SIGNATURE_TTL_SECONDS at startup --- clk-gateway/src/setup.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/clk-gateway/src/setup.ts b/clk-gateway/src/setup.ts index 93f065f9..4b619bcb 100644 --- a/clk-gateway/src/setup.ts +++ b/clk-gateway/src/setup.ts @@ -73,10 +73,38 @@ const zyfiSponsoredUrl = process.env.ZYFI_BASE_URL const resolverSignerPrivateKey = process.env.RESOLVER_SIGNER_PRIVATE_KEY; const l1ResolverAddress = process.env.L1_RESOLVER_ADDR; const l1ChainId = process.env.L1_CHAIN_ID ? Number(process.env.L1_CHAIN_ID) : 1; -const resolutionSignatureTtlSeconds = process.env - .RESOLUTION_SIGNATURE_TTL_SECONDS - ? Number(process.env.RESOLUTION_SIGNATURE_TTL_SECONDS) - : 60; + +// Must match the L1 UniversalResolver's _MAX_SIGNATURE_TTL. Signatures with +// expiresAt > now + MAX_RESOLUTION_SIGNATURE_TTL_SECONDS are rejected on-chain, +// so we fail fast at startup instead of emitting signatures that are guaranteed +// to revert. +const MAX_RESOLUTION_SIGNATURE_TTL_SECONDS = 300; + +function parseResolutionSignatureTtl(raw: string | undefined): number { + 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( + `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: must be > 0, got ${parsed}`, + ); + } + if (parsed > MAX_RESOLUTION_SIGNATURE_TTL_SECONDS) { + throw new Error( + `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: must be <= ${MAX_RESOLUTION_SIGNATURE_TTL_SECONDS} ` + + `(L1 resolver _MAX_SIGNATURE_TTL), got ${parsed}`, + ); + } + return parsed; +} + +const resolutionSignatureTtlSeconds = parseResolutionSignatureTtl( + process.env.RESOLUTION_SIGNATURE_TTL_SECONDS, +); const resolverSigner = resolverSignerPrivateKey ? new EthersWallet(resolverSignerPrivateKey) From cffeb5255ae0cf3ddf655e130620b9e4d3759e10 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:57:44 -0400 Subject: [PATCH 12/28] fix(resolver): validate signer inputs and enforce trusted-signer floor --- src/nameservice/UniversalResolver.sol | 34 ++++++++++++++++- test/nameservice/UniversalResolver.t.sol | 47 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 8ec8ea76..000f4083 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -49,6 +49,9 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { error UnsupportedSelector(bytes4 selector); error CallDataTooShort(uint256 length); error OwnershipCannotBeRenounced(); + error ZeroSignerAddress(); + error EmptyUrl(); + error CannotDisableLastTrustedSigner(); error SignatureExpired(uint64 expiresAt); error SignatureTtlTooLong(uint64 expiresAt); error InvalidSigner(address recovered); @@ -65,6 +68,12 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { /// Mapping (rather than a single address) to allow zero-downtime key rotation. mapping(address => bool) public isTrustedSigner; + /// @notice Number of addresses currently marked as trusted signers. + /// @dev Kept in sync with `isTrustedSigner` and used to prevent dropping to zero. + /// If this ever hits zero, all resolution breaks and can only be restored + /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. + uint256 public trustedSignerCount; + event UrlUpdated(string oldUrl, string newUrl); event TrustedSignerUpdated(address indexed signer, bool trusted); @@ -72,10 +81,14 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { Ownable(_owner) EIP712("NodleUniversalResolver", "1") { + if (_initialSigner == address(0)) revert ZeroSignerAddress(); + if (bytes(_url).length == 0) revert EmptyUrl(); + url = _url; registry = _registry; isTrustedSigner[_initialSigner] = true; + trustedSignerCount = 1; emit TrustedSignerUpdated(_initialSigner, true); } @@ -86,9 +99,26 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { } /// @notice Enable or disable a trusted gateway signer. - /// @dev Keep at least one trusted signer enabled at all times or resolution will break. + /// @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 { - isTrustedSigner[signer] = trusted; + if (signer == address(0)) revert ZeroSignerAddress(); + + bool current = isTrustedSigner[signer]; + if (current == trusted) { + // Idempotent: nothing to do, no event, no count change. + return; + } + + if (trusted) { + isTrustedSigner[signer] = true; + trustedSignerCount++; + } else { + if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); + isTrustedSigner[signer] = false; + trustedSignerCount--; + } + emit TrustedSignerUpdated(signer, trusted); } diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index e3ef7699..fdc04f12 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -273,6 +273,53 @@ contract UniversalResolverTest is Test { resolver.setTrustedSigner(backupSigner, true); } + function test_Constructor_RevertsOnZeroSigner() public { + vm.expectRevert(UniversalResolver.ZeroSignerAddress.selector); + new UniversalResolver(GATEWAY_URL, owner, registry, address(0)); + } + + function test_Constructor_RevertsOnEmptyUrl() public { + vm.expectRevert(UniversalResolver.EmptyUrl.selector); + new UniversalResolver("", owner, registry, signer); + } + + function test_SetTrustedSigner_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(UniversalResolver.ZeroSignerAddress.selector); + resolver.setTrustedSigner(address(0), true); + } + + function test_SetTrustedSigner_CannotDisableLastSigner() public { + vm.prank(owner); + vm.expectRevert(UniversalResolver.CannotDisableLastTrustedSigner.selector); + resolver.setTrustedSigner(signer, false); + } + + function test_SetTrustedSigner_IsIdempotent() public { + assertEq(resolver.trustedSignerCount(), 1); + // Re-enabling an already-trusted signer is a no-op (no count change, no emit). + vm.prank(owner); + resolver.setTrustedSigner(signer, true); + assertEq(resolver.trustedSignerCount(), 1); + + // Disabling an already-untrusted signer is also a no-op. + vm.prank(owner); + resolver.setTrustedSigner(backupSigner, false); + assertEq(resolver.trustedSignerCount(), 1); + } + + function test_TrustedSignerCount_TracksChanges() public { + assertEq(resolver.trustedSignerCount(), 1); + + vm.prank(owner); + resolver.setTrustedSigner(backupSigner, true); + assertEq(resolver.trustedSignerCount(), 2); + + vm.prank(owner); + resolver.setTrustedSigner(signer, false); + assertEq(resolver.trustedSignerCount(), 1); + } + function test_RenounceOwnership_Reverts() public { vm.prank(owner); vm.expectRevert(UniversalResolver.OwnershipCannotBeRenounced.selector); From 8b2a8e144f19f4521ff65b608e5a4f0c71b24ca0 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 17:58:12 -0400 Subject: [PATCH 13/28] fix(gateway): validate sender matches configured L1 resolver address --- clk-gateway/src/routes/resolve.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/clk-gateway/src/routes/resolve.ts b/clk-gateway/src/routes/resolve.ts index 4ea7c131..2ac6bc7a 100644 --- a/clk-gateway/src/routes/resolve.ts +++ b/clk-gateway/src/routes/resolve.ts @@ -1,4 +1,4 @@ -import { AbiCoder, dataSlice, hexlify, isHexString } from "ethers" +import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, isHexString } from "ethers" import { Router } from "express" import { body, matchedData, validationResult } from "express-validator" import { @@ -38,7 +38,9 @@ router.post( body("sender") .optional() .isString() - .withMessage("sender must be a string"), + .withMessage("sender must be a string") + .custom((value: string) => isAddress(value)) + .withMessage("sender must be a valid address"), body("data") .isString() .custom((value: string) => isHexString(value)) @@ -69,7 +71,21 @@ router.post( ) } - const { data: ccipCallData } = matchedData(req) + const { data: ccipCallData, sender } = matchedData(req) + + // If the client provided a `sender`, ERC-3668 says it's the address of the + // resolver that emitted OffchainLookup. Reject mismatches to cut down on + // abuse surface — we only sign responses destined for our known L1 resolver. + if (sender) { + const normalizedSender = getAddress(sender as string) + const expected = getAddress(l1ResolverAddress) + if (normalizedSender !== expected) { + throw new HttpError( + `sender ${normalizedSender} does not match configured L1 resolver ${expected}`, + 400, + ) + } + } // callData from the OffchainLookup revert is abi.encode(bytes name, bytes data). // The ERC-3668 spec permits the client to prepend the resolver selector. From f156a16318cb777919029df4bfe09a65f2e7feda Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 18:02:53 -0400 Subject: [PATCH 14/28] test(resolver): cover multichain resolveWithSig empty-record case --- test/nameservice/UniversalResolver.t.sol | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index fdc04f12..f247d372 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -165,6 +165,22 @@ contract UniversalResolverTest is Test { assertEq(decoded.length, 20); } + function test_ResolveWithSig_AddrMultichain_EmptyRecord_HappyPath() public { + // "No record" for addr(bytes32,uint256) is empty bytes per ENSIP-11. + bytes memory expectedAddr = bytes(""); + bytes memory data = _addrMultichainCallData("example.clave.eth", ZKSYNC_MAINNET_COIN_TYPE); + bytes memory result = abi.encode(expectedAddr); + uint64 expiresAt = uint64(block.timestamp + 60); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + bytes memory out = resolver.resolveWithSig(response, extraData); + bytes memory decoded = abi.decode(out, (bytes)); + assertEq(decoded.length, 0); + } + function test_ResolveWithSig_Text_HappyPath() public { string memory textValue = "@nodle_network"; bytes memory data = _textCallData("example.clave.eth", "com.twitter"); From b5ad8a397f20d1159105a0ba9889f050c972425d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 18:11:18 -0400 Subject: [PATCH 15/28] test(resolver): avoid cspell false positive in short-calldata test --- test/nameservice/UniversalResolver.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol index f247d372..77665a87 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/UniversalResolver.t.sol @@ -108,7 +108,7 @@ contract UniversalResolverTest is Test { } function test_Resolve_ShortCallData_Reverts() public { - bytes memory shortData = hex"deadbe"; // only 3 bytes + bytes memory shortData = hex"112233"; // only 3 bytes, below 4-byte selector vm.expectRevert(abi.encodeWithSelector(UniversalResolver.CallDataTooShort.selector, uint256(3))); resolver.resolve(DNS_FULL, shortData); } From ad92b93f8b0ce607c421ee36ee3f4b5d38b16dd2 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 18:19:11 -0400 Subject: [PATCH 16/28] chore(cspell): allow repoint, repointed, cutover --- .cspell.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 4c9fc401..77c5ae9b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -98,6 +98,9 @@ "delegatecall", "sponsorable", "typehash", - "hexlify" + "hexlify", + "repoint", + "repointed", + "cutover" ] } From 6fdbd6189a6f1b183989ffb35c47d31ff3bdcccc Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 10:25:22 -0400 Subject: [PATCH 17/28] Separate signed-gateway resolver into its own contract file Restore the original storage-proof UniversalResolver.sol as a historical artifact. The new EIP-712 signed-gateway model lives in SignedUniversalResolver.sol with its own test and deploy script. --- script/DeployL1Ens.s.sol | 8 +- src/nameservice/SignedUniversalResolver.sol | 271 ++++++++++++++++++ src/nameservice/UniversalResolver.sol | 253 ++++++---------- .../doc/signed-resolver-protocol.md | 4 +- ...er.t.sol => SignedUniversalResolver.t.sol} | 36 +-- 5 files changed, 385 insertions(+), 187 deletions(-) create mode 100644 src/nameservice/SignedUniversalResolver.sol rename test/nameservice/{UniversalResolver.t.sol => SignedUniversalResolver.t.sol} (90%) diff --git a/script/DeployL1Ens.s.sol b/script/DeployL1Ens.s.sol index fe6a512c..9fed39e2 100644 --- a/script/DeployL1Ens.s.sol +++ b/script/DeployL1Ens.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.18; import {Script, console} from "lib/forge-std/src/Script.sol"; -import {UniversalResolver} from "../src/nameservice/UniversalResolver.sol"; +import {SignedUniversalResolver} from "../src/nameservice/SignedUniversalResolver.sol"; interface IResolverSetter { function setResolver(bytes32 node, address resolver) external; @@ -17,15 +17,15 @@ contract DeployL1Ens is Script { address resolverAddress = vm.envOr("NS_RESOLVER_ADDR", address(0)); if (resolverAddress == address(0)) { - console.log("Deploying UniversalResolver (signed-gateway model)..."); - UniversalResolver l1Resolver = new UniversalResolver( + console.log("Deploying SignedUniversalResolver (signed-gateway model)..."); + SignedUniversalResolver l1Resolver = new SignedUniversalResolver( vm.envString("NS_OFFCHAIN_RESOLVER_URL"), vm.envAddress("NS_OWNER_ADDR"), vm.envAddress("NS_ADDR"), vm.envAddress("NS_TRUSTED_SIGNER_ADDR") ); resolverAddress = address(l1Resolver); - console.log("Deployed UniversalResolver at", resolverAddress); + console.log("Deployed SignedUniversalResolver at", resolverAddress); } // Optional: auto-repoint ENS to the new resolver in the same broadcast. diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol new file mode 100644 index 00000000..1ce025a6 --- /dev/null +++ b/src/nameservice/SignedUniversalResolver.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +/** + * @title SignedUniversalResolver + * @notice ENS-compatible L1 resolver for names registered on L2 (zkSync Era). + * @dev Uses the CCIP-Read (ERC-3668) pattern with a trusted-gateway signature + * model. The off-chain gateway queries the L2 NameService directly and + * returns an EIP-712 signed response. This contract recovers the signer + * and accepts the response only if it matches a registered trusted signer. + * + * This replaces the earlier zkSync storage-proof design which depended on + * per-batch state roots being committed to L1 — that path was broken when + * zkSync Era migrated settlement to ZK Gateway (~July 30, 2025). + */ +pragma solidity ^0.8.26; + +import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +/// @title IExtendedResolver +/// @notice ENSIP-10: Wildcard Resolution +interface IExtendedResolver { + function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory); +} + +contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { + bytes4 private constant _EXTENDED_INTERFACE_ID = 0x9061b923; // ENSIP-10 + + bytes4 private constant _ADDR_SELECTOR = 0x3b3b57de; // addr(bytes32) + bytes4 private constant _ADDR_MULTICHAIN_SELECTOR = 0xf1cb7e06; // addr(bytes32,uint) + bytes4 private constant _TEXT_SELECTOR = 0x59d1d43c; // text(bytes32,string) + uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) per ENSIP-11 + + /// @notice EIP-712 typehash for the payload signed by the trusted gateway. + /// @dev Keccak of "Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)" + bytes32 private constant _RESOLUTION_TYPEHASH = + keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); + + /// @notice Hard cap on how far into the future a gateway signature may claim to be valid. + /// @dev Bounds the replay window if a signer key is compromised: even a maliciously + /// long `expiresAt` is clamped to this value on-chain. 5 minutes is comfortably + /// above L1 clock skew while keeping blast radius small. + uint64 private constant _MAX_SIGNATURE_TTL = 5 minutes; + + error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); + error UnsupportedCoinType(uint256 coinType); + error UnsupportedSelector(bytes4 selector); + error CallDataTooShort(uint256 length); + error OwnershipCannotBeRenounced(); + error ZeroSignerAddress(); + error EmptyUrl(); + error CannotDisableLastTrustedSigner(); + error SignatureExpired(uint64 expiresAt); + error SignatureTtlTooLong(uint64 expiresAt); + error InvalidSigner(address recovered); + + /// @notice URL of the CCIP-Read gateway. + string public url; + + /// @notice Address of the L2 NameService contract. Read by the off-chain gateway + /// to choose which L2 contract to query. Not consulted on-chain — the trust + /// anchor for resolution is the EIP-712 signer, not this field. + address public immutable registry; + + /// @notice Trusted signers whose EIP-712 signatures this resolver will accept. + /// Mapping (rather than a single address) to allow zero-downtime key rotation. + mapping(address => bool) public isTrustedSigner; + + /// @notice Number of addresses currently marked as trusted signers. + /// @dev Kept in sync with `isTrustedSigner` and used to prevent dropping to zero. + /// If this ever hits zero, all resolution breaks and can only be restored + /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. + uint256 public trustedSignerCount; + + event UrlUpdated(string oldUrl, string newUrl); + event TrustedSignerUpdated(address indexed signer, bool trusted); + + constructor(string memory _url, address _owner, address _registry, address _initialSigner) + Ownable(_owner) + EIP712("NodleUniversalResolver", "1") + { + if (_initialSigner == address(0)) revert ZeroSignerAddress(); + if (bytes(_url).length == 0) revert EmptyUrl(); + + url = _url; + registry = _registry; + + isTrustedSigner[_initialSigner] = true; + trustedSignerCount = 1; + emit TrustedSignerUpdated(_initialSigner, true); + } + + /// @notice Update the CCIP-Read gateway URL. + function setUrl(string memory _url) external onlyOwner { + emit UrlUpdated(url, _url); + url = _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 { + if (signer == address(0)) revert ZeroSignerAddress(); + + bool current = isTrustedSigner[signer]; + if (current == trusted) { + // Idempotent: nothing to do, no event, no count change. + return; + } + + if (trusted) { + isTrustedSigner[signer] = true; + trustedSignerCount++; + } else { + if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); + isTrustedSigner[signer] = false; + trustedSignerCount--; + } + + emit TrustedSignerUpdated(signer, trusted); + } + + /// @notice Ownership cannot be renounced: losing the owner bricks setUrl and + /// setTrustedSigner, which would permanently break gateway rotation and + /// signer revocation. Transfer to a new owner instead. + function renounceOwnership() public pure override { + revert OwnershipCannotBeRenounced(); + } + + /// @notice Parses DNS encoded domain name + /// @param name DNS encoded domain name + /// @return _sub Subdomain + /// @return _dom Domain + /// @return _top Top level domain + /// @dev e.g example.clave.eth is encoded as b"\x07example\x05clave\x03eth" + /// sub = "example" + /// dom = "clave" + /// top = "eth" + /// @dev It's possible that the name is just a top level domain, in which case sub and dom will be empty + /// @dev It's possible that the name is just a domain, in which case sub will be empty + function _parseDnsDomain(bytes calldata name) + internal + pure + returns (string memory _sub, string memory _dom, string memory _top) + { + uint256 length = name.length; + + uint8 firstLen = uint8(name[0]); + string memory first = string(name[1:1 + firstLen]); + + // If there's only one segment, it's a top level domain + // {top_length}.{top}.{0x00} + if (length == firstLen + 2) return ("", "", first); + + uint8 secondLen = uint8(name[firstLen + 1]); + string memory second = string(name[firstLen + 2:firstLen + 2 + secondLen]); + + // If there's only two segments, it's a domain + // {dom_length}.{dom}.{top_length}.{top}.{0x00} + if (length == firstLen + secondLen + 3) return ("", first, second); + + uint8 thirdLen = uint8(name[firstLen + secondLen + 2]); + string memory third = string(name[firstLen + secondLen + 3:firstLen + secondLen + 3 + thirdLen]); + + return (first, second, third); + } + + /// @notice ENSIP-10 entry point. Triggers CCIP-Read lookup via OffchainLookup revert. + /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") + /// @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,,) = _parseDnsDomain(_name); + + // Explicit length check so short calldata reverts with a controlled error + // instead of a panic on the slice below. + if (_data.length < 4) { + revert CallDataTooShort(_data.length); + } + + // Dispatch only on supported selectors so the gateway is never asked for nonsense. + bytes4 functionSelector = bytes4(_data[:4]); + if ( + functionSelector != _TEXT_SELECTOR && functionSelector != _ADDR_SELECTOR + && functionSelector != _ADDR_MULTICHAIN_SELECTOR + ) { + revert UnsupportedSelector(functionSelector); + } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); + if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { + revert UnsupportedCoinType(coinType); + } + } + + // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 with + // the ENS "no record" convention: zero address for addr queries, empty string + // for text queries. The resolver only exists to answer subdomain lookups — it + // holds no state about the parent name. If a specific address needs to be + // associated with the bare domain, set it via a different resolver at the + // ENS registry level. + if (bytes(sub).length == 0) { + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(""); + } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" + // is an empty bytes value, not a zero address. + return abi.encode(bytes("")); + } + return abi.encode(address(0)); + } + + // Pass the raw (name, data) to the gateway. It will query the L2 NameService, + // build the ABI-encoded result, and return it along with an EIP-712 signature. + bytes memory callData = abi.encode(_name, _data); + bytes memory extraData = abi.encode(_name, _data); + + string[] memory urls = new string[](1); + urls[0] = url; + + revert OffchainLookup(address(this), urls, callData, SignedUniversalResolver.resolveWithSig.selector, extraData); + } + + /// @notice CCIP-Read callback. Verifies the gateway's EIP-712 signature and returns the result. + /// @param _response ABI-encoded (bytes result, uint64 expiresAt, bytes signature) + /// @param _extraData ABI-encoded (bytes name, bytes data) — echoed from the original resolve() call + /// @return The ABI-encoded resolution result, ready to be returned to the ENS caller. + function resolveWithSig(bytes calldata _response, bytes calldata _extraData) + external + view + returns (bytes memory) + { + (bytes memory result, uint64 expiresAt, bytes memory signature) = + abi.decode(_response, (bytes, uint64, bytes)); + (bytes memory name, bytes memory data) = abi.decode(_extraData, (bytes, bytes)); + + if (block.timestamp > expiresAt) { + revert SignatureExpired(expiresAt); + } + if (expiresAt > block.timestamp + _MAX_SIGNATURE_TTL) { + revert SignatureTtlTooLong(expiresAt); + } + + bytes32 structHash = keccak256( + abi.encode(_RESOLUTION_TYPEHASH, keccak256(name), keccak256(data), keccak256(result), expiresAt) + ); + bytes32 digest = _hashTypedDataV4(structHash); + address recovered = ECDSA.recover(digest, signature); + + if (!isTrustedSigner[recovered]) { + revert InvalidSigner(recovered); + } + + return result; + } + + /// @notice Expose the EIP-712 domain separator so off-chain signers can verify their setup. + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165) returns (bool) { + return interfaceId == type(IERC165).interfaceId || interfaceId == _EXTENDED_INTERFACE_ID + || interfaceId == type(IExtendedResolver).interfaceId; + } +} diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 000f4083..91a3c75c 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -1,23 +1,18 @@ // SPDX-License-Identifier: BSD-3-Clause-Clear /** - * @title UniversalResolver - * @notice ENS-compatible L1 resolver for names registered on L2 (zkSync Era). - * @dev Uses the CCIP-Read (ERC-3668) pattern with a trusted-gateway signature - * model. The off-chain gateway queries the L2 NameService directly and - * returns an EIP-712 signed response. This contract recovers the signer - * and accepts the response only if it matches a registered trusted signer. - * - * This replaces the earlier zkSync storage-proof design which depended on - * per-batch state roots being committed to L1 — that path was broken when - * zkSync Era migrated settlement to ZK Gateway (~July 30, 2025). + * @title UniversalResolver for resolving ens subdomains based on names registered on L2 + * @dev This contract is based on ClaveResolver that can be found in this repository: + * https://github.com/getclave/zksync-storage-proofs */ -pragma solidity ^0.8.26; +pragma solidity ^0.8.23; import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { + StorageProof, + StorageProofVerifier +} from "zksync-storage-proofs/packages/zksync-storage-contracts/src/StorageProofVerifier.sol"; /// @title IExtendedResolver /// @notice ENSIP-10: Wildcard Resolution @@ -25,110 +20,53 @@ interface IExtendedResolver { function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory); } -contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { +contract UniversalResolver is IExtendedResolver, IERC165, Ownable { bytes4 private constant _EXTENDED_INTERFACE_ID = 0x9061b923; // ENSIP-10 bytes4 private constant _ADDR_SELECTOR = 0x3b3b57de; // addr(bytes32) bytes4 private constant _ADDR_MULTICHAIN_SELECTOR = 0xf1cb7e06; // addr(bytes32,uint) bytes4 private constant _TEXT_SELECTOR = 0x59d1d43c; // text(bytes32,string) - uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) per ENSIP-11 - - /// @notice EIP-712 typehash for the payload signed by the trusted gateway. - /// @dev Keccak of "Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)" - bytes32 private constant _RESOLUTION_TYPEHASH = - keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); - - /// @notice Hard cap on how far into the future a gateway signature may claim to be valid. - /// @dev Bounds the replay window if a signer key is compromised: even a maliciously - /// long `expiresAt` is clamped to this value on-chain. 5 minutes is comfortably - /// above L1 clock skew while keeping blast radius small. - uint64 private constant _MAX_SIGNATURE_TTL = 5 minutes; + uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) >>> 0 as per ENSIP11 error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); - error CallDataTooShort(uint256 length); - error OwnershipCannotBeRenounced(); - error ZeroSignerAddress(); - error EmptyUrl(); - error CannotDisableLastTrustedSigner(); - error SignatureExpired(uint64 expiresAt); - error SignatureTtlTooLong(uint64 expiresAt); - error InvalidSigner(address recovered); - - /// @notice URL of the CCIP-Read gateway. + error UnsupportedChain(uint256 coinType); + error InvalidStorageProof(); + + /// @notice Storage proof verifier contract + StorageProofVerifier public storageProofVerifier; + + /// @notice URL of the resolver string public url; - /// @notice Address of the L2 NameService contract. Read by the off-chain gateway - /// to choose which L2 contract to query. Not consulted on-chain — the trust - /// anchor for resolution is the EIP-712 signer, not this field. + /// @notice Address of the register contract on L2 address public immutable registry; - /// @notice Trusted signers whose EIP-712 signatures this resolver will accept. - /// Mapping (rather than a single address) to allow zero-downtime key rotation. - mapping(address => bool) public isTrustedSigner; - - /// @notice Number of addresses currently marked as trusted signers. - /// @dev Kept in sync with `isTrustedSigner` and used to prevent dropping to zero. - /// If this ever hits zero, all resolution breaks and can only be restored - /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. - uint256 public trustedSignerCount; + /// @notice Storage slot for the mapping index, specific to registry contract + uint256 public immutable addrsSlot; + uint256 public immutable textRecordsSlot; - event UrlUpdated(string oldUrl, string newUrl); - event TrustedSignerUpdated(address indexed signer, bool trusted); + /// @notice Address of the domain owner + address public domainOwner; - constructor(string memory _url, address _owner, address _registry, address _initialSigner) - Ownable(_owner) - EIP712("NodleUniversalResolver", "1") + constructor(string memory _url, address _domainOwner, address _registry, StorageProofVerifier _storageProofVerifier) + Ownable(_domainOwner) { - if (_initialSigner == address(0)) revert ZeroSignerAddress(); - if (bytes(_url).length == 0) revert EmptyUrl(); - url = _url; + domainOwner = _domainOwner; registry = _registry; + storageProofVerifier = _storageProofVerifier; - isTrustedSigner[_initialSigner] = true; - trustedSignerCount = 1; - emit TrustedSignerUpdated(_initialSigner, true); + // With the current storage layout of ClickNameResolver, the mapping slot of _owners storage is 2 and the mapping slot of _textRecords storage is 9 + addrsSlot = 2; + textRecordsSlot = 9; } - /// @notice Update the CCIP-Read gateway URL. function setUrl(string memory _url) external onlyOwner { - emit UrlUpdated(url, _url); url = _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 { - if (signer == address(0)) revert ZeroSignerAddress(); - - bool current = isTrustedSigner[signer]; - if (current == trusted) { - // Idempotent: nothing to do, no event, no count change. - return; - } - - if (trusted) { - isTrustedSigner[signer] = true; - trustedSignerCount++; - } else { - if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); - isTrustedSigner[signer] = false; - trustedSignerCount--; - } - - emit TrustedSignerUpdated(signer, trusted); - } - - /// @notice Ownership cannot be renounced: losing the owner bricks setUrl and - /// setTrustedSigner, which would permanently break gateway rotation and - /// signer revocation. Transfer to a new owner instead. - function renounceOwnership() public pure override { - revert OwnershipCannotBeRenounced(); - } - /// @notice Parses DNS encoded domain name /// @param name DNS encoded domain name /// @return _sub Subdomain @@ -167,98 +105,87 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { return (first, second, third); } - /// @notice ENSIP-10 entry point. Triggers CCIP-Read lookup via OffchainLookup revert. - /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") - /// @param _data ABI-encoded ENS resolution call (addr / addr-multichain / text) + /// @notice Calculates the key for the given subdomain name in the L2 registry + /// @dev Names are stored in the registry, in a mapping with slot `addrsSlot` + function getStorageKey(string memory subDomain) public view returns (bytes32) { + uint256 tokenId = uint256(keccak256(abi.encodePacked(subDomain))); + return keccak256(abi.encode(tokenId, addrsSlot)); + } + + /// @notice Calculates the storage key for a specific text record + /// @param subDomain The subdomain to get the record for + /// @param key The text record key (e.g. "avatar") + /// @return The final storage key for the text value: mapping (string => string) + function getTextRecordStorageKey(string memory subDomain, string memory key) public view returns (bytes32) { + uint256 tokenId = uint256(keccak256(abi.encodePacked(subDomain))); + bytes32 firstLevel = keccak256(abi.encode(tokenId, textRecordsSlot)); + return keccak256(abi.encodePacked(key, firstLevel)); + } + + /// @notice Resolves a name based on its subdomain part regardless of the given domain and top level + /// @param _name The name to resolve which must be a pack of length prefixed names for subdomain, domain and top. + /// example: b"\x07example\x05clave\x03eth" + /// + /// @param _data The ABI encoded data for the underlying resolution function (Eg, addr(bytes32), text(bytes32,string), etc). function resolve(bytes calldata _name, bytes calldata _data) external view returns (bytes memory) { - (string memory sub,,) = _parseDnsDomain(_name); + (string memory sub,string memory dom,) = _parseDnsDomain(_name); - // Explicit length check so short calldata reverts with a controlled error - // instead of a panic on the slice below. - if (_data.length < 4) { - revert CallDataTooShort(_data.length); + if (bytes(sub).length == 0) { + return abi.encodePacked(domainOwner); } - // Dispatch only on supported selectors so the gateway is never asked for nonsense. bytes4 functionSelector = bytes4(_data[:4]); - if ( - functionSelector != _TEXT_SELECTOR && functionSelector != _ADDR_SELECTOR - && functionSelector != _ADDR_MULTICHAIN_SELECTOR - ) { - revert UnsupportedSelector(functionSelector); - } - if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); - if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { - revert UnsupportedCoinType(coinType); - } - } + bytes32 storageKey; - // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 with - // the ENS "no record" convention: zero address for addr queries, empty string - // for text queries. The resolver only exists to answer subdomain lookups — it - // holds no state about the parent name. If a specific address needs to be - // associated with the bare domain, set it via a different resolver at the - // ENS registry level. - if (bytes(sub).length == 0) { - if (functionSelector == _TEXT_SELECTOR) { - return abi.encode(""); - } + if (functionSelector == _TEXT_SELECTOR) { + (, string memory key) = abi.decode(_data[4:], (bytes32, string)); + storageKey = getTextRecordStorageKey(sub, key); + } else if (functionSelector == _ADDR_SELECTOR || functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + storageKey = getStorageKey(sub); if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" - // is an empty bytes value, not a zero address. - return abi.encode(bytes("")); + (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); + if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { + revert UnsupportedCoinType(coinType); + } } - return abi.encode(address(0)); + } else { + revert UnsupportedSelector(functionSelector); } - // Pass the raw (name, data) to the gateway. It will query the L2 NameService, - // build the ABI-encoded result, and return it along with an EIP-712 signature. - bytes memory callData = abi.encode(_name, _data); - bytes memory extraData = abi.encode(_name, _data); + bytes memory callData = abi.encode(storageKey, dom); + bytes memory extraData = abi.encode(storageKey, functionSelector); string[] memory urls = new string[](1); urls[0] = url; - revert OffchainLookup(address(this), urls, callData, UniversalResolver.resolveWithSig.selector, extraData); + revert OffchainLookup(address(this), urls, callData, UniversalResolver.resolveWithProof.selector, extraData); } - /// @notice CCIP-Read callback. Verifies the gateway's EIP-712 signature and returns the result. - /// @param _response ABI-encoded (bytes result, uint64 expiresAt, bytes signature) - /// @param _extraData ABI-encoded (bytes name, bytes data) — echoed from the original resolve() call - /// @return The ABI-encoded resolution result, ready to be returned to the ENS caller. - function resolveWithSig(bytes calldata _response, bytes calldata _extraData) - external - view - returns (bytes memory) - { - (bytes memory result, uint64 expiresAt, bytes memory signature) = - abi.decode(_response, (bytes, uint64, bytes)); - (bytes memory name, bytes memory data) = abi.decode(_extraData, (bytes, bytes)); + /// @notice Callback used by CCIP read compatible clients to verify and parse the response. + /// @param _response ABI encoded StorageProof struct + /// @return ABI encoded value of the storage key + function resolveWithProof(bytes memory _response, bytes memory _extraData) external view returns (bytes memory) { + (StorageProof memory proof, string memory stringValue) = abi.decode(_response, (StorageProof, string)); + (uint256 storageKey, bytes4 functionSelector) = abi.decode(_extraData, (uint256, bytes4)); - if (block.timestamp > expiresAt) { - revert SignatureExpired(expiresAt); - } - if (expiresAt > block.timestamp + _MAX_SIGNATURE_TTL) { - revert SignatureTtlTooLong(expiresAt); - } + // Replace the account in the proof with the known address of the registry + proof.account = registry; + // Replace the key in the proof with the caller's specified key. It's because the caller may obtain the response/proof from an untrusted offchain source. + proof.key = storageKey; - bytes32 structHash = keccak256( - abi.encode(_RESOLUTION_TYPEHASH, keccak256(name), keccak256(data), keccak256(result), expiresAt) - ); - bytes32 digest = _hashTypedDataV4(structHash); - address recovered = ECDSA.recover(digest, signature); + bool verified = storageProofVerifier.verify(proof); - if (!isTrustedSigner[recovered]) { - revert InvalidSigner(recovered); + if (!verified) { + revert InvalidStorageProof(); } - return result; - } - - /// @notice Expose the EIP-712 domain separator so off-chain signers can verify their setup. - function domainSeparator() external view returns (bytes32) { - return _domainSeparatorV4(); + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(stringValue); + } else if (functionSelector == _ADDR_SELECTOR || functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + return abi.encodePacked(proof.value); + } else { + revert UnsupportedSelector(functionSelector); + } } /** diff --git a/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md index de55448c..6a762ef1 100644 --- a/src/nameservice/doc/signed-resolver-protocol.md +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -330,7 +330,7 @@ The 5-minute max TTL guarantees that even signatures already in flight expire wi - [ERC-3668: CCIP Read](https://eips.ethereum.org/EIPS/eip-3668) - [EIP-712: Typed Structured Data Hashing and Signing](https://eips.ethereum.org/EIPS/eip-712) - [ERC-165: Standard Interface Detection](https://eips.ethereum.org/EIPS/eip-165) -- `src/nameservice/UniversalResolver.sol` -- `test/nameservice/UniversalResolver.t.sol` +- `src/nameservice/SignedUniversalResolver.sol` +- `test/nameservice/SignedUniversalResolver.t.sol` - `clk-gateway/src/resolver/signResolution.ts` - `clk-gateway/src/routes/resolve.ts` diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol similarity index 90% rename from test/nameservice/UniversalResolver.t.sol rename to test/nameservice/SignedUniversalResolver.t.sol index 77665a87..06256a5a 100644 --- a/test/nameservice/UniversalResolver.t.sol +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.26; import {Test} from "forge-std/Test.sol"; -import {UniversalResolver, IExtendedResolver} from "../../src/nameservice/UniversalResolver.sol"; +import {SignedUniversalResolver, IExtendedResolver} from "../../src/nameservice/SignedUniversalResolver.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -contract UniversalResolverTest is Test { - UniversalResolver public resolver; +contract SignedUniversalResolverTest is Test { + SignedUniversalResolver public resolver; address public owner; address public registry; @@ -39,7 +39,7 @@ contract UniversalResolverTest is Test { (signer, signerPk) = makeAddrAndKey("signer"); (backupSigner, backupSignerPk) = makeAddrAndKey("backup"); - resolver = new UniversalResolver(GATEWAY_URL, owner, registry, signer); + resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer); } // --- helpers --- @@ -109,19 +109,19 @@ contract UniversalResolverTest is Test { function test_Resolve_ShortCallData_Reverts() public { bytes memory shortData = hex"112233"; // only 3 bytes, below 4-byte selector - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.CallDataTooShort.selector, uint256(3))); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.CallDataTooShort.selector, uint256(3))); resolver.resolve(DNS_FULL, shortData); } function test_Resolve_UnsupportedSelector_Reverts() public { bytes memory bogus = abi.encodeWithSelector(bytes4(0xdeadbeef), bytes32(0)); - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.UnsupportedSelector.selector, bytes4(0xdeadbeef))); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnsupportedSelector.selector, bytes4(0xdeadbeef))); resolver.resolve(DNS_FULL, bogus); } function test_Resolve_AddrMultichain_WrongCoinType_Reverts() public { bytes memory data = _addrMultichainCallData("example.clave.eth", 60); // ETH mainnet coin type - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.UnsupportedCoinType.selector, uint256(60))); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnsupportedCoinType.selector, uint256(60))); resolver.resolve(DNS_FULL, data); } @@ -207,7 +207,7 @@ contract UniversalResolverTest is Test { bytes memory extraData = abi.encode(DNS_FULL, data); vm.warp(uint256(expiresAt) + 1); - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.SignatureExpired.selector, expiresAt)); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureExpired.selector, expiresAt)); resolver.resolveWithSig(response, extraData); } @@ -221,7 +221,7 @@ contract UniversalResolverTest is Test { bytes memory response = abi.encode(result, expiresAt, sig); bytes memory extraData = abi.encode(DNS_FULL, data); - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.SignatureTtlTooLong.selector, expiresAt)); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureTtlTooLong.selector, expiresAt)); resolver.resolveWithSig(response, extraData); } @@ -235,7 +235,7 @@ contract UniversalResolverTest is Test { bytes memory response = abi.encode(result, expiresAt, sig); bytes memory extraData = abi.encode(DNS_FULL, data); - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.InvalidSigner.selector, backupSigner)); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.InvalidSigner.selector, backupSigner)); resolver.resolveWithSig(response, extraData); } @@ -280,7 +280,7 @@ contract UniversalResolverTest is Test { // Original signer's signatures are now rejected bytes memory oldSig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); bytes memory oldResponse = abi.encode(result, expiresAt, oldSig); - vm.expectRevert(abi.encodeWithSelector(UniversalResolver.InvalidSigner.selector, signer)); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.InvalidSigner.selector, signer)); resolver.resolveWithSig(oldResponse, extraData); } @@ -290,24 +290,24 @@ contract UniversalResolverTest is Test { } function test_Constructor_RevertsOnZeroSigner() public { - vm.expectRevert(UniversalResolver.ZeroSignerAddress.selector); - new UniversalResolver(GATEWAY_URL, owner, registry, address(0)); + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0)); } function test_Constructor_RevertsOnEmptyUrl() public { - vm.expectRevert(UniversalResolver.EmptyUrl.selector); - new UniversalResolver("", owner, registry, signer); + vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); + new SignedUniversalResolver("", owner, registry, signer); } function test_SetTrustedSigner_RevertsOnZeroAddress() public { vm.prank(owner); - vm.expectRevert(UniversalResolver.ZeroSignerAddress.selector); + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); resolver.setTrustedSigner(address(0), true); } function test_SetTrustedSigner_CannotDisableLastSigner() public { vm.prank(owner); - vm.expectRevert(UniversalResolver.CannotDisableLastTrustedSigner.selector); + vm.expectRevert(SignedUniversalResolver.CannotDisableLastTrustedSigner.selector); resolver.setTrustedSigner(signer, false); } @@ -338,7 +338,7 @@ contract UniversalResolverTest is Test { function test_RenounceOwnership_Reverts() public { vm.prank(owner); - vm.expectRevert(UniversalResolver.OwnershipCannotBeRenounced.selector); + vm.expectRevert(SignedUniversalResolver.OwnershipCannotBeRenounced.selector); resolver.renounceOwnership(); } From 85280f37a06640585b268aa26fde4ebea88e70d0 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 10:29:52 -0400 Subject: [PATCH 18/28] Use Ownable2Step instead of Ownable for SignedUniversalResolver --- src/nameservice/SignedUniversalResolver.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index 1ce025a6..faf57516 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -16,6 +16,7 @@ pragma solidity ^0.8.26; import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; @@ -25,7 +26,7 @@ interface IExtendedResolver { function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory); } -contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable, EIP712 { +contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EIP712 { bytes4 private constant _EXTENDED_INTERFACE_ID = 0x9061b923; // ENSIP-10 bytes4 private constant _ADDR_SELECTOR = 0x3b3b57de; // addr(bytes32) From dfaf80a36444313a170a956a191ff46335bae3a0 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 10:30:40 -0400 Subject: [PATCH 19/28] Validate non-empty URL in setUrl and fix CEI ordering --- src/nameservice/SignedUniversalResolver.sol | 4 +++- test/nameservice/SignedUniversalResolver.t.sol | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index faf57516..4f788cbb 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -95,8 +95,10 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI /// @notice Update the CCIP-Read gateway URL. function setUrl(string memory _url) external onlyOwner { - emit UrlUpdated(url, _url); + if (bytes(_url).length == 0) revert EmptyUrl(); + string memory oldUrl = url; url = _url; + emit UrlUpdated(oldUrl, _url); } /// @notice Enable or disable a trusted gateway signer. diff --git a/test/nameservice/SignedUniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol index 06256a5a..d6b9b146 100644 --- a/test/nameservice/SignedUniversalResolver.t.sol +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -353,6 +353,12 @@ contract SignedUniversalResolverTest is Test { assertEq(resolver.url(), "https://new.example"); } + function test_SetUrl_RevertsOnEmptyUrl() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); + resolver.setUrl(""); + } + // --- EIP-712 domain binding --- function test_DomainSeparator_IsNonZero() public view { From aa5203f493539045fd43a9a42ea70e181ecc4082 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 10:32:13 -0400 Subject: [PATCH 20/28] Split setTrustedSigner into trustSigner and revokeSigner --- src/nameservice/SignedUniversalResolver.sol | 47 ++++++++--------- .../nameservice/SignedUniversalResolver.t.sol | 50 ++++++++++++------- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index 4f788cbb..3caf402c 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -76,7 +76,8 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI uint256 public trustedSignerCount; event UrlUpdated(string oldUrl, string newUrl); - event TrustedSignerUpdated(address indexed signer, bool trusted); + event SignerTrusted(address indexed signer); + event SignerRevoked(address indexed signer); constructor(string memory _url, address _owner, address _registry, address _initialSigner) Ownable(_owner) @@ -90,7 +91,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI isTrustedSigner[_initialSigner] = true; trustedSignerCount = 1; - emit TrustedSignerUpdated(_initialSigner, true); + emit SignerTrusted(_initialSigner); } /// @notice Update the CCIP-Read gateway URL. @@ -101,33 +102,33 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI 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 { + /// @notice Register a new trusted gateway signer. + /// @dev Idempotent: re-trusting an already-trusted signer is a no-op. + function trustSigner(address signer) external onlyOwner { if (signer == address(0)) revert ZeroSignerAddress(); + if (isTrustedSigner[signer]) return; - bool current = isTrustedSigner[signer]; - if (current == trusted) { - // Idempotent: nothing to do, no event, no count change. - return; - } + isTrustedSigner[signer] = true; + trustedSignerCount++; + emit SignerTrusted(signer); + } - if (trusted) { - isTrustedSigner[signer] = true; - trustedSignerCount++; - } else { - if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); - isTrustedSigner[signer] = false; - trustedSignerCount--; - } + /// @notice Revoke trust from a gateway signer. + /// @dev Enforces a floor of 1 so the owner cannot brick resolution. + /// Idempotent: revoking an already-untrusted signer is a no-op. + function revokeSigner(address signer) external onlyOwner { + if (signer == address(0)) revert ZeroSignerAddress(); + if (!isTrustedSigner[signer]) return; + if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); - emit TrustedSignerUpdated(signer, trusted); + isTrustedSigner[signer] = false; + trustedSignerCount--; + emit SignerRevoked(signer); } - /// @notice Ownership cannot be renounced: losing the owner bricks setUrl and - /// setTrustedSigner, which would permanently break gateway rotation and - /// signer revocation. Transfer to a new owner instead. + /// @notice Ownership cannot be renounced: losing the owner bricks trustSigner, + /// revokeSigner and setUrl, which would permanently break gateway rotation + /// and signer revocation. Transfer to a new owner instead. function renounceOwnership() public pure override { revert OwnershipCannotBeRenounced(); } diff --git a/test/nameservice/SignedUniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol index d6b9b146..affbbe87 100644 --- a/test/nameservice/SignedUniversalResolver.t.sol +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -31,7 +31,8 @@ contract SignedUniversalResolverTest is Test { // b"\x05clave\x03eth\x00" bare domain bytes private constant DNS_BARE = hex"05636c6176650365746800"; - event TrustedSignerUpdated(address indexed signer, bool trusted); + event SignerTrusted(address indexed signer); + event SignerRevoked(address indexed signer); function setUp() public { owner = makeAddr("owner"); @@ -261,8 +262,8 @@ contract SignedUniversalResolverTest is Test { // Enable backup signer vm.prank(owner); vm.expectEmit(true, false, false, true, address(resolver)); - emit TrustedSignerUpdated(backupSigner, true); - resolver.setTrustedSigner(backupSigner, true); + emit SignerTrusted(backupSigner); + resolver.trustSigner(backupSigner); // Backup signature now works bytes memory data = _addrCallData("example.clave.eth"); @@ -275,7 +276,9 @@ contract SignedUniversalResolverTest is Test { // Revoke original signer vm.prank(owner); - resolver.setTrustedSigner(signer, false); + vm.expectEmit(true, false, false, true, address(resolver)); + emit SignerRevoked(signer); + resolver.revokeSigner(signer); // Original signer's signatures are now rejected bytes memory oldSig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); @@ -284,9 +287,14 @@ contract SignedUniversalResolverTest is Test { resolver.resolveWithSig(oldResponse, extraData); } - function test_SetTrustedSigner_OnlyOwner() public { + function test_TrustSigner_OnlyOwner() public { vm.expectRevert(); - resolver.setTrustedSigner(backupSigner, true); + resolver.trustSigner(backupSigner); + } + + function test_RevokeSigner_OnlyOwner() public { + vm.expectRevert(); + resolver.revokeSigner(signer); } function test_Constructor_RevertsOnZeroSigner() public { @@ -299,28 +307,36 @@ contract SignedUniversalResolverTest is Test { new SignedUniversalResolver("", owner, registry, signer); } - function test_SetTrustedSigner_RevertsOnZeroAddress() public { + function test_TrustSigner_RevertsOnZeroAddress() public { vm.prank(owner); vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); - resolver.setTrustedSigner(address(0), true); + resolver.trustSigner(address(0)); } - function test_SetTrustedSigner_CannotDisableLastSigner() public { + function test_RevokeSigner_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); + resolver.revokeSigner(address(0)); + } + + function test_RevokeSigner_CannotDisableLastSigner() public { vm.prank(owner); vm.expectRevert(SignedUniversalResolver.CannotDisableLastTrustedSigner.selector); - resolver.setTrustedSigner(signer, false); + resolver.revokeSigner(signer); } - function test_SetTrustedSigner_IsIdempotent() public { + function test_TrustSigner_IsIdempotent() public { assertEq(resolver.trustedSignerCount(), 1); - // Re-enabling an already-trusted signer is a no-op (no count change, no emit). + // Re-trusting an already-trusted signer is a no-op. vm.prank(owner); - resolver.setTrustedSigner(signer, true); + resolver.trustSigner(signer); assertEq(resolver.trustedSignerCount(), 1); + } - // Disabling an already-untrusted signer is also a no-op. + function test_RevokeSigner_IsIdempotent() public { + // Revoking an already-untrusted signer is a no-op. vm.prank(owner); - resolver.setTrustedSigner(backupSigner, false); + resolver.revokeSigner(backupSigner); assertEq(resolver.trustedSignerCount(), 1); } @@ -328,11 +344,11 @@ contract SignedUniversalResolverTest is Test { assertEq(resolver.trustedSignerCount(), 1); vm.prank(owner); - resolver.setTrustedSigner(backupSigner, true); + resolver.trustSigner(backupSigner); assertEq(resolver.trustedSignerCount(), 2); vm.prank(owner); - resolver.setTrustedSigner(signer, false); + resolver.revokeSigner(signer); assertEq(resolver.trustedSignerCount(), 1); } From 647df3d4a33180894f33a1e04db615fb89eef023 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 10:58:01 -0400 Subject: [PATCH 21/28] Update bare-domain comment to document all three return types --- src/nameservice/SignedUniversalResolver.sol | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index 3caf402c..8153107e 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -198,12 +198,11 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI } } - // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 with - // the ENS "no record" convention: zero address for addr queries, empty string - // for text queries. The resolver only exists to answer subdomain lookups — it - // holds no state about the parent name. If a specific address needs to be - // associated with the bare domain, set it via a different resolver at the - // ENS registry level. + // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 + // with the ENS "no record" convention: zero address for addr(bytes32), + // empty bytes for addr(bytes32,uint256) per ENSIP-11, empty string for + // text(bytes32,string). The resolver only exists to answer subdomain + // lookups — it holds no state about the parent name. if (bytes(sub).length == 0) { if (functionSelector == _TEXT_SELECTOR) { return abi.encode(""); From 98d7c3f992d6cfd99570f89529f2c73b26cb6076 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 11:11:49 -0400 Subject: [PATCH 22/28] Convert parseResolutionSignatureTtl to IIFE --- clk-gateway/src/setup.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/clk-gateway/src/setup.ts b/clk-gateway/src/setup.ts index 4b619bcb..ad445123 100644 --- a/clk-gateway/src/setup.ts +++ b/clk-gateway/src/setup.ts @@ -80,31 +80,24 @@ const l1ChainId = process.env.L1_CHAIN_ID ? Number(process.env.L1_CHAIN_ID) : 1; // to revert. const MAX_RESOLUTION_SIGNATURE_TTL_SECONDS = 300; -function parseResolutionSignatureTtl(raw: string | undefined): number { +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)) { + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) throw new Error( `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: "${raw}" is not a finite integer`, ); - } - if (parsed <= 0) { + if (parsed <= 0) throw new Error( - `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: must be > 0, got ${parsed}`, + `RESOLUTION_SIGNATURE_TTL_SECONDS must be > 0, got ${parsed}`, ); - } - if (parsed > MAX_RESOLUTION_SIGNATURE_TTL_SECONDS) { + if (parsed > MAX_RESOLUTION_SIGNATURE_TTL_SECONDS) throw new Error( - `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: must be <= ${MAX_RESOLUTION_SIGNATURE_TTL_SECONDS} ` + - `(L1 resolver _MAX_SIGNATURE_TTL), got ${parsed}`, + `RESOLUTION_SIGNATURE_TTL_SECONDS must be <= ${MAX_RESOLUTION_SIGNATURE_TTL_SECONDS}, got ${parsed}`, ); - } return parsed; -} - -const resolutionSignatureTtlSeconds = parseResolutionSignatureTtl( - process.env.RESOLUTION_SIGNATURE_TTL_SECONDS, -); +})(); const resolverSigner = resolverSignerPrivateKey ? new EthersWallet(resolverSignerPrivateKey) From 3f1f05190fa2aefb771074f49b1c10cc6568d558 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 11:17:28 -0400 Subject: [PATCH 23/28] Remove unused hexlify import from resolve route --- clk-gateway/src/routes/resolve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clk-gateway/src/routes/resolve.ts b/clk-gateway/src/routes/resolve.ts index 2ac6bc7a..e960e551 100644 --- a/clk-gateway/src/routes/resolve.ts +++ b/clk-gateway/src/routes/resolve.ts @@ -1,4 +1,4 @@ -import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, isHexString } from "ethers" +import { AbiCoder, dataSlice, getAddress, isAddress, isHexString } from "ethers" import { Router } from "express" import { body, matchedData, validationResult } from "express-validator" import { From 12a3912897f0d8b006185c481fdc749a6bfb95c6 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 11:18:43 -0400 Subject: [PATCH 24/28] Validate TLD against parentTLD in resolve route --- clk-gateway/src/routes/resolve.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/clk-gateway/src/routes/resolve.ts b/clk-gateway/src/routes/resolve.ts index e960e551..23935944 100644 --- a/clk-gateway/src/routes/resolve.ts +++ b/clk-gateway/src/routes/resolve.ts @@ -9,6 +9,7 @@ import { nameServiceContracts, nodleNameServiceContract, nodleNSDomain, + parentTLD, resolutionSignatureTtlSeconds, resolverSigner, } from "../setup" @@ -110,6 +111,13 @@ router.post( const parsed = parseDnsDomain(Buffer.from(decodedName.slice(2), "hex")) + if (parsed.tld && parsed.tld !== parentTLD) { + throw new HttpError( + `Unexpected TLD: "${parsed.tld}" (expected "${parentTLD}")`, + 400, + ) + } + // Route to the correct L2 NameService based on the parent domain. let nameServiceContract if (parsed.domain === clickNSDomain) { From 106c43449c240f9f1dd0b6a2dfd1bd71d8b6692d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 11:47:11 -0400 Subject: [PATCH 25/28] Add on-chain domain allowlist and fuzz tests for TTL boundaries The resolver now validates the domain segment of DNS-encoded names against an allowlist (isAllowedDomain), preventing it from blindly triggering OffchainLookup if the ENS registry mistakenly points an unrelated domain at this contract. Adds addDomain/removeDomain admin functions, constructor initial domain parameter, and fuzz tests covering TTL/expiry arithmetic and DNS parsing. --- script/DeployL1Ens.s.sol | 3 +- src/nameservice/SignedUniversalResolver.sol | 57 ++++- .../doc/signed-resolver-protocol.md | 55 ++++- .../nameservice/SignedUniversalResolver.t.sol | 228 +++++++++++++++++- 4 files changed, 320 insertions(+), 23 deletions(-) diff --git a/script/DeployL1Ens.s.sol b/script/DeployL1Ens.s.sol index 9fed39e2..84b34155 100644 --- a/script/DeployL1Ens.s.sol +++ b/script/DeployL1Ens.s.sol @@ -22,7 +22,8 @@ contract DeployL1Ens is Script { vm.envString("NS_OFFCHAIN_RESOLVER_URL"), vm.envAddress("NS_OWNER_ADDR"), vm.envAddress("NS_ADDR"), - vm.envAddress("NS_TRUSTED_SIGNER_ADDR") + vm.envAddress("NS_TRUSTED_SIGNER_ADDR"), + vm.envString("NS_DOMAIN") ); resolverAddress = address(l1Resolver); console.log("Deployed SignedUniversalResolver at", resolverAddress); diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index 8153107e..36928896 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -52,6 +52,8 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI error OwnershipCannotBeRenounced(); error ZeroSignerAddress(); error EmptyUrl(); + error EmptyDomain(); + error UnknownDomain(string domain); error CannotDisableLastTrustedSigner(); error SignatureExpired(uint64 expiresAt); error SignatureTtlTooLong(uint64 expiresAt); @@ -75,16 +77,27 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. uint256 public trustedSignerCount; + /// @notice Domains this resolver is allowed to serve (e.g. "nodl", "clk"). + /// Keyed by keccak256(bytes(domain)). Prevents the resolver from blindly + /// triggering OffchainLookup for domains it was never intended to handle. + mapping(bytes32 => bool) public isAllowedDomain; + event UrlUpdated(string oldUrl, string newUrl); event SignerTrusted(address indexed signer); event SignerRevoked(address indexed signer); - - constructor(string memory _url, address _owner, address _registry, address _initialSigner) - Ownable(_owner) - EIP712("NodleUniversalResolver", "1") - { + event DomainAdded(string domain); + event DomainRemoved(string domain); + + constructor( + string memory _url, + address _owner, + address _registry, + address _initialSigner, + string memory _initialDomain + ) Ownable(_owner) EIP712("NodleUniversalResolver", "1") { if (_initialSigner == address(0)) revert ZeroSignerAddress(); if (bytes(_url).length == 0) revert EmptyUrl(); + if (bytes(_initialDomain).length == 0) revert EmptyDomain(); url = _url; registry = _registry; @@ -92,6 +105,9 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI isTrustedSigner[_initialSigner] = true; trustedSignerCount = 1; emit SignerTrusted(_initialSigner); + + isAllowedDomain[keccak256(bytes(_initialDomain))] = true; + emit DomainAdded(_initialDomain); } /// @notice Update the CCIP-Read gateway URL. @@ -126,6 +142,28 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI emit SignerRevoked(signer); } + /// @notice Allow a new domain to be resolved through this contract. + /// @dev Idempotent: re-adding an already-allowed domain is a no-op. + function addDomain(string memory domain) external onlyOwner { + if (bytes(domain).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(domain)); + if (isAllowedDomain[key]) return; + + isAllowedDomain[key] = true; + emit DomainAdded(domain); + } + + /// @notice Remove a domain from the allowlist. + /// @dev Idempotent: removing an already-disallowed domain is a no-op. + function removeDomain(string memory domain) external onlyOwner { + if (bytes(domain).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(domain)); + if (!isAllowedDomain[key]) return; + + isAllowedDomain[key] = false; + emit DomainRemoved(domain); + } + /// @notice Ownership cannot be renounced: losing the owner bricks trustSigner, /// revokeSigner and setUrl, which would permanently break gateway rotation /// and signer revocation. Transfer to a new owner instead. @@ -175,7 +213,14 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") /// @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,,) = _parseDnsDomain(_name); + (string memory sub, string memory dom,) = _parseDnsDomain(_name); + + // Reject domains this resolver was never configured to serve. This prevents + // the resolver from blindly triggering OffchainLookup if the ENS registry + // mistakenly points an unrelated domain at this contract. + if (bytes(dom).length > 0 && !isAllowedDomain[keccak256(bytes(dom))]) { + revert UnknownDomain(dom); + } // Explicit length check so short calldata reverts with a controlled error // instead of a panic on the slice below. diff --git a/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md index 6a762ef1..b3776039 100644 --- a/src/nameservice/doc/signed-resolver-protocol.md +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -2,7 +2,7 @@ > Describes the on-chain contract, the off-chain gateway, and the EIP-712 message -**Last updated:** 2026-04-13 +**Last updated:** 2026-04-21 --- @@ -106,16 +106,26 @@ Rationale: this resolver holds no state about the parent name — it exists only string public url; // CCIP-Read gateway URL address public immutable registry; // L2 NameService address — METADATA ONLY, not trusted mapping(address => bool) public isTrustedSigner; +mapping(bytes32 => bool) public isAllowedDomain; // keccak256(bytes(domain)) → allowed ``` **Trust anchor note:** `registry` is metadata for off-chain tooling and auditors. It is never consulted on-chain. The only trust anchor for resolution is the EIP-712 signer set. +**Domain allowlist:** `isAllowedDomain` gates which parent domains (e.g. "nodl", "clk") the resolver will serve. The contract parses the DNS-encoded name and rejects unknown domains with `UnknownDomain(string)` before triggering `OffchainLookup`. This prevents the resolver from blindly forwarding requests if the ENS registry mistakenly points an unrelated domain at this contract. The allowlist must be kept in sync with the gateway's configured domain→contract mapping. + ### 4.5 Errors ```solidity error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); error UnsupportedCoinType(uint256 coinType); error UnsupportedSelector(bytes4 selector); +error CallDataTooShort(uint256 length); +error EmptyUrl(); +error EmptyDomain(); +error UnknownDomain(string domain); +error OwnershipCannotBeRenounced(); +error ZeroSignerAddress(); +error CannotDisableLastTrustedSigner(); error SignatureExpired(uint64 expiresAt); error SignatureTtlTooLong(uint64 expiresAt); error InvalidSigner(address recovered); @@ -125,7 +135,10 @@ error InvalidSigner(address recovered); ```solidity event UrlUpdated(string oldUrl, string newUrl); -event TrustedSignerUpdated(address indexed signer, bool trusted); +event SignerTrusted(address indexed signer); +event SignerRevoked(address indexed signer); +event DomainAdded(string domain); +event DomainRemoved(string domain); ``` ### 4.7 Admin surface @@ -133,11 +146,15 @@ event TrustedSignerUpdated(address indexed signer, bool trusted); | Function | Access | Purpose | |---|---|---| | `setUrl(string)` | `onlyOwner` | Rotate gateway URL | -| `setTrustedSigner(address, bool)` | `onlyOwner` | Add or revoke a trusted gateway signer | -| `transferOwnership(address)` | `onlyOwner` | Standard OZ handoff | +| `trustSigner(address)` | `onlyOwner` | Register a trusted gateway signer (idempotent) | +| `revokeSigner(address)` | `onlyOwner` | Revoke a trusted gateway signer (idempotent, floor of 1) | +| `addDomain(string)` | `onlyOwner` | Allow a domain to be resolved (idempotent) | +| `removeDomain(string)` | `onlyOwner` | Remove a domain from the allowlist (idempotent) | +| `transferOwnership(address)` | `onlyOwner` | Standard OZ `Ownable2Step` handoff | +| `acceptOwnership()` | pending owner | Complete the two-step ownership transfer | | `renounceOwnership()` | **blocked** (reverts) | Prevents permanently bricking admin setters | -At least one trusted signer must remain enabled at all times, or all resolution breaks. +At least one trusted signer must remain enabled at all times, or all resolution breaks. The domain allowlist has no such floor — removing all domains effectively disables the resolver without bricking admin functions. ## 5. EIP-712 Payload @@ -281,12 +298,12 @@ There is no on-chain fallback and no on-chain cache. HA must be provided operati ### 8.1 Signer rotation (zero downtime) 1. Generate a new signing key in the secret manager. -2. Owner calls `setTrustedSigner(newSigner, true)`. +2. Owner calls `trustSigner(newSigner)`. 3. Deploy gateway with the new key (blue/green or rolling) and verify it produces valid signatures end-to-end. -4. Owner calls `setTrustedSigner(oldSigner, false)`. +4. Owner calls `revokeSigner(oldSigner)`. 5. Delete the old key material. -At no point should the contract have zero enabled signers. +At no point should the contract have zero enabled signers (`revokeSigner` enforces a floor of 1). ### 8.2 Gateway URL rotation @@ -296,13 +313,24 @@ At no point should the contract have zero enabled signers. Note: the old `OffchainLookup` revert for in-flight requests still contains the old URL, so clients with a request already in progress will use the old URL. In practice, CCIP-Read requests are short-lived; a short overlap period is sufficient. -### 8.3 Ownership handoff +### 8.3 Domain management + +Adding a new parent domain (e.g. expanding from `nodl.eth` to also serve `clk.eth`): + +1. Deploy the L2 NameService contract for the new domain (if not already deployed). +2. Configure the gateway with the new domain → L2 contract mapping. +3. Owner calls `addDomain("clk")` on the L1 resolver. +4. Point the ENS node for the new domain at this resolver. + +Removing a domain: owner calls `removeDomain("clk")`. Resolution for that domain stops immediately on-chain. Update the gateway config to remove the route. + +### 8.4 Ownership handoff -Standard `transferOwnership(newOwner)`. Production owner should be a multisig. `renounceOwnership` is intentionally blocked. +Standard two-step `transferOwnership(newOwner)` + `acceptOwnership()` (Ownable2Step). Production owner should be a multisig. `renounceOwnership` is intentionally blocked. -### 8.4 Emergency: signer key compromise +### 8.5 Emergency: signer key compromise -1. From the multisig, call `setTrustedSigner(compromisedSigner, false)` immediately — this is the hard kill. +1. From the multisig, call `revokeSigner(compromisedSigner)` immediately — this is the hard kill. 2. Rotate the gateway to a new signer per §8.1. 3. Audit logs for the suspected window of compromise. 4. Communicate externally if any user-facing impact is suspected. @@ -313,7 +341,8 @@ The 5-minute max TTL guarantees that even signatures already in flight expire wi - **Gateway is a liveness dependency.** See §7.4. - **No on-chain cache.** Every resolution call triggers a gateway round-trip. Clients typically cache in ENS.js or at the CDN layer. -- **Single contract may serve multiple parent domains.** One deployment can answer for both `nodl.eth` and `clk.eth` via the gateway's domain routing. This is operationally simple but a signer compromise affects both. Blast-radius isolation requires separate deployments with separate signers. +- **Single contract may serve multiple parent domains.** One deployment can answer for both `nodl.eth` and `clk.eth` via the on-chain domain allowlist and the gateway's domain routing. This is operationally simple but a signer compromise affects both. Blast-radius isolation requires separate deployments with separate signers. +- **Domain allowlist must be kept in sync with the gateway.** The contract's `isAllowedDomain` mapping and the gateway's configured domain→contract mapping are independent. Adding a domain to one but not the other will cause either on-chain rejection (contract missing) or gateway 404 (gateway missing). There is no automated sync or startup health check. - **Reverse resolution is not supported.** This resolver does not implement `name(bytes32)` or ENSIP-19 reverse records. - **No on-chain record of signer identities beyond the address.** Associate human-readable labels in an off-chain rotation log. diff --git a/test/nameservice/SignedUniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol index affbbe87..294d88db 100644 --- a/test/nameservice/SignedUniversalResolver.t.sol +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -34,13 +34,15 @@ contract SignedUniversalResolverTest is Test { event SignerTrusted(address indexed signer); event SignerRevoked(address indexed signer); + string public constant INITIAL_DOMAIN = "clave"; + function setUp() public { owner = makeAddr("owner"); registry = makeAddr("registry"); (signer, signerPk) = makeAddrAndKey("signer"); (backupSigner, backupSignerPk) = makeAddrAndKey("backup"); - resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer); + resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, INITIAL_DOMAIN); } // --- helpers --- @@ -299,12 +301,21 @@ contract SignedUniversalResolverTest is Test { function test_Constructor_RevertsOnZeroSigner() public { vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); - new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0)); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0), INITIAL_DOMAIN); } function test_Constructor_RevertsOnEmptyUrl() public { vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); - new SignedUniversalResolver("", owner, registry, signer); + new SignedUniversalResolver("", owner, registry, signer, INITIAL_DOMAIN); + } + + function test_Constructor_RevertsOnEmptyDomain() public { + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, ""); + } + + function test_Constructor_SetsInitialDomain() public view { + assertTrue(resolver.isAllowedDomain(keccak256(bytes(INITIAL_DOMAIN)))); } function test_TrustSigner_RevertsOnZeroAddress() public { @@ -417,10 +428,221 @@ contract SignedUniversalResolverTest is Test { assertFalse(resolver.supportsInterface(0xdeadbeef)); } + // --- domain allowlist --- + + function test_AddDomain_OnlyOwner() public { + vm.expectRevert(); + resolver.addDomain("nodl"); + } + + function test_AddDomain_Success() public { + vm.prank(owner); + resolver.addDomain("nodl"); + assertTrue(resolver.isAllowedDomain(keccak256(bytes("nodl")))); + } + + function test_AddDomain_IsIdempotent() public { + vm.prank(owner); + resolver.addDomain("nodl"); + // Second add is a no-op + vm.prank(owner); + resolver.addDomain("nodl"); + assertTrue(resolver.isAllowedDomain(keccak256(bytes("nodl")))); + } + + function test_AddDomain_RevertsOnEmptyDomain() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + resolver.addDomain(""); + } + + function test_RemoveDomain_OnlyOwner() public { + vm.expectRevert(); + resolver.removeDomain(INITIAL_DOMAIN); + } + + function test_RemoveDomain_Success() public { + vm.prank(owner); + resolver.removeDomain(INITIAL_DOMAIN); + assertFalse(resolver.isAllowedDomain(keccak256(bytes(INITIAL_DOMAIN)))); + } + + function test_RemoveDomain_IsIdempotent() public { + // Removing an already-disallowed domain is a no-op + vm.prank(owner); + resolver.removeDomain("nonexistent"); + } + + function test_RemoveDomain_RevertsOnEmptyDomain() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + resolver.removeDomain(""); + } + + function test_Resolve_UnknownDomain_Reverts() public { + // DNS-encoded "example.unknown.eth" — domain is "unknown", not in allowlist + bytes memory dnsUnknown = hex"076578616d706c6507756e6b6e6f776e0365746800"; + bytes memory data = _addrCallData("example.unknown.eth"); + + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnknownDomain.selector, "unknown")); + resolver.resolve(dnsUnknown, data); + } + + function test_Resolve_AllowedDomain_TriggersOffchainLookup() public { + // DNS_FULL uses "clave" domain which is in the allowlist + bytes memory data = _addrCallData("example.clave.eth"); + vm.expectRevert(); // OffchainLookup + resolver.resolve(DNS_FULL, data); + } + + function test_Resolve_NewlyAddedDomain_Works() public { + // Add "nodl" domain + vm.prank(owner); + resolver.addDomain("nodl"); + + // DNS-encoded "example.nodl.eth" + bytes memory dnsNodl = hex"076578616d706c65046e6f646c0365746800"; + bytes memory data = _addrCallData("example.nodl.eth"); + vm.expectRevert(); // OffchainLookup + resolver.resolve(dnsNodl, data); + } + + function test_Resolve_RemovedDomain_Reverts() public { + // Remove the initial "clave" domain + vm.prank(owner); + resolver.removeDomain(INITIAL_DOMAIN); + + bytes memory data = _addrCallData("example.clave.eth"); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnknownDomain.selector, "clave")); + resolver.resolve(DNS_FULL, data); + } + // --- sanity: initial signer was set --- function test_InitialSignerIsTrusted() public view { assertTrue(resolver.isTrustedSigner(signer)); assertFalse(resolver.isTrustedSigner(backupSigner)); } + + // --- fuzz: TTL / expiry boundaries --- + + /// @notice Fuzz expiresAt across the full uint64 range. + /// Partitions: expired (past), valid window, TTL too long. + function testFuzz_ResolveWithSig_ExpiresAt(uint64 expiresAt) public { + // Fix block.timestamp to a known value so the three zones are deterministic. + uint256 ts = 1_700_000_000; + vm.warp(ts); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + if (expiresAt < ts) { + // Zone 1: expired — block.timestamp > expiresAt + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureExpired.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } else if (expiresAt > ts + 5 minutes) { + // Zone 3: TTL too long — expiresAt > block.timestamp + _MAX_SIGNATURE_TTL + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureTtlTooLong.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } else { + // Zone 2: valid window — ts <= expiresAt <= ts + 300 + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + } + + /// @notice Fuzz block.timestamp while keeping expiresAt fixed at a known valid offset. + /// Ensures the expiry check works regardless of when the chain is. + function testFuzz_ResolveWithSig_Timestamp(uint64 timestamp) public { + // Bound timestamp to avoid overflow when adding 5 minutes + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp + 60); // 60s into valid window + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // Should always succeed: expiresAt = now + 60 is within [now, now + 300] + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz the exact boundary: expiresAt == block.timestamp (not expired, edge). + function testFuzz_ResolveWithSig_ExpiresAtExactlyNow(uint64 timestamp) public { + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp); // exactly now + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // block.timestamp > expiresAt is false when equal → should succeed + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz the upper boundary: expiresAt == block.timestamp + 5 minutes (max allowed). + function testFuzz_ResolveWithSig_ExpiresAtMaxTtl(uint64 timestamp) public { + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp + 5 minutes); // exactly at cap + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // expiresAt == block.timestamp + _MAX_SIGNATURE_TTL → not strictly greater → should succeed + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz DNS-encoded names with variable-length segments. + /// Verifies resolve() doesn't panic on arbitrary well-formed DNS names. + function testFuzz_Resolve_DnsName(uint8 subLen, uint8 domLen, uint8 tldLen) public { + // Bound lengths to [1,63] per DNS label rules + subLen = uint8(bound(subLen, 1, 63)); + domLen = uint8(bound(domLen, 1, 63)); + tldLen = uint8(bound(tldLen, 1, 63)); + + // Build DNS-encoded name: <0x00> + bytes memory name = new bytes(uint256(subLen) + uint256(domLen) + uint256(tldLen) + 4); + name[0] = bytes1(subLen); + // Fill sub with 'a' + for (uint256 i = 0; i < subLen; i++) { + name[1 + i] = "a"; + } + name[1 + subLen] = bytes1(domLen); + // Fill dom with 'b' + for (uint256 i = 0; i < domLen; i++) { + name[2 + subLen + i] = "b"; + } + name[2 + subLen + domLen] = bytes1(tldLen); + // Fill tld with 'c' + for (uint256 i = 0; i < tldLen; i++) { + name[3 + subLen + domLen + i] = "c"; + } + name[name.length - 1] = 0x00; + + bytes memory data = _addrCallData("test"); + + // Has a subdomain → should revert (UnknownDomain for non-allowlisted + // domains, OffchainLookup for allowlisted ones). Either way, no panic. + vm.expectRevert(); + resolver.resolve(name, data); + } } From 7e625e41130bf27a22b041e5ce96762adf1f92e4 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 12:02:28 -0400 Subject: [PATCH 26/28] Accept multiple initial domains in constructor Constructor now takes string[] instead of a single string, so both "nodl" and "clk" can be allowlisted at deploy time. Deploy script reads NS_DOMAINS (comma-separated) with fallback to NS_DOMAIN. --- script/DeployL1Ens.s.sol | 11 +++++- src/nameservice/SignedUniversalResolver.sol | 15 +++++--- .../nameservice/SignedUniversalResolver.t.sol | 35 ++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/script/DeployL1Ens.s.sol b/script/DeployL1Ens.s.sol index 84b34155..f0ad714f 100644 --- a/script/DeployL1Ens.s.sol +++ b/script/DeployL1Ens.s.sol @@ -18,12 +18,21 @@ contract DeployL1Ens is Script { if (resolverAddress == address(0)) { console.log("Deploying SignedUniversalResolver (signed-gateway model)..."); + + // NS_DOMAINS is a comma-separated list of domains to allowlist, e.g. "nodl,clk". + string[] memory domains = vm.envOr("NS_DOMAINS", ",", new string[](0)); + if (domains.length == 0) { + // Fallback: single domain from NS_DOMAIN for backward compat. + domains = new string[](1); + domains[0] = vm.envString("NS_DOMAIN"); + } + SignedUniversalResolver l1Resolver = new SignedUniversalResolver( vm.envString("NS_OFFCHAIN_RESOLVER_URL"), vm.envAddress("NS_OWNER_ADDR"), vm.envAddress("NS_ADDR"), vm.envAddress("NS_TRUSTED_SIGNER_ADDR"), - vm.envString("NS_DOMAIN") + domains ); resolverAddress = address(l1Resolver); console.log("Deployed SignedUniversalResolver at", resolverAddress); diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index 36928896..c31f859e 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -53,6 +53,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI error ZeroSignerAddress(); error EmptyUrl(); error EmptyDomain(); + error NoInitialDomains(); error UnknownDomain(string domain); error CannotDisableLastTrustedSigner(); error SignatureExpired(uint64 expiresAt); @@ -93,11 +94,11 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI address _owner, address _registry, address _initialSigner, - string memory _initialDomain + string[] memory _initialDomains ) Ownable(_owner) EIP712("NodleUniversalResolver", "1") { if (_initialSigner == address(0)) revert ZeroSignerAddress(); if (bytes(_url).length == 0) revert EmptyUrl(); - if (bytes(_initialDomain).length == 0) revert EmptyDomain(); + if (_initialDomains.length == 0) revert NoInitialDomains(); url = _url; registry = _registry; @@ -106,8 +107,14 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI trustedSignerCount = 1; emit SignerTrusted(_initialSigner); - isAllowedDomain[keccak256(bytes(_initialDomain))] = true; - emit DomainAdded(_initialDomain); + for (uint256 i = 0; i < _initialDomains.length; i++) { + if (bytes(_initialDomains[i]).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(_initialDomains[i])); + if (!isAllowedDomain[key]) { + isAllowedDomain[key] = true; + emit DomainAdded(_initialDomains[i]); + } + } } /// @notice Update the CCIP-Read gateway URL. diff --git a/test/nameservice/SignedUniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol index 294d88db..d3e06dbc 100644 --- a/test/nameservice/SignedUniversalResolver.t.sol +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -36,13 +36,19 @@ contract SignedUniversalResolverTest is Test { string public constant INITIAL_DOMAIN = "clave"; + function _initialDomains() internal pure returns (string[] memory) { + string[] memory domains = new string[](1); + domains[0] = INITIAL_DOMAIN; + return domains; + } + function setUp() public { owner = makeAddr("owner"); registry = makeAddr("registry"); (signer, signerPk) = makeAddrAndKey("signer"); (backupSigner, backupSignerPk) = makeAddrAndKey("backup"); - resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, INITIAL_DOMAIN); + resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, _initialDomains()); } // --- helpers --- @@ -301,23 +307,42 @@ contract SignedUniversalResolverTest is Test { function test_Constructor_RevertsOnZeroSigner() public { vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); - new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0), INITIAL_DOMAIN); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0), _initialDomains()); } function test_Constructor_RevertsOnEmptyUrl() public { vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); - new SignedUniversalResolver("", owner, registry, signer, INITIAL_DOMAIN); + new SignedUniversalResolver("", owner, registry, signer, _initialDomains()); + } + + function test_Constructor_RevertsOnNoInitialDomains() public { + string[] memory empty = new string[](0); + vm.expectRevert(SignedUniversalResolver.NoInitialDomains.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, empty); } - function test_Constructor_RevertsOnEmptyDomain() public { + function test_Constructor_RevertsOnEmptyDomainInArray() public { + string[] memory domains = new string[](2); + domains[0] = "nodl"; + domains[1] = ""; vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); - new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, ""); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, domains); } function test_Constructor_SetsInitialDomain() public view { assertTrue(resolver.isAllowedDomain(keccak256(bytes(INITIAL_DOMAIN)))); } + function test_Constructor_SetsMultipleInitialDomains() public { + string[] memory domains = new string[](2); + domains[0] = "nodl"; + domains[1] = "clk"; + SignedUniversalResolver multi = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, domains); + assertTrue(multi.isAllowedDomain(keccak256(bytes("nodl")))); + assertTrue(multi.isAllowedDomain(keccak256(bytes("clk")))); + assertFalse(multi.isAllowedDomain(keccak256(bytes("other")))); + } + function test_TrustSigner_RevertsOnZeroAddress() public { vm.prank(owner); vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); From d240bea65718a044379a5c854a64f4322dbc96b1 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 13:06:48 -0400 Subject: [PATCH 27/28] Fix linter warnings in SignedUniversalResolver Use calldata instead of memory for string params, pre-increment for counters, extract _bareDomainResponse helper to reduce resolve() line count, and deduplicate callData/extraData in OffchainLookup. --- src/nameservice/SignedUniversalResolver.sol | 54 +++++++++------------ 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/src/nameservice/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol index c31f859e..aa187974 100644 --- a/src/nameservice/SignedUniversalResolver.sol +++ b/src/nameservice/SignedUniversalResolver.sol @@ -66,6 +66,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI /// @notice Address of the L2 NameService contract. Read by the off-chain gateway /// to choose which L2 contract to query. Not consulted on-chain — the trust /// anchor for resolution is the EIP-712 signer, not this field. + // solhint-disable-next-line immutable-vars-naming address public immutable registry; /// @notice Trusted signers whose EIP-712 signatures this resolver will accept. @@ -107,7 +108,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI trustedSignerCount = 1; emit SignerTrusted(_initialSigner); - for (uint256 i = 0; i < _initialDomains.length; i++) { + for (uint256 i = 0; i < _initialDomains.length; ++i) { if (bytes(_initialDomains[i]).length == 0) revert EmptyDomain(); bytes32 key = keccak256(bytes(_initialDomains[i])); if (!isAllowedDomain[key]) { @@ -118,7 +119,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI } /// @notice Update the CCIP-Read gateway URL. - function setUrl(string memory _url) external onlyOwner { + function setUrl(string calldata _url) external onlyOwner { if (bytes(_url).length == 0) revert EmptyUrl(); string memory oldUrl = url; url = _url; @@ -132,7 +133,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI if (isTrustedSigner[signer]) return; isTrustedSigner[signer] = true; - trustedSignerCount++; + ++trustedSignerCount; emit SignerTrusted(signer); } @@ -145,13 +146,13 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); isTrustedSigner[signer] = false; - trustedSignerCount--; + --trustedSignerCount; emit SignerRevoked(signer); } /// @notice Allow a new domain to be resolved through this contract. /// @dev Idempotent: re-adding an already-allowed domain is a no-op. - function addDomain(string memory domain) external onlyOwner { + function addDomain(string calldata domain) external onlyOwner { if (bytes(domain).length == 0) revert EmptyDomain(); bytes32 key = keccak256(bytes(domain)); if (isAllowedDomain[key]) return; @@ -162,7 +163,7 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI /// @notice Remove a domain from the allowlist. /// @dev Idempotent: removing an already-disallowed domain is a no-op. - function removeDomain(string memory domain) external onlyOwner { + function removeDomain(string calldata domain) external onlyOwner { if (bytes(domain).length == 0) revert EmptyDomain(); bytes32 key = keccak256(bytes(domain)); if (!isAllowedDomain[key]) return; @@ -216,26 +217,34 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI return (first, second, third); } + /// @notice Returns the ENS "no record" encoding for a bare-domain query. + /// @param functionSelector The 4-byte ENS selector from the original call. + function _bareDomainResponse(bytes4 functionSelector) internal pure returns (bytes memory) { + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(""); + } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" + // is an empty bytes value, not a zero address. + return abi.encode(bytes("")); + } + return abi.encode(address(0)); + } + /// @notice ENSIP-10 entry point. Triggers CCIP-Read lookup via OffchainLookup revert. /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") /// @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); - // Reject domains this resolver was never configured to serve. This prevents - // the resolver from blindly triggering OffchainLookup if the ENS registry - // mistakenly points an unrelated domain at this contract. if (bytes(dom).length > 0 && !isAllowedDomain[keccak256(bytes(dom))]) { revert UnknownDomain(dom); } - // Explicit length check so short calldata reverts with a controlled error - // instead of a panic on the slice below. if (_data.length < 4) { revert CallDataTooShort(_data.length); } - // Dispatch only on supported selectors so the gateway is never asked for nonsense. bytes4 functionSelector = bytes4(_data[:4]); if ( functionSelector != _TEXT_SELECTOR && functionSelector != _ADDR_SELECTOR @@ -250,32 +259,15 @@ contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EI } } - // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 - // with the ENS "no record" convention: zero address for addr(bytes32), - // empty bytes for addr(bytes32,uint256) per ENSIP-11, empty string for - // text(bytes32,string). The resolver only exists to answer subdomain - // lookups — it holds no state about the parent name. if (bytes(sub).length == 0) { - if (functionSelector == _TEXT_SELECTOR) { - return abi.encode(""); - } - if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" - // is an empty bytes value, not a zero address. - return abi.encode(bytes("")); - } - return abi.encode(address(0)); + return _bareDomainResponse(functionSelector); } - // Pass the raw (name, data) to the gateway. It will query the L2 NameService, - // build the ABI-encoded result, and return it along with an EIP-712 signature. bytes memory callData = abi.encode(_name, _data); - bytes memory extraData = abi.encode(_name, _data); - string[] memory urls = new string[](1); urls[0] = url; - revert OffchainLookup(address(this), urls, callData, SignedUniversalResolver.resolveWithSig.selector, extraData); + revert OffchainLookup(address(this), urls, callData, SignedUniversalResolver.resolveWithSig.selector, callData); } /// @notice CCIP-Read callback. Verifies the gateway's EIP-712 signature and returns the result. From bba761631279455a710bc54d4311010318fd5807 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 21 Apr 2026 13:35:14 -0400 Subject: [PATCH 28/28] Restore old UniversalResolver contract --- src/nameservice/UniversalResolver.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nameservice/UniversalResolver.sol b/src/nameservice/UniversalResolver.sol index 91a3c75c..963802be 100644 --- a/src/nameservice/UniversalResolver.sol +++ b/src/nameservice/UniversalResolver.sol @@ -5,7 +5,7 @@ * @dev This contract is based on ClaveResolver that can be found in this repository: * https://github.com/getclave/zksync-storage-proofs */ -pragma solidity ^0.8.23; +pragma solidity ^0.8.26; import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; @@ -128,7 +128,7 @@ contract UniversalResolver is IExtendedResolver, IERC165, Ownable { /// /// @param _data The ABI encoded data for the underlying resolution function (Eg, addr(bytes32), text(bytes32,string), etc). function resolve(bytes calldata _name, bytes calldata _data) external view returns (bytes memory) { - (string memory sub,string memory dom,) = _parseDnsDomain(_name); + (string memory sub, string memory dom,) = _parseDnsDomain(_name); if (bytes(sub).length == 0) { return abi.encodePacked(domainOwner);