Skip to content

Instantly share code, notes, and snippets.

@freeatnet
Last active November 22, 2024 20:45
Show Gist options
  • Save freeatnet/e08eb0eb5879c0d62a59130031544ae3 to your computer and use it in GitHub Desktop.
Save freeatnet/e08eb0eb5879c0d62a59130031544ae3 to your computer and use it in GitHub Desktop.
A set of helper functions and a sample Next.js API route to implement an ENS offchain resolver while remaining blissfully unaware of EIP-3668 and any ENSIP implementation details.
import {
bytesToString,
decodeFunctionData,
encodeAbiParameters,
encodeFunctionResult,
encodePacked,
keccak256,
parseAbi,
parseAbiItem,
serializeSignature,
toBytes,
type ByteArray,
type Hex,
} from "viem";
import { sign } from "viem/accounts";
/**
* @description Combines members of an intersection into a readable type.
* @see {@link https://github.com/wevm/viem/blob/a49c100a0b2878fbfd9f1c9b43c5cc25de241754/src/types/utils.ts#L165}
*/
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export type ResolverQueryAddr = {
args:
| readonly [nodeHash: `0x${string}`]
| readonly [nodeHash: `0x${string}`, coinType: bigint];
functionName: "addr";
};
export type ResolverQueryText = {
args: readonly [nodeHash: `0x${string}`, key: string];
functionName: "text";
};
export type ResolverQueryContentHash = {
args: readonly [nodeHash: `0x${string}`];
functionName: "contenthash";
};
export type ResolverQuery = Prettify<
ResolverQueryAddr | ResolverQueryText | ResolverQueryContentHash
>;
type DecodedRequestFullReturnType = Prettify<{
/**
* Text value of the ENS name to be resolved.
*/
name: string;
/**
* Query to be resolved. Contains a `functionName` denoting the interface name,
* and `args` containing the necessary arguments. `functionName` can be one of:
*
* * `addr`: Address resolution query
*
* Follows either [Contract Address Interface](https://docs.ens.domains/ensip/1)
* or [ENSIP-9 Multichain Addresses interface](https://docs.ens.domains/ensip/9).
* The resolver must return a [correctly-encoded](https://docs.ens.domains/ensip/9#address-encoding)
* address for the specified [`coinType`](https://docs.ens.domains/web/resolution#multi-chain),
* or an empty string if there is no matching value.
*
* * `text`: Text resolution query
*
* Follows [Text Records interface](https://docs.ens.domains/ensip/11).
* Common keys include `description`, `url`, `avatar`, `org.telegram`, etc.
* The resolver must return a UTF-8 encoded string for the specified key,
* or an empty string if there is no matching value.
*
* * `contenthash`: Content hash query
*
* Follows [Content Hash interface](https://docs.ens.domains/ensip/7).
*/
query: Prettify<ResolverQuery>;
}>;
// Taken from ensjs https://github.com/ensdomains/ensjs/blob/main/packages/ensjs/src/utils/hexEncodedName.ts
function bytesToPacket(bytes: ByteArray): string {
let offset = 0;
let result = "";
while (offset < bytes.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- sad.
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);
}
/**
* ABI for `OffchainResolver.sol`
* @see https://github.com/ensdomains/offchain-resolver/blob/efb7c02eb8f8fc02a222cd055f9c055919b7a5ae/packages/contracts/contracts/OffchainResolver.sol#L41-L51
*/
const OFFCHAIN_RESOLVER_RESOLVE_SIGNATURE = parseAbiItem(
"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)",
]);
/**
* Decodes CCIP request parameters into an ENS name and a resolver query.
*/
export function decodeEnsOffchainRequest({
data,
}: {
sender: `0x${string}`;
data: `0x${string}`;
}): DecodedRequestFullReturnType {
const decodedResolveCall = decodeFunctionData({
abi: [OFFCHAIN_RESOLVER_RESOLVE_SIGNATURE],
data,
});
const [dnsEncodedName, encodedResolveCall] = decodedResolveCall.args;
const name = dnsDecodeName(dnsEncodedName);
const query = decodeFunctionData({
abi: RESOLVER_ABI,
data: encodedResolveCall,
});
// TODO: handle AbiFunctionSignatureNotFoundError coming from `decodeFunctionData` if the resolver call is invalid/unsupported
// e.g.:
// if (err instanceof AbiFunctionSignatureNotFoundError) {
// throw new InvalidResolverCallError("Unrecognized resolver signature", {
// cause: err,
// });
// }
return {
name,
query,
};
}
/**
* Encodes and signs a result as a CCIP response.
*/
export async function encodeEnsOffchainResponse(
request: { sender: `0x${string}`; data: `0x${string}` },
response: { result: string; validUntil: number },
signerPrivateKey: Hex,
): Promise<Hex> {
const { sender, data } = request;
const { result, validUntil } = response;
const { query } = decodeEnsOffchainRequest({ sender, data });
// Encode the resolver function result as it would be returned by the contract
const functionResult = encodeFunctionResult({
abi: RESOLVER_ABI,
functionName: query.functionName,
result,
});
// Prepare the message hash for use in `verify()`, see `makeSignatureHash()` in `SignatureVerifier.sol`
// https://github.com/ensdomains/offchain-resolver/blob/efb7c02eb8f8fc02a222cd055f9c055919b7a5ae/packages/contracts/contracts/SignatureVerifier.sol#L8-L16
const messageHash = keccak256(
encodePacked(
["bytes", "address", "uint64", "bytes32", "bytes32"],
[
"0x1900", // This is hardcoded in the contract but idk why
sender, // target: The address the signature is for.
BigInt(validUntil), // expires: The timestamp at which the response becomes invalid.
keccak256(data), // request: The original request that was sent.
keccak256(functionResult), // result: The `result` field of the response (not including the signature part).
],
),
);
// Sign and encode the response, see `verify()` in `SignatureVerifier.sol`
// https://github.com/ensdomains/offchain-resolver/blob/efb7c02eb8f8fc02a222cd055f9c055919b7a5ae/packages/contracts/contracts/SignatureVerifier.sol#L18-L34
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 { type NextRequest } from "next/server";
import { isAddress, isHex } from "viem";
import { z } from "zod";
import { env } from "~/env";
import {
decodeEnsOffchainRequest,
encodeEnsOffchainResponse,
type ResolverQuery,
} from "./ensOffchainHelpers";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
const REQUEST_SCHEMA = z.object({
sender: z
.string()
.refine((data) => isAddress(data), { message: "Sender is not an address" }),
data: z
.string()
.refine((data) => isHex(data), { message: "Data is not valid hex" }),
});
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sender: string; data: string }> },
) {
const parsedParams = REQUEST_SCHEMA.safeParse(await params);
if (!parsedParams.success) {
return Response.json(
{ message: parsedParams.error.message },
{ status: 400, headers: { ...CORS_HEADERS } },
);
}
const { sender, data } = parsedParams.data;
const { name, query } = decodeEnsOffchainRequest({ sender, data });
let result;
let validUntil;
// TODO: Perform lookup, fill result and validUntil
return Response.json(
{
data: await encodeEnsOffchainResponse(
{ sender, data },
{ result, validUntil },
env.ENS_CCIP_DATA_SIGNER_KEY,
),
},
{
status: 200,
headers: { ...CORS_HEADERS },
},
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment