From 363926ce187949f3a8e8801f53a370f5b004266b Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 13 Apr 2026 15:32:01 -0400 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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" ] }