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..23935944 --- /dev/null +++ b/clk-gateway/src/routes/resolve.ts @@ -0,0 +1,171 @@ +import { AbiCoder, dataSlice, getAddress, isAddress, isHexString } from "ethers" +import { Router } from "express" +import { body, matchedData, validationResult } from "express-validator" +import { + clickNameServiceContract, + clickNSDomain, + l1ChainId, + l1ResolverAddress, + nameServiceContracts, + nodleNameServiceContract, + nodleNSDomain, + parentTLD, + 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")) + + if (parsed.tld && parsed.tld !== parentTLD) { + throw new HttpError( + `Unexpected TLD: "${parsed.tld}" (expected "${parentTLD}")`, + 400, + ) + } + + // Route to the correct L2 NameService based on the parent domain. + let nameServiceContract + if (parsed.domain === clickNSDomain) { + 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..ad445123 100644 --- a/clk-gateway/src/setup.ts +++ b/clk-gateway/src/setup.ts @@ -1,63 +1,107 @@ -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; + +const resolutionSignatureTtlSeconds = (() => { + const raw = process.env.RESOLUTION_SIGNATURE_TTL_SECONDS; + if (raw === undefined || raw === "") return 60; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) + throw new Error( + `Invalid RESOLUTION_SIGNATURE_TTL_SECONDS: "${raw}" is not a finite integer`, + ); + if (parsed <= 0) + throw new Error( + `RESOLUTION_SIGNATURE_TTL_SECONDS must be > 0, got ${parsed}`, + ); + if (parsed > MAX_RESOLUTION_SIGNATURE_TTL_SECONDS) + throw new Error( + `RESOLUTION_SIGNATURE_TTL_SECONDS must be <= ${MAX_RESOLUTION_SIGNATURE_TTL_SECONDS}, got ${parsed}`, + ); + return parsed; +})(); + +const resolverSigner = resolverSignerPrivateKey + ? new EthersWallet(resolverSignerPrivateKey) + : null; const zyfiRequestTemplate: ZyfiSponsoredRequest = { chainId: Number(process.env.L2_CHAIN_ID!), @@ -73,27 +117,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 +146,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 +169,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..f0ad714f 100644 --- a/script/DeployL1Ens.s.sol +++ b/script/DeployL1Ens.s.sol @@ -2,12 +2,7 @@ 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"; +import {SignedUniversalResolver} from "../src/nameservice/SignedUniversalResolver.sol"; interface IResolverSetter { function setResolver(bytes32 node, address resolver) external; @@ -19,51 +14,49 @@ 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..."); - UniversalResolver l1Resolver = new UniversalResolver( + console.log("Deploying SignedUniversalResolver (signed-gateway model)..."); + + // NS_DOMAINS is a comma-separated list of domains to allowlist, e.g. "nodl,clk". + string[] memory domains = vm.envOr("NS_DOMAINS", ",", new string[](0)); + if (domains.length == 0) { + // Fallback: single domain from NS_DOMAIN for backward compat. + domains = new string[](1); + domains[0] = vm.envString("NS_DOMAIN"); + } + + SignedUniversalResolver l1Resolver = new SignedUniversalResolver( vm.envString("NS_OFFCHAIN_RESOLVER_URL"), vm.envAddress("NS_OWNER_ADDR"), vm.envAddress("NS_ADDR"), - StorageProofVerifier(spvAddress) + vm.envAddress("NS_TRUSTED_SIGNER_ADDR"), + domains ); resolverAddress = address(l1Resolver); - console.log("Deployed UniversalResolver at", resolverAddress); + console.log("Deployed SignedUniversalResolver 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/SignedUniversalResolver.sol b/src/nameservice/SignedUniversalResolver.sol new file mode 100644 index 00000000..aa187974 --- /dev/null +++ b/src/nameservice/SignedUniversalResolver.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +/** + * @title SignedUniversalResolver + * @notice ENS-compatible L1 resolver for names registered on L2 (zkSync Era). + * @dev Uses the CCIP-Read (ERC-3668) pattern with a trusted-gateway signature + * model. The off-chain gateway queries the L2 NameService directly and + * returns an EIP-712 signed response. This contract recovers the signer + * and accepts the response only if it matches a registered trusted signer. + * + * This replaces the earlier zkSync storage-proof design which depended on + * per-batch state roots being committed to L1 — that path was broken when + * zkSync Era migrated settlement to ZK Gateway (~July 30, 2025). + */ +pragma solidity ^0.8.26; + +import {IERC165} from "lib/forge-std/src/interfaces/IERC165.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +/// @title IExtendedResolver +/// @notice ENSIP-10: Wildcard Resolution +interface IExtendedResolver { + function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory); +} + +contract SignedUniversalResolver is IExtendedResolver, IERC165, Ownable2Step, EIP712 { + bytes4 private constant _EXTENDED_INTERFACE_ID = 0x9061b923; // ENSIP-10 + + bytes4 private constant _ADDR_SELECTOR = 0x3b3b57de; // addr(bytes32) + bytes4 private constant _ADDR_MULTICHAIN_SELECTOR = 0xf1cb7e06; // addr(bytes32,uint) + bytes4 private constant _TEXT_SELECTOR = 0x59d1d43c; // text(bytes32,string) + uint256 private constant _ZKSYNC_MAINNET_COIN_TYPE = 2147483972; // (0x80000000 | 0x144) per ENSIP-11 + + /// @notice EIP-712 typehash for the payload signed by the trusted gateway. + /// @dev Keccak of "Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)" + bytes32 private constant _RESOLUTION_TYPEHASH = + keccak256("Resolution(bytes name,bytes data,bytes result,uint64 expiresAt)"); + + /// @notice Hard cap on how far into the future a gateway signature may claim to be valid. + /// @dev Bounds the replay window if a signer key is compromised: even a maliciously + /// long `expiresAt` is clamped to this value on-chain. 5 minutes is comfortably + /// above L1 clock skew while keeping blast radius small. + uint64 private constant _MAX_SIGNATURE_TTL = 5 minutes; + + error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); + error UnsupportedCoinType(uint256 coinType); + error UnsupportedSelector(bytes4 selector); + error CallDataTooShort(uint256 length); + error OwnershipCannotBeRenounced(); + error ZeroSignerAddress(); + error EmptyUrl(); + error EmptyDomain(); + error NoInitialDomains(); + error UnknownDomain(string domain); + error CannotDisableLastTrustedSigner(); + error SignatureExpired(uint64 expiresAt); + error SignatureTtlTooLong(uint64 expiresAt); + error InvalidSigner(address recovered); + + /// @notice URL of the CCIP-Read gateway. + string public url; + + /// @notice Address of the L2 NameService contract. Read by the off-chain gateway + /// to choose which L2 contract to query. Not consulted on-chain — the trust + /// anchor for resolution is the EIP-712 signer, not this field. + // solhint-disable-next-line immutable-vars-naming + address public immutable registry; + + /// @notice Trusted signers whose EIP-712 signatures this resolver will accept. + /// Mapping (rather than a single address) to allow zero-downtime key rotation. + mapping(address => bool) public isTrustedSigner; + + /// @notice Number of addresses currently marked as trusted signers. + /// @dev Kept in sync with `isTrustedSigner` and used to prevent dropping to zero. + /// If this ever hits zero, all resolution breaks and can only be restored + /// by the owner. The contract enforces a floor of 1 in `setTrustedSigner`. + uint256 public trustedSignerCount; + + /// @notice Domains this resolver is allowed to serve (e.g. "nodl", "clk"). + /// Keyed by keccak256(bytes(domain)). Prevents the resolver from blindly + /// triggering OffchainLookup for domains it was never intended to handle. + mapping(bytes32 => bool) public isAllowedDomain; + + event UrlUpdated(string oldUrl, string newUrl); + event SignerTrusted(address indexed signer); + event SignerRevoked(address indexed signer); + event DomainAdded(string domain); + event DomainRemoved(string domain); + + constructor( + string memory _url, + address _owner, + address _registry, + address _initialSigner, + string[] memory _initialDomains + ) Ownable(_owner) EIP712("NodleUniversalResolver", "1") { + if (_initialSigner == address(0)) revert ZeroSignerAddress(); + if (bytes(_url).length == 0) revert EmptyUrl(); + if (_initialDomains.length == 0) revert NoInitialDomains(); + + url = _url; + registry = _registry; + + isTrustedSigner[_initialSigner] = true; + trustedSignerCount = 1; + emit SignerTrusted(_initialSigner); + + for (uint256 i = 0; i < _initialDomains.length; ++i) { + if (bytes(_initialDomains[i]).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(_initialDomains[i])); + if (!isAllowedDomain[key]) { + isAllowedDomain[key] = true; + emit DomainAdded(_initialDomains[i]); + } + } + } + + /// @notice Update the CCIP-Read gateway URL. + function setUrl(string calldata _url) external onlyOwner { + if (bytes(_url).length == 0) revert EmptyUrl(); + string memory oldUrl = url; + url = _url; + emit UrlUpdated(oldUrl, _url); + } + + /// @notice Register a new trusted gateway signer. + /// @dev Idempotent: re-trusting an already-trusted signer is a no-op. + function trustSigner(address signer) external onlyOwner { + if (signer == address(0)) revert ZeroSignerAddress(); + if (isTrustedSigner[signer]) return; + + isTrustedSigner[signer] = true; + ++trustedSignerCount; + emit SignerTrusted(signer); + } + + /// @notice Revoke trust from a gateway signer. + /// @dev Enforces a floor of 1 so the owner cannot brick resolution. + /// Idempotent: revoking an already-untrusted signer is a no-op. + function revokeSigner(address signer) external onlyOwner { + if (signer == address(0)) revert ZeroSignerAddress(); + if (!isTrustedSigner[signer]) return; + if (trustedSignerCount == 1) revert CannotDisableLastTrustedSigner(); + + isTrustedSigner[signer] = false; + --trustedSignerCount; + emit SignerRevoked(signer); + } + + /// @notice Allow a new domain to be resolved through this contract. + /// @dev Idempotent: re-adding an already-allowed domain is a no-op. + function addDomain(string calldata domain) external onlyOwner { + if (bytes(domain).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(domain)); + if (isAllowedDomain[key]) return; + + isAllowedDomain[key] = true; + emit DomainAdded(domain); + } + + /// @notice Remove a domain from the allowlist. + /// @dev Idempotent: removing an already-disallowed domain is a no-op. + function removeDomain(string calldata domain) external onlyOwner { + if (bytes(domain).length == 0) revert EmptyDomain(); + bytes32 key = keccak256(bytes(domain)); + if (!isAllowedDomain[key]) return; + + isAllowedDomain[key] = false; + emit DomainRemoved(domain); + } + + /// @notice Ownership cannot be renounced: losing the owner bricks trustSigner, + /// revokeSigner and setUrl, which would permanently break gateway rotation + /// and signer revocation. Transfer to a new owner instead. + function renounceOwnership() public pure override { + revert OwnershipCannotBeRenounced(); + } + + /// @notice Parses DNS encoded domain name + /// @param name DNS encoded domain name + /// @return _sub Subdomain + /// @return _dom Domain + /// @return _top Top level domain + /// @dev e.g example.clave.eth is encoded as b"\x07example\x05clave\x03eth" + /// sub = "example" + /// dom = "clave" + /// top = "eth" + /// @dev It's possible that the name is just a top level domain, in which case sub and dom will be empty + /// @dev It's possible that the name is just a domain, in which case sub will be empty + function _parseDnsDomain(bytes calldata name) + internal + pure + returns (string memory _sub, string memory _dom, string memory _top) + { + uint256 length = name.length; + + uint8 firstLen = uint8(name[0]); + string memory first = string(name[1:1 + firstLen]); + + // If there's only one segment, it's a top level domain + // {top_length}.{top}.{0x00} + if (length == firstLen + 2) return ("", "", first); + + uint8 secondLen = uint8(name[firstLen + 1]); + string memory second = string(name[firstLen + 2:firstLen + 2 + secondLen]); + + // If there's only two segments, it's a domain + // {dom_length}.{dom}.{top_length}.{top}.{0x00} + if (length == firstLen + secondLen + 3) return ("", first, second); + + uint8 thirdLen = uint8(name[firstLen + secondLen + 2]); + string memory third = string(name[firstLen + secondLen + 3:firstLen + secondLen + 3 + thirdLen]); + + return (first, second, third); + } + + /// @notice Returns the ENS "no record" encoding for a bare-domain query. + /// @param functionSelector The 4-byte ENS selector from the original call. + function _bareDomainResponse(bytes4 functionSelector) internal pure returns (bytes memory) { + if (functionSelector == _TEXT_SELECTOR) { + return abi.encode(""); + } + if (functionSelector == _ADDR_MULTICHAIN_SELECTOR) { + // ENSIP-11: addr(bytes32,uint256) returns `bytes`. "No record" + // is an empty bytes value, not a zero address. + return abi.encode(bytes("")); + } + return abi.encode(address(0)); + } + + /// @notice ENSIP-10 entry point. Triggers CCIP-Read lookup via OffchainLookup revert. + /// @param _name DNS-encoded name (e.g. b"\x07example\x05clave\x03eth") + /// @param _data ABI-encoded ENS resolution call (addr / addr-multichain / text) + function resolve(bytes calldata _name, bytes calldata _data) external view returns (bytes memory) { + (string memory sub, string memory dom,) = _parseDnsDomain(_name); + + if (bytes(dom).length > 0 && !isAllowedDomain[keccak256(bytes(dom))]) { + revert UnknownDomain(dom); + } + + if (_data.length < 4) { + revert CallDataTooShort(_data.length); + } + + 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); + } + } + + if (bytes(sub).length == 0) { + return _bareDomainResponse(functionSelector); + } + + bytes memory callData = abi.encode(_name, _data); + string[] memory urls = new string[](1); + urls[0] = url; + + revert OffchainLookup(address(this), urls, callData, SignedUniversalResolver.resolveWithSig.selector, callData); + } + + /// @notice CCIP-Read callback. Verifies the gateway's EIP-712 signature and returns the result. + /// @param _response ABI-encoded (bytes result, uint64 expiresAt, bytes signature) + /// @param _extraData ABI-encoded (bytes name, bytes data) — echoed from the original resolve() call + /// @return The ABI-encoded resolution result, ready to be returned to the ENS caller. + function resolveWithSig(bytes calldata _response, bytes calldata _extraData) + external + view + returns (bytes memory) + { + (bytes memory result, uint64 expiresAt, bytes memory signature) = + abi.decode(_response, (bytes, uint64, bytes)); + (bytes memory name, bytes memory data) = abi.decode(_extraData, (bytes, bytes)); + + if (block.timestamp > expiresAt) { + revert SignatureExpired(expiresAt); + } + if (expiresAt > block.timestamp + _MAX_SIGNATURE_TTL) { + revert SignatureTtlTooLong(expiresAt); + } + + bytes32 structHash = keccak256( + abi.encode(_RESOLUTION_TYPEHASH, keccak256(name), keccak256(data), keccak256(result), expiresAt) + ); + bytes32 digest = _hashTypedDataV4(structHash); + address recovered = ECDSA.recover(digest, signature); + + if (!isTrustedSigner[recovered]) { + revert InvalidSigner(recovered); + } + + return result; + } + + /// @notice Expose the EIP-712 domain separator so off-chain signers can verify their setup. + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165) returns (bool) { + return interfaceId == type(IERC165).interfaceId || interfaceId == _EXTENDED_INTERFACE_ID + || interfaceId == type(IExtendedResolver).interfaceId; + } +} diff --git a/src/nameservice/doc/signed-resolver-protocol.md b/src/nameservice/doc/signed-resolver-protocol.md new file mode 100644 index 00000000..b3776039 --- /dev/null +++ b/src/nameservice/doc/signed-resolver-protocol.md @@ -0,0 +1,365 @@ +# 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-21 + +--- + +## 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; +mapping(bytes32 => bool) public isAllowedDomain; // keccak256(bytes(domain)) → allowed +``` + +**Trust anchor note:** `registry` is metadata for off-chain tooling and auditors. It is never consulted on-chain. The only trust anchor for resolution is the EIP-712 signer set. + +**Domain allowlist:** `isAllowedDomain` gates which parent domains (e.g. "nodl", "clk") the resolver will serve. The contract parses the DNS-encoded name and rejects unknown domains with `UnknownDomain(string)` before triggering `OffchainLookup`. This prevents the resolver from blindly forwarding requests if the ENS registry mistakenly points an unrelated domain at this contract. The allowlist must be kept in sync with the gateway's configured domain→contract mapping. + +### 4.5 Errors + +```solidity +error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData); +error UnsupportedCoinType(uint256 coinType); +error UnsupportedSelector(bytes4 selector); +error CallDataTooShort(uint256 length); +error EmptyUrl(); +error EmptyDomain(); +error UnknownDomain(string domain); +error OwnershipCannotBeRenounced(); +error ZeroSignerAddress(); +error CannotDisableLastTrustedSigner(); +error SignatureExpired(uint64 expiresAt); +error SignatureTtlTooLong(uint64 expiresAt); +error InvalidSigner(address recovered); +``` + +### 4.6 Events + +```solidity +event UrlUpdated(string oldUrl, string newUrl); +event SignerTrusted(address indexed signer); +event SignerRevoked(address indexed signer); +event DomainAdded(string domain); +event DomainRemoved(string domain); +``` + +### 4.7 Admin surface + +| Function | Access | Purpose | +|---|---|---| +| `setUrl(string)` | `onlyOwner` | Rotate gateway URL | +| `trustSigner(address)` | `onlyOwner` | Register a trusted gateway signer (idempotent) | +| `revokeSigner(address)` | `onlyOwner` | Revoke a trusted gateway signer (idempotent, floor of 1) | +| `addDomain(string)` | `onlyOwner` | Allow a domain to be resolved (idempotent) | +| `removeDomain(string)` | `onlyOwner` | Remove a domain from the allowlist (idempotent) | +| `transferOwnership(address)` | `onlyOwner` | Standard OZ `Ownable2Step` handoff | +| `acceptOwnership()` | pending owner | Complete the two-step ownership transfer | +| `renounceOwnership()` | **blocked** (reverts) | Prevents permanently bricking admin setters | + +At least one trusted signer must remain enabled at all times, or all resolution breaks. The domain allowlist has no such floor — removing all domains effectively disables the resolver without bricking admin functions. + +## 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 `trustSigner(newSigner)`. +3. Deploy gateway with the new key (blue/green or rolling) and verify it produces valid signatures end-to-end. +4. Owner calls `revokeSigner(oldSigner)`. +5. Delete the old key material. + +At no point should the contract have zero enabled signers (`revokeSigner` enforces a floor of 1). + +### 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 Domain management + +Adding a new parent domain (e.g. expanding from `nodl.eth` to also serve `clk.eth`): + +1. Deploy the L2 NameService contract for the new domain (if not already deployed). +2. Configure the gateway with the new domain → L2 contract mapping. +3. Owner calls `addDomain("clk")` on the L1 resolver. +4. Point the ENS node for the new domain at this resolver. + +Removing a domain: owner calls `removeDomain("clk")`. Resolution for that domain stops immediately on-chain. Update the gateway config to remove the route. + +### 8.4 Ownership handoff + +Standard two-step `transferOwnership(newOwner)` + `acceptOwnership()` (Ownable2Step). Production owner should be a multisig. `renounceOwnership` is intentionally blocked. + +### 8.5 Emergency: signer key compromise + +1. From the multisig, call `revokeSigner(compromisedSigner)` immediately — this is the hard kill. +2. Rotate the gateway to a new signer per §8.1. +3. Audit logs for the suspected window of compromise. +4. Communicate externally if any user-facing impact is suspected. + +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 on-chain domain allowlist and the gateway's domain routing. This is operationally simple but a signer compromise affects both. Blast-radius isolation requires separate deployments with separate signers. +- **Domain allowlist must be kept in sync with the gateway.** The contract's `isAllowedDomain` mapping and the gateway's configured domain→contract mapping are independent. Adding a domain to one but not the other will cause either on-chain rejection (contract missing) or gateway 404 (gateway missing). There is no automated sync or startup health check. +- **Reverse resolution is not supported.** This resolver does not implement `name(bytes32)` or ENSIP-19 reverse records. +- **No on-chain record of signer identities beyond the address.** Associate human-readable labels in an off-chain rotation log. + +## 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/SignedUniversalResolver.sol` +- `test/nameservice/SignedUniversalResolver.t.sol` +- `clk-gateway/src/resolver/signResolution.ts` +- `clk-gateway/src/routes/resolve.ts` diff --git a/test/nameservice/SignedUniversalResolver.t.sol b/test/nameservice/SignedUniversalResolver.t.sol new file mode 100644 index 00000000..d3e06dbc --- /dev/null +++ b/test/nameservice/SignedUniversalResolver.t.sol @@ -0,0 +1,673 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {SignedUniversalResolver, IExtendedResolver} from "../../src/nameservice/SignedUniversalResolver.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract SignedUniversalResolverTest is Test { + SignedUniversalResolver 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 SignerTrusted(address indexed signer); + event SignerRevoked(address indexed signer); + + string public constant INITIAL_DOMAIN = "clave"; + + function _initialDomains() internal pure returns (string[] memory) { + string[] memory domains = new string[](1); + domains[0] = INITIAL_DOMAIN; + return domains; + } + + function setUp() public { + owner = makeAddr("owner"); + registry = makeAddr("registry"); + (signer, signerPk) = makeAddrAndKey("signer"); + (backupSigner, backupSignerPk) = makeAddrAndKey("backup"); + + resolver = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, _initialDomains()); + } + + // --- 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(SignedUniversalResolver.CallDataTooShort.selector, uint256(3))); + resolver.resolve(DNS_FULL, shortData); + } + + function test_Resolve_UnsupportedSelector_Reverts() public { + bytes memory bogus = abi.encodeWithSelector(bytes4(0xdeadbeef), bytes32(0)); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnsupportedSelector.selector, bytes4(0xdeadbeef))); + resolver.resolve(DNS_FULL, bogus); + } + + function test_Resolve_AddrMultichain_WrongCoinType_Reverts() public { + bytes memory data = _addrMultichainCallData("example.clave.eth", 60); // ETH mainnet coin type + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.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(SignedUniversalResolver.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(SignedUniversalResolver.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(SignedUniversalResolver.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 SignerTrusted(backupSigner); + resolver.trustSigner(backupSigner); + + // 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); + vm.expectEmit(true, false, false, true, address(resolver)); + emit SignerRevoked(signer); + resolver.revokeSigner(signer); + + // Original signer's signatures are now rejected + bytes memory oldSig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory oldResponse = abi.encode(result, expiresAt, oldSig); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.InvalidSigner.selector, signer)); + resolver.resolveWithSig(oldResponse, extraData); + } + + function test_TrustSigner_OnlyOwner() public { + vm.expectRevert(); + resolver.trustSigner(backupSigner); + } + + function test_RevokeSigner_OnlyOwner() public { + vm.expectRevert(); + resolver.revokeSigner(signer); + } + + function test_Constructor_RevertsOnZeroSigner() public { + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, address(0), _initialDomains()); + } + + function test_Constructor_RevertsOnEmptyUrl() public { + vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); + new SignedUniversalResolver("", owner, registry, signer, _initialDomains()); + } + + function test_Constructor_RevertsOnNoInitialDomains() public { + string[] memory empty = new string[](0); + vm.expectRevert(SignedUniversalResolver.NoInitialDomains.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, empty); + } + + function test_Constructor_RevertsOnEmptyDomainInArray() public { + string[] memory domains = new string[](2); + domains[0] = "nodl"; + domains[1] = ""; + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, domains); + } + + function test_Constructor_SetsInitialDomain() public view { + assertTrue(resolver.isAllowedDomain(keccak256(bytes(INITIAL_DOMAIN)))); + } + + function test_Constructor_SetsMultipleInitialDomains() public { + string[] memory domains = new string[](2); + domains[0] = "nodl"; + domains[1] = "clk"; + SignedUniversalResolver multi = new SignedUniversalResolver(GATEWAY_URL, owner, registry, signer, domains); + assertTrue(multi.isAllowedDomain(keccak256(bytes("nodl")))); + assertTrue(multi.isAllowedDomain(keccak256(bytes("clk")))); + assertFalse(multi.isAllowedDomain(keccak256(bytes("other")))); + } + + function test_TrustSigner_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); + resolver.trustSigner(address(0)); + } + + function test_RevokeSigner_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.ZeroSignerAddress.selector); + resolver.revokeSigner(address(0)); + } + + function test_RevokeSigner_CannotDisableLastSigner() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.CannotDisableLastTrustedSigner.selector); + resolver.revokeSigner(signer); + } + + function test_TrustSigner_IsIdempotent() public { + assertEq(resolver.trustedSignerCount(), 1); + // Re-trusting an already-trusted signer is a no-op. + vm.prank(owner); + resolver.trustSigner(signer); + assertEq(resolver.trustedSignerCount(), 1); + } + + function test_RevokeSigner_IsIdempotent() public { + // Revoking an already-untrusted signer is a no-op. + vm.prank(owner); + resolver.revokeSigner(backupSigner); + assertEq(resolver.trustedSignerCount(), 1); + } + + function test_TrustedSignerCount_TracksChanges() public { + assertEq(resolver.trustedSignerCount(), 1); + + vm.prank(owner); + resolver.trustSigner(backupSigner); + assertEq(resolver.trustedSignerCount(), 2); + + vm.prank(owner); + resolver.revokeSigner(signer); + assertEq(resolver.trustedSignerCount(), 1); + } + + function test_RenounceOwnership_Reverts() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.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"); + } + + function test_SetUrl_RevertsOnEmptyUrl() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyUrl.selector); + resolver.setUrl(""); + } + + // --- EIP-712 domain binding --- + + function test_DomainSeparator_IsNonZero() public view { + 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)); + } + + // --- domain allowlist --- + + function test_AddDomain_OnlyOwner() public { + vm.expectRevert(); + resolver.addDomain("nodl"); + } + + function test_AddDomain_Success() public { + vm.prank(owner); + resolver.addDomain("nodl"); + assertTrue(resolver.isAllowedDomain(keccak256(bytes("nodl")))); + } + + function test_AddDomain_IsIdempotent() public { + vm.prank(owner); + resolver.addDomain("nodl"); + // Second add is a no-op + vm.prank(owner); + resolver.addDomain("nodl"); + assertTrue(resolver.isAllowedDomain(keccak256(bytes("nodl")))); + } + + function test_AddDomain_RevertsOnEmptyDomain() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + resolver.addDomain(""); + } + + function test_RemoveDomain_OnlyOwner() public { + vm.expectRevert(); + resolver.removeDomain(INITIAL_DOMAIN); + } + + function test_RemoveDomain_Success() public { + vm.prank(owner); + resolver.removeDomain(INITIAL_DOMAIN); + assertFalse(resolver.isAllowedDomain(keccak256(bytes(INITIAL_DOMAIN)))); + } + + function test_RemoveDomain_IsIdempotent() public { + // Removing an already-disallowed domain is a no-op + vm.prank(owner); + resolver.removeDomain("nonexistent"); + } + + function test_RemoveDomain_RevertsOnEmptyDomain() public { + vm.prank(owner); + vm.expectRevert(SignedUniversalResolver.EmptyDomain.selector); + resolver.removeDomain(""); + } + + function test_Resolve_UnknownDomain_Reverts() public { + // DNS-encoded "example.unknown.eth" — domain is "unknown", not in allowlist + bytes memory dnsUnknown = hex"076578616d706c6507756e6b6e6f776e0365746800"; + bytes memory data = _addrCallData("example.unknown.eth"); + + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnknownDomain.selector, "unknown")); + resolver.resolve(dnsUnknown, data); + } + + function test_Resolve_AllowedDomain_TriggersOffchainLookup() public { + // DNS_FULL uses "clave" domain which is in the allowlist + bytes memory data = _addrCallData("example.clave.eth"); + vm.expectRevert(); // OffchainLookup + resolver.resolve(DNS_FULL, data); + } + + function test_Resolve_NewlyAddedDomain_Works() public { + // Add "nodl" domain + vm.prank(owner); + resolver.addDomain("nodl"); + + // DNS-encoded "example.nodl.eth" + bytes memory dnsNodl = hex"076578616d706c65046e6f646c0365746800"; + bytes memory data = _addrCallData("example.nodl.eth"); + vm.expectRevert(); // OffchainLookup + resolver.resolve(dnsNodl, data); + } + + function test_Resolve_RemovedDomain_Reverts() public { + // Remove the initial "clave" domain + vm.prank(owner); + resolver.removeDomain(INITIAL_DOMAIN); + + bytes memory data = _addrCallData("example.clave.eth"); + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.UnknownDomain.selector, "clave")); + resolver.resolve(DNS_FULL, data); + } + + // --- sanity: initial signer was set --- + + function test_InitialSignerIsTrusted() public view { + assertTrue(resolver.isTrustedSigner(signer)); + assertFalse(resolver.isTrustedSigner(backupSigner)); + } + + // --- fuzz: TTL / expiry boundaries --- + + /// @notice Fuzz expiresAt across the full uint64 range. + /// Partitions: expired (past), valid window, TTL too long. + function testFuzz_ResolveWithSig_ExpiresAt(uint64 expiresAt) public { + // Fix block.timestamp to a known value so the three zones are deterministic. + uint256 ts = 1_700_000_000; + vm.warp(ts); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + if (expiresAt < ts) { + // Zone 1: expired — block.timestamp > expiresAt + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureExpired.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } else if (expiresAt > ts + 5 minutes) { + // Zone 3: TTL too long — expiresAt > block.timestamp + _MAX_SIGNATURE_TTL + vm.expectRevert(abi.encodeWithSelector(SignedUniversalResolver.SignatureTtlTooLong.selector, expiresAt)); + resolver.resolveWithSig(response, extraData); + } else { + // Zone 2: valid window — ts <= expiresAt <= ts + 300 + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + } + + /// @notice Fuzz block.timestamp while keeping expiresAt fixed at a known valid offset. + /// Ensures the expiry check works regardless of when the chain is. + function testFuzz_ResolveWithSig_Timestamp(uint64 timestamp) public { + // Bound timestamp to avoid overflow when adding 5 minutes + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp + 60); // 60s into valid window + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // Should always succeed: expiresAt = now + 60 is within [now, now + 300] + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz the exact boundary: expiresAt == block.timestamp (not expired, edge). + function testFuzz_ResolveWithSig_ExpiresAtExactlyNow(uint64 timestamp) public { + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp); // exactly now + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // block.timestamp > expiresAt is false when equal → should succeed + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz the upper boundary: expiresAt == block.timestamp + 5 minutes (max allowed). + function testFuzz_ResolveWithSig_ExpiresAtMaxTtl(uint64 timestamp) public { + vm.assume(timestamp > 0 && timestamp < type(uint64).max - 5 minutes); + vm.warp(timestamp); + + bytes memory data = _addrCallData("example.clave.eth"); + bytes memory result = abi.encode(makeAddr("owner")); + uint64 expiresAt = uint64(timestamp + 5 minutes); // exactly at cap + + bytes memory sig = _signResolution(signerPk, DNS_FULL, data, result, expiresAt); + bytes memory response = abi.encode(result, expiresAt, sig); + bytes memory extraData = abi.encode(DNS_FULL, data); + + // expiresAt == block.timestamp + _MAX_SIGNATURE_TTL → not strictly greater → should succeed + bytes memory out = resolver.resolveWithSig(response, extraData); + assertEq(keccak256(out), keccak256(result)); + } + + /// @notice Fuzz DNS-encoded names with variable-length segments. + /// Verifies resolve() doesn't panic on arbitrary well-formed DNS names. + function testFuzz_Resolve_DnsName(uint8 subLen, uint8 domLen, uint8 tldLen) public { + // Bound lengths to [1,63] per DNS label rules + subLen = uint8(bound(subLen, 1, 63)); + domLen = uint8(bound(domLen, 1, 63)); + tldLen = uint8(bound(tldLen, 1, 63)); + + // Build DNS-encoded name: <0x00> + bytes memory name = new bytes(uint256(subLen) + uint256(domLen) + uint256(tldLen) + 4); + name[0] = bytes1(subLen); + // Fill sub with 'a' + for (uint256 i = 0; i < subLen; i++) { + name[1 + i] = "a"; + } + name[1 + subLen] = bytes1(domLen); + // Fill dom with 'b' + for (uint256 i = 0; i < domLen; i++) { + name[2 + subLen + i] = "b"; + } + name[2 + subLen + domLen] = bytes1(tldLen); + // Fill tld with 'c' + for (uint256 i = 0; i < tldLen; i++) { + name[3 + subLen + domLen + i] = "c"; + } + name[name.length - 1] = 0x00; + + bytes memory data = _addrCallData("test"); + + // Has a subdomain → should revert (UnknownDomain for non-allowlisted + // domains, OffchainLookup for allowlisted ones). Either way, no panic. + vm.expectRevert(); + resolver.resolve(name, data); + } +}