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
- Resolve millions of names without onchain writes
- Let clients fetch records from your API, with a signature you verify onchain
- Use short TTLs, multiple URLs, and key rotation for reliability
- Start with addr and text, add contenthash when ready
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:
- No per-name storage onchain
- Dynamic data from your systems
- Good performance and cost profile
The flow at a glance
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
- ☑️ Gateway returns the exact tuple
{result, expires, sig}
encoded asbytes
- ☑️ Digest is
keccak256(0x1900 || resolver || expires || keccak256(request) || keccak256(result))
- ☑️ Contract checks
signers[signer]
andexpires >= block.timestamp
- ☑️ Suffix guard in gateway
- ☑️ Multiple URLs in
OffchainLookup
if you need HA - ☑️ Short TTL and CDN rules
- ☑️ Owner functions for signer and URL rotation
- ☑️ Dashboards and alerts
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