Yoginth

Design Patterns for ENS Offchain Resolution

A simple, practical guide to building secure and scalable ENS Offchain Resolver / L2 Resolver using CCIP Read.

TL;DR

What is offchain resolution

ENS offchain resolution lets a smart contract say I do not have the answer here, please ask my trusted API. The client does a web request, gets a signed answer, then calls back into the contract. The contract verifies the signature and returns the record. This is defined by CCIP Read.

Why teams use it:

The flow at a glance

ccip-sequence

Key idea

The gateway signs a hash that includes the resolver address, the request hash, the response hash, and an expiry. The contract only accepts responses from allow-listed signers and only before expiry.

Contracts

IExtendedResolver.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

interface IExtendedResolver {
    function resolve(bytes memory name, bytes memory data) external view returns(bytes memory);
}

OffchainResolver.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@ensdomains/ens-contracts/contracts/resolvers/SupportsInterface.sol";
import "./IExtendedResolver.sol";
import "./SignatureVerifier.sol";

interface IResolverService {
    function resolve(bytes calldata name, bytes calldata data) external view returns(bytes memory result, uint64 expires, bytes memory sig);
}

contract OffchainResolver is IExtendedResolver, SupportsInterface {
    string public url;
    mapping(address=>bool) public signers;

    event NewSigners(address[] signers);
    error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData);

    constructor(string memory _url, address[] memory _signers) {
        url = _url;
        for(uint i = 0; i < _signers.length; i++) {
            signers[_signers[i]] = true;
        }
        emit NewSigners(_signers);
    }

    function makeSignatureHash(address target, uint64 expires, bytes memory request, bytes memory result) external pure returns(bytes32) {
        return SignatureVerifier.makeSignatureHash(target, expires, request, result);
    }

    function resolve(bytes calldata name, bytes calldata data) external override view returns(bytes memory) {
        bytes memory callData = abi.encodeWithSelector(IResolverService.resolve.selector, name, data);
        string;
        urls[0] = url;
        revert OffchainLookup(
            address(this),
            urls,
            callData,
            OffchainResolver.resolveWithProof.selector,
            callData
        );
    }

    function resolveWithProof(bytes calldata response, bytes calldata extraData) external view returns(bytes memory) {
        (address signer, bytes memory result) = SignatureVerifier.verify(extraData, response);
        require(
            signers[signer],
            "SignatureVerifier: Invalid sigature");
        return result;
    }

    function supportsInterface(bytes4 interfaceID) public pure override returns(bool) {
        return interfaceID == type(IExtendedResolver).interfaceId || super.supportsInterface(interfaceID);
    }
}

SignatureVerifier.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

library SignatureVerifier {
    function makeSignatureHash(address target, uint64 expires, bytes memory request, bytes memory result) internal pure returns(bytes32) {
        return keccak256(abi.encodePacked(hex"1900", target, expires, keccak256(request), keccak256(result)));
    }

    function verify(bytes calldata request, bytes calldata response) internal view returns(address, bytes memory) {
        (bytes memory result, uint64 expires, bytes memory sig) = abi.decode(response, (bytes, uint64, bytes));
        address signer = ECDSA.recover(makeSignatureHash(address(this), expires, request, result), sig);
        require(
            expires >= block.timestamp,
            "SignatureVerifier: Signature expired");
        return (signer, result);
    }
}

Gateway service

import { Hono } from "hono";
import ccip from "./resolver/ccip";
import getAccount from "./resolver/getAccount";

const app = new Hono();

app.get("/ccip/:sender/:data", ccip);

export default app;
import type { Context } from "hono";
import type { Hex } from "viem";
import { getRecord } from "./query";
import { decodeEnsOffchainRequest, encodeEnsOffchainResponse } from "./utils";

const CCIP = async (ctx: Context) => {
  const sender = ctx.req.param("sender");
  const dataParam = ctx.req.param("data");
  if (!sender || !dataParam) return ctx.json({ error: "Bad Request" }, 400);

  let result: string;

  try {
    const param = { data: dataParam as Hex, sender: sender as Hex };
    const { name, query } = decodeEnsOffchainRequest(param);
    result = await getRecord(name, query);
    const data = await encodeEnsOffchainResponse(
      param,
      result,
      process.env.PRIVATE_KEY as Hex
    );

    return ctx.json({ data }, 200);
  } catch {
    return ctx.json({ error: "Bad Request" }, 400);
  }
};

