Last active
November 22, 2024 20:45
-
-
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.
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
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; | |
} |
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
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