Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
363926c
fix(gateway): resolve text records and accept text/plain CCIP-Read POSTs
Douglasacost Apr 13, 2026
9bca5e9
feat(resolver): replace storage-proof verification with EIP-712 signe…
Douglasacost Apr 13, 2026
019599c
fix(resolver): block renounceOwnership to prevent bricking admin setters
Douglasacost Apr 13, 2026
9723b9e
feat(resolver): cap max signature TTL at 5 minutes to bound replay wi…
Douglasacost Apr 13, 2026
0dbeba5
feat(gateway): EIP-712 signed CCIP-Read endpoint for UniversalResolver
Douglasacost Apr 13, 2026
ae6a1d0
feat(doc): add protocol specification for Signed-Gateway UniversalRes…
Douglasacost Apr 13, 2026
b1fe5ee
chore(cspell): allow typehash and hexlify as domain terms
Douglasacost Apr 13, 2026
1243a8c
fix(resolver): encode addr-multichain as ENSIP-11 bytes, not address
Douglasacost Apr 13, 2026
8de71d2
fix(resolver): reject short calldata with CallDataTooShort instead of…
Douglasacost Apr 13, 2026
1e654e9
refactor(resolver): use OwnershipCannotBeRenounced custom error
Douglasacost Apr 13, 2026
eadbca2
fix(gateway): validate RESOLUTION_SIGNATURE_TTL_SECONDS at startup
Douglasacost Apr 13, 2026
cffeb52
fix(resolver): validate signer inputs and enforce trusted-signer floor
Douglasacost Apr 13, 2026
8b2a8e1
fix(gateway): validate sender matches configured L1 resolver address
Douglasacost Apr 13, 2026
f156a16
test(resolver): cover multichain resolveWithSig empty-record case
Douglasacost Apr 13, 2026
b5ad8a3
test(resolver): avoid cspell false positive in short-calldata test
Douglasacost Apr 13, 2026
ad92b93
chore(cspell): allow repoint, repointed, cutover
Douglasacost Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@
"reconstructable",
"Württemberg",
"delegatecall",
"sponsorable"
"sponsorable",
"typehash",
"hexlify",
"repoint",
"repointed",
"cutover"
]
}
6 changes: 5 additions & 1 deletion clk-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*",
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions clk-gateway/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
104 changes: 104 additions & 0 deletions clk-gateway/src/resolver/resolveFromL2.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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])
}
}
Comment thread
Douglasacost marked this conversation as resolved.

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 }
71 changes: 71 additions & 0 deletions clk-gateway/src/resolver/signResolution.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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],
)
}
163 changes: 163 additions & 0 deletions clk-gateway/src/routes/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, isHexString } from "ethers"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[8/10] Unused hexlify import

hexlify is imported here but never used anywhere in this file. Remove it.

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,
)
}
Comment thread
Douglasacost marked this conversation as resolved.

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 <selector>).
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"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[9/10] Consider validating parsed.tld against parentTLD

The gateway uses parsed.sub and parsed.domain for routing but never validates parsed.tld. A sanity check against the configured parentTLD would reject obviously malformed names early. Low priority since the L1 resolver already constrains which names reach the gateway.


// 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 || "<empty>"}`,
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
Loading
Loading