-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
363926c
fix(gateway): resolve text records and accept text/plain CCIP-Read POSTs
Douglasacost 9bca5e9
feat(resolver): replace storage-proof verification with EIP-712 signe…
Douglasacost 019599c
fix(resolver): block renounceOwnership to prevent bricking admin setters
Douglasacost 9723b9e
feat(resolver): cap max signature TTL at 5 minutes to bound replay wi…
Douglasacost 0dbeba5
feat(gateway): EIP-712 signed CCIP-Read endpoint for UniversalResolver
Douglasacost ae6a1d0
feat(doc): add protocol specification for Signed-Gateway UniversalRes…
Douglasacost b1fe5ee
chore(cspell): allow typehash and hexlify as domain terms
Douglasacost 1243a8c
fix(resolver): encode addr-multichain as ENSIP-11 bytes, not address
Douglasacost 8de71d2
fix(resolver): reject short calldata with CallDataTooShort instead of…
Douglasacost 1e654e9
refactor(resolver): use OwnershipCannotBeRenounced custom error
Douglasacost eadbca2
fix(gateway): validate RESOLUTION_SIGNATURE_TTL_SECONDS at startup
Douglasacost cffeb52
fix(resolver): validate signer inputs and enforce trusted-signer floor
Douglasacost 8b2a8e1
fix(gateway): validate sender matches configured L1 resolver address
Douglasacost f156a16
test(resolver): cover multichain resolveWithSig empty-record case
Douglasacost b5ad8a3
test(resolver): avoid cspell false positive in short-calldata test
Douglasacost ad92b93
chore(cspell): allow repoint, repointed, cutover
Douglasacost 8498078
Merge remote-tracking branch 'origin/main' into fix/gateway-text-reco…
Douglasacost 6fdbd61
Separate signed-gateway resolver into its own contract file
Douglasacost 85280f3
Use Ownable2Step instead of Ownable for SignedUniversalResolver
Douglasacost dfaf80a
Validate non-empty URL in setUrl and fix CEI ordering
Douglasacost aa5203f
Split setTrustedSigner into trustSigner and revokeSigner
Douglasacost 647df3d
Update bare-domain comment to document all three return types
Douglasacost 98d7c3f
Convert parseResolutionSignatureTtl to IIFE
Douglasacost 3f1f051
Remove unused hexlify import from resolve route
Douglasacost 12a3912
Validate TLD against parentTLD in resolve route
Douglasacost 106c434
Add on-chain domain allowlist and fuzz tests for TTL boundaries
Douglasacost 7e625e4
Accept multiple initial domains in constructor
Douglasacost d240bea
Fix linter warnings in SignedUniversalResolver
Douglasacost bba7616
Restore old UniversalResolver contract
Douglasacost File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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], | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| } | ||
|
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")) | ||
|
Douglasacost marked this conversation as resolved.
|
||
|
|
||
| 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 || "<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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.