-
Notifications
You must be signed in to change notification settings - Fork 5
Fix text record resolution and enhance CCIP-Read handling #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
363926c
9bca5e9
019599c
9723b9e
0dbeba5
ae6a1d0
b1fe5ee
1243a8c
8de71d2
1e654e9
eadbca2
cffeb52
8b2a8e1
f156a16
b5ad8a3
ad92b93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]) | ||
| } | ||
| } | ||
|
|
||
| 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 } | ||
| 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], | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { AbiCoder, dataSlice, getAddress, hexlify, isAddress, isHexString } from "ethers" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [8/10] Unused
|
||
| 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, | ||
| ) | ||
| } | ||
|
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")) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [9/10] Consider validating
|
||
|
|
||
| // 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 | ||
Uh oh!
There was an error while loading. Please reload this page.