diff --git a/.cspell.json b/.cspell.json index e8153a66..77c5ae9b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -96,6 +96,11 @@ "reconstructable", "Württemberg", "delegatecall", - "sponsorable" + "sponsorable", + "typehash", + "hexlify", + "repoint", + "repointed", + "cutover" ] } diff --git a/clk-gateway/src/index.ts b/clk-gateway/src/index.ts index 85a563fc..5b3807be 100644 --- a/clk-gateway/src/index.ts +++ b/clk-gateway/src/index.ts @@ -44,9 +44,12 @@ import { import { getBatchInfo, fetchZyfiSponsored } from "./helpers"; import reservedHashes from "./reservedHashes"; import namesRouter from "./routes/names"; +import resolveRouter from "./routes/resolve"; 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: "*", @@ -512,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/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)", diff --git a/clk-gateway/src/resolver/resolveFromL2.ts b/clk-gateway/src/resolver/resolveFromL2.ts new file mode 100644 index 00000000..46ed6da2 --- /dev/null +++ b/clk-gateway/src/resolver/resolveFromL2.ts @@ -0,0 +1,104 @@ +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 +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) { + 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}`) + } + } + + 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 → ENS "no record" convention. + if (isMultichain) { + return abi.encode(["bytes"], ["0x"]) + } + 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..2ac6bc7a --- /dev/null +++ b/clk-gateway/src/routes/resolve.ts @@ -0,0 +1,163 @@ +import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, 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") + .custom((value: string) => isAddress(value)) + .withMessage("sender must be a valid address"), + 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, 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. + // 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..4b619bcb 100644 --- a/clk-gateway/src/setup.ts +++ b/clk-gateway/src/setup.ts @@ -1,63 +1,114 @@ -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; + +// 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) + : null; const zyfiRequestTemplate: ZyfiSponsoredRequest = { chainId: Number(process.env.L2_CHAIN_ID!), @@ -73,27 +124,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 +153,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 +176,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, +}; 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..000f4083 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,110 @@ 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)"); + + /// @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 UnsupportedChain(uint256 coinType); - error InvalidStorageProof(); - - /// @notice Storage proof verifier contract - StorageProofVerifier public storageProofVerifier; - - /// @notice URL of the resolver + error CallDataTooShort(uint256 length); + error OwnershipCannotBeRenounced(); + error ZeroSignerAddress(); + error EmptyUrl(); + error CannotDisableLastTrustedSigner(); + error SignatureExpired(uint64 expiresAt); + error SignatureTtlTooLong(uint64 expiresAt); + error InvalidSigner(address recovered); + + /// @notice URL of the CCIP-Read gateway. string public url; - /// @notice Address of the 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 Number of addresses currently marked as trusted signers. + /// @dev Kept in sync with `isTrustedSigner` and used to prevent dropping to zero. + /// If this ever hits zero, all resolution breaks and can only be restored + /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. + uint256 public trustedSignerCount; - /// @notice 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") { + if (_initialSigner == address(0)) revert ZeroSignerAddress(); + if (bytes(_url).length == 0) revert EmptyUrl(); + 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; + trustedSignerCount = 1; + emit TrustedSignerUpdated(_initialSigner, true); } + /// @notice Update the CCIP-Read gateway URL. function setUrl(string memory _url) external onlyOwner { + emit UrlUpdated(url, _url); url = _url; } + /// @notice Enable or disable a trusted gateway signer. + /// @dev Keeps `trustedSignerCount` in sync and enforces a floor of 1 so the + /// owner cannot brick resolution by disabling the last signer. + function setTrustedSigner(address signer, bool trusted) external onlyOwner { + if (signer == address(0)) revert ZeroSignerAddress(); + + bool current = isTrustedSigner[signer]; + if (current == trusted) { + // Idempotent: nothing to do, no event, no count change. + return; + } + + if (trusted) { + isTrustedSigner[signer] = true; + trustedSignerCount++; + } else { + if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); + isTrustedSigner[signer] = false; + trustedSignerCount--; + } + + emit TrustedSignerUpdated(signer, trusted); + } + + /// @notice Ownership cannot be renounced: losing the owner bricks setUrl and + /// setTrustedSigner, which would permanently break gateway rotation and + /// signer revocation. Transfer to a new owner instead. + function renounceOwnership() public pure override { + revert OwnershipCannotBeRenounced(); + } + /// @notice Parses DNS encoded domain name /// @param name DNS encoded domain name /// @return _sub Subdomain @@ -105,87 +167,98 @@ 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); + // 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]); - bytes32 storageKey; + 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); + } + } - 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); + // Bare-domain queries (nodl.eth itself, no subdomain) are answered on L1 with + // the ENS "no record" convention: zero address for addr queries, empty string + // for text queries. The resolver only exists to answer subdomain lookups — it + // holds no state about the parent name. If a specific address needs to be + // associated with the bare domain, set it via a different resolver at the + // ENS registry level. + if (bytes(sub).length == 0) { + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(""); + } if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { - (, uint256 coinType) = abi.decode(_data[4:], (bytes32, uint256)); - if (coinType != _ZKSYNC_MAINNET_COIN_TYPE) { - revert UnsupportedCoinType(coinType); - } + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" + // is an empty bytes value, not a zero address. + return abi.encode(bytes("")); } - } 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); + } + if (expiresAt > block.timestamp + _MAX_SIGNATURE_TTL) { + revert SignatureTtlTooLong(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/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md new file mode 100644 index 00000000..de55448c --- /dev/null +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -0,0 +1,336 @@ +# 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(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. + +### 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` diff --git a/test/nameservice/UniversalResolver.t.sol b/test/nameservice/UniversalResolver.t.sol new file mode 100644 index 00000000..77665a87 --- /dev/null +++ b/test/nameservice/UniversalResolver.t.sol @@ -0,0 +1,404 @@ +// 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_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)); + bytes memory decoded = abi.decode(out, (bytes)); + assertEq(decoded.length, 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_ShortCallData_Reverts() public { + 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); + } + + 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_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_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"); + 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_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")); + 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); + } + + 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); + resolver.renounceOwnership(); + } + + // --- 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)); + } +}