export default CCIP;
import { zeroAddress } from "viem";
import getLensAccount from "@/utils/getLensAccount";
import type { ResolverQuery } from "./utils";

export async function getRecord(name: string, query: ResolverQuery) {
  const { functionName, args } = query;

  let res: string;
  const account = await getLensAccount(name.replace(".hey.xyz", ""));

  switch (functionName) {
    case "addr": {
      res = account.address ?? zeroAddress;
      break;
    }
    case "text": {
      const key = args[1];
      res = account.texts[key as keyof typeof account.texts] ?? "";
      break;
    }
    default: {
      throw new Error(`Unsupported query function ${functionName}`);
    }
  }

  return res;
}
import {
  type AbiItem,
  type ByteArray,
  type Hex,
  type Prettify,
  serializeSignature
} from "viem";
import { sign } from "viem/accounts";
import {
  bytesToString,
  decodeFunctionData,
  encodeAbiParameters,
  encodeFunctionResult,
  encodePacked,
  keccak256,
  parseAbi,
  toBytes
} from "viem/utils";

type ResolverQueryAddr = {
  args:
    | readonly [nodeHash: `0x${string}`]
    | readonly [nodeHash: `0x${string}`, coinType: bigint];
  functionName: "addr";
};

type ResolverQueryText = {
  args: readonly [nodeHash: `0x${string}`, key: string];
  functionName: "text";
};

type ResolverQueryContentHash = {
  args: readonly [nodeHash: `0x${string}`];
  functionName: "contenthash";
};

export type ResolverQuery = Prettify<
  ResolverQueryAddr | ResolverQueryText | ResolverQueryContentHash
>;

type DecodedRequestFullReturnType = {
  name: string;
  query: ResolverQuery;
};

function bytesToPacket(bytes: ByteArray): string {
  let offset = 0;
  let result = "";

  while (offset < bytes.length) {
    const len = bytes[offset];
    if (len === 0) {
      offset += 1;
      break;
    }

    result += `${bytesToString(bytes.subarray(offset + 1, offset + len + 1))}.`;
    offset += len + 1;
  }

  return result.replace(/\.$/, "");
}

function dnsDecodeName(encodedName: string): string {
  const bytesName = toBytes(encodedName);
  return bytesToPacket(bytesName);
}

const OFFCHAIN_RESOLVER_ABI = parseAbi([
  "function resolve(bytes calldata name, bytes calldata data) view returns(bytes memory result, uint64 expires, bytes memory sig)"
]);

const RESOLVER_ABI = parseAbi([
  "function addr(bytes32 node) view returns (address)",
  "function addr(bytes32 node, uint256 coinType) view returns (bytes memory)",
  "function text(bytes32 node, string key) view returns (string memory)",
  "function contenthash(bytes32 node) view returns (bytes memory)"
]);

export function decodeEnsOffchainRequest({
  data
}: {
  sender: `0x${string}`;
  data: `0x${string}`;
}): DecodedRequestFullReturnType {
  const decodedResolveCall = decodeFunctionData({
    abi: OFFCHAIN_RESOLVER_ABI,
    data
  });

  const [dnsEncodedName, encodedResolveCall] = decodedResolveCall.args;
  const name = dnsDecodeName(dnsEncodedName);
  const query = decodeFunctionData({
    abi: RESOLVER_ABI,
    data: encodedResolveCall
  });

  return {
    name,
    query
  };
}

export async function encodeEnsOffchainResponse(
  request: { sender: `0x${string}`; data: `0x${string}` },
  result: string,
  signerPrivateKey: Hex
): Promise<Hex> {
  const { sender, data } = request;
  const { query } = decodeEnsOffchainRequest({ data, sender });
  const ttl = 1000;
  const validUntil = Math.floor(Date.now() / 1000 + ttl);

  const abiItem: AbiItem | undefined = RESOLVER_ABI.find(
    (abi) =>
      abi.name === query.functionName && abi.inputs.length === query.args.length
  );

  const functionResult = encodeFunctionResult({
    abi: [abiItem],
    functionName: query.functionName,
    result
  });

  const messageHash = keccak256(
    encodePacked(
      ["bytes", "address", "uint64", "bytes32", "bytes32"],
      [
        "0x1900",
        sender,
        BigInt(validUntil),
        keccak256(data),
        keccak256(functionResult)
      ]
    )
  );

  const sig = await sign({
    hash: messageHash,
    privateKey: signerPrivateKey
  });

  const encodedResponse = encodeAbiParameters(
    [
      { name: "result", type: "bytes" },
      { name: "expires", type: "uint64" },
      { name: "sig", type: "bytes" }
    ],
    [functionResult, BigInt(validUntil), serializeSignature(sig)]
  );

  return encodedResponse;
}
import getAccount from "@hey/helpers/getAccount";
import getAvatar from "@hey/helpers/getAvatar";
import type { Maybe, MetadataAttributeFragment } from "@hey/indexer";
import { AccountDocument, type AccountFragment } from "@hey/indexer";
import apolloClient from "@hey/indexer/apollo/client";
import { type Hex, zeroAddress } from "viem";

const getAccountAttribute = (
  key: string,
  attributes: Maybe<MetadataAttributeFragment[]> = []
): string => {
  const attribute = attributes?.find((attr) => attr.key === key);
  return attribute?.value || "";
};

interface LensAccount {
  address: Hex;
  texts: {
    avatar: string;
    description: string;
    name?: string;
    url: string;
    location?: string;
    "com.twitter"?: string;
  };
}

const defaultAccount: LensAccount = {
  address: zeroAddress,
  texts: {
    avatar: "",
    "com.twitter": "",
    description: "",
    location: "",
    name: "",
    url: ""
  }
};

const getLensAccount = async (handle: string): Promise<LensAccount> => {
  try {
    const { data } = await apolloClient.query<{
      account: AccountFragment;
    }>({
      fetchPolicy: "no-cache",
      query: AccountDocument,
      variables: { request: { username: { localName: handle } } }
    });

    if (!data.account.isBeta) {
      return defaultAccount;
    }

    const address = data.account.owner;
    if (!address) return defaultAccount;
    return {
      address: address.toLowerCase() as Hex,
      texts: {
        avatar: getAvatar(data.account),
        "com.twitter": getAccountAttribute(
          "x",
          data.account?.metadata?.attributes
        ),
        description: data.account.metadata?.bio ?? "",
        location: getAccountAttribute(
          "location",
          data.account?.metadata?.attributes
        ),
        name: getAccount(data.account).name,
        url: `https://hey.xyz${getAccount(data.account).link}`
      }
    };
  } catch {
    return defaultAccount;
  }
};

export default getLensAccount;

Design patterns

Wildcard names

Answer for any *.yourdomain.tld via resolve(bytes name, bytes data) on the nearest ancestor resolver that implements it. No per-subname writes.

URL failover

Return multiple URLs in OffchainLookup. Host gateways in different regions. Health check them.

Key rotation

Add addSigner, removeSigner, and setUrl with an owner. Emit events. Rotate keys.

Short TTL with caching

Keep TTL small like 5 to 15 minutes. Cache by name and function at the edge. Respect expires.

Strict domain scoping

In the gateway, require name.endsWith(".yourdomain.tld"). Refuse anything else.

Record whitelist

Start narrow. addr and a few text keys. Add contenthash when ready.

Multi-coin addresses

For addr(node, coinType), return the correct raw bytes. For 60, return 20 raw bytes of the EVM address.

Observability

Log name, function, signer, expiry. Track cache hit rate and latency. Alert on signature or expiry errors.

Deployment checklist

Quick tests

Resolve an address:

import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider(RPC_URL);
console.log(await provider.resolveName("alice.yourdomain.tld"));

Read a text record:

const r = await provider.getResolver("alice.yourdomain.tld");
console.log(await r.getText("url"));

Foundry unit test scaffold:

bytes memory data = abi.encodeWithSignature("addr(bytes32)", node);
vm.expectRevert(); resolver.resolve("alice.yourdomain.tld", data); // OffchainLookup

Wrap up

Start with addr and a couple of text keys. Keep TTLs short, add failover URLs, and rotate signer keys. This pattern scales from hobby projects to large companies without heavy onchain state.

GitHub

WIP: https://github.com/bigint/ens-offchain-resolver

Subscribe to my blog