diff --git a/.changeset/wise-hornets-destroy.md b/.changeset/wise-hornets-destroy.md new file mode 100644 index 00000000000..21fd9aee6f4 --- /dev/null +++ b/.changeset/wise-hornets-destroy.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Adds L2 ENS name resolution diff --git a/packages/thirdweb/scripts/generate/abis/ens/L2Resolver.json b/packages/thirdweb/scripts/generate/abis/ens/L2Resolver.json new file mode 100644 index 00000000000..5e526888e3f --- /dev/null +++ b/packages/thirdweb/scripts/generate/abis/ens/L2Resolver.json @@ -0,0 +1,3 @@ +[ + "function name(bytes32 node) view returns (string memory)" +] \ No newline at end of file diff --git a/packages/thirdweb/src/exports/extensions/ens.ts b/packages/thirdweb/src/exports/extensions/ens.ts index 0ffc1a1296f..08b4d4ebdb5 100644 --- a/packages/thirdweb/src/exports/extensions/ens.ts +++ b/packages/thirdweb/src/exports/extensions/ens.ts @@ -17,3 +17,13 @@ export { type ResolveNameOptions, resolveName, } from "../../extensions/ens/resolve-name.js"; + +export { + type ResolveL2NameOptions, + resolveL2Name, +} from "../../extensions/ens/resolve-l2-name.js"; + +export { + BASENAME_RESOLVER_ADDRESS, + BASE_SEPOLIA_BASENAME_RESOLVER_ADDRESS, +} from "../../extensions/ens/constants.js"; diff --git a/packages/thirdweb/src/extensions/ens/__generated__/L2Resolver/read/name.ts b/packages/thirdweb/src/extensions/ens/__generated__/L2Resolver/read/name.ts new file mode 100644 index 00000000000..d28a781eff3 --- /dev/null +++ b/packages/thirdweb/src/extensions/ens/__generated__/L2Resolver/read/name.ts @@ -0,0 +1,122 @@ +import type { AbiParameterToPrimitiveType } from "abitype"; +import { readContract } from "../../../../../transaction/read-contract.js"; +import type { BaseTransactionOptions } from "../../../../../transaction/types.js"; +import { encodeAbiParameters } from "../../../../../utils/abi/encodeAbiParameters.js"; +import { decodeAbiParameters } from "viem"; +import type { Hex } from "../../../../../utils/encoding/hex.js"; +import { detectMethod } from "../../../../../utils/bytecode/detectExtension.js"; + +/** + * Represents the parameters for the "name" function. + */ +export type NameParams = { + node: AbiParameterToPrimitiveType<{ type: "bytes32"; name: "node" }>; +}; + +export const FN_SELECTOR = "0x691f3431" as const; +const FN_INPUTS = [ + { + type: "bytes32", + name: "node", + }, +] as const; +const FN_OUTPUTS = [ + { + type: "string", + }, +] as const; + +/** + * Checks if the `name` method is supported by the given contract. + * @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors. + * @returns A boolean indicating if the `name` method is supported. + * @extension ENS + * @example + * ```ts + * import { isNameSupported } from "thirdweb/extensions/ens"; + * + * const supported = isNameSupported(["0x..."]); + * ``` + */ +export function isNameSupported(availableSelectors: string[]) { + return detectMethod({ + availableSelectors, + method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const, + }); +} + +/** + * Encodes the parameters for the "name" function. + * @param options - The options for the name function. + * @returns The encoded ABI parameters. + * @extension ENS + * @example + * ```ts + * import { encodeNameParams } "thirdweb/extensions/ens"; + * const result = encodeNameParams({ + * node: ..., + * }); + * ``` + */ +export function encodeNameParams(options: NameParams) { + return encodeAbiParameters(FN_INPUTS, [options.node]); +} + +/** + * Encodes the "name" function into a Hex string with its parameters. + * @param options - The options for the name function. + * @returns The encoded hexadecimal string. + * @extension ENS + * @example + * ```ts + * import { encodeName } "thirdweb/extensions/ens"; + * const result = encodeName({ + * node: ..., + * }); + * ``` + */ +export function encodeName(options: NameParams) { + // we do a "manual" concat here to avoid the overhead of the "concatHex" function + // we can do this because we know the specific formats of the values + return (FN_SELECTOR + + encodeNameParams(options).slice(2)) as `${typeof FN_SELECTOR}${string}`; +} + +/** + * Decodes the result of the name function call. + * @param result - The hexadecimal result to decode. + * @returns The decoded result as per the FN_OUTPUTS definition. + * @extension ENS + * @example + * ```ts + * import { decodeNameResult } from "thirdweb/extensions/ens"; + * const result = decodeNameResult("..."); + * ``` + */ +export function decodeNameResult(result: Hex) { + return decodeAbiParameters(FN_OUTPUTS, result)[0]; +} + +/** + * Calls the "name" function on the contract. + * @param options - The options for the name function. + * @returns The parsed result of the function call. + * @extension ENS + * @example + * ```ts + * import { name } from "thirdweb/extensions/ens"; + * + * const result = await name({ + * contract, + * node: ..., + * }); + * + * ``` + */ +export async function name(options: BaseTransactionOptions) { + return readContract({ + contract: options.contract, + method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const, + params: [options.node], + }); +} diff --git a/packages/thirdweb/src/extensions/ens/constants.ts b/packages/thirdweb/src/extensions/ens/constants.ts index 8a57d1b2ce7..f900fedbc86 100644 --- a/packages/thirdweb/src/extensions/ens/constants.ts +++ b/packages/thirdweb/src/extensions/ens/constants.ts @@ -1,2 +1,6 @@ export const UNIVERSAL_RESOLVER_ADDRESS = "0xce01f8eee7E479C928F8919abD53E553a36CeF67"; +export const BASENAME_RESOLVER_ADDRESS = + "0xC6d566A56A1aFf6508b41f6c90ff131615583BCD"; +export const BASE_SEPOLIA_BASENAME_RESOLVER_ADDRESS = + "0x6533C94869D28fAA8dF77cc63f9e2b2D6Cf77eBA"; diff --git a/packages/thirdweb/src/extensions/ens/resolve-address.test.ts b/packages/thirdweb/src/extensions/ens/resolve-address.test.ts index 66995c82d01..90cbb709fe4 100644 --- a/packages/thirdweb/src/extensions/ens/resolve-address.test.ts +++ b/packages/thirdweb/src/extensions/ens/resolve-address.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { TEST_CLIENT } from "../../../test/src/test-clients.js"; +import { base } from "../../chains/chain-definitions/base.js"; +import { BASENAME_RESOLVER_ADDRESS } from "./constants.js"; import { resolveAddress } from "./resolve-address.js"; // skip this test suite if there is no secret key available to test with @@ -22,4 +24,15 @@ describe.runIf(process.env.TW_SECRET_KEY)("ENS:resolve-address", () => { }); expect(address).toBe("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); }); + + it("should resolve Basename", async () => { + const name = "myk.base.eth"; + const address = await resolveAddress({ + client: TEST_CLIENT, + name, + resolverChain: base, + resolverAddress: BASENAME_RESOLVER_ADDRESS, + }); + expect(address).toBe("0x653Ff253b0c7C1cc52f484e891b71f9f1F010Bfb"); + }); }); diff --git a/packages/thirdweb/src/extensions/ens/resolve-address.ts b/packages/thirdweb/src/extensions/ens/resolve-address.ts index 5ae72527828..37975a8697c 100644 --- a/packages/thirdweb/src/extensions/ens/resolve-address.ts +++ b/packages/thirdweb/src/extensions/ens/resolve-address.ts @@ -32,6 +32,18 @@ export type ResolveAddressOptions = { * name: "vitalik.eth", * }); * ``` + * + * Resolve an address to a Basename. + * ```ts + * import { resolveAddress, BASENAME_RESOLVER_ADDRESS } from "thirdweb/extensions/ens"; + * import { base } from "thirdweb/chains"; + * const address = await resolveAddress({ + * client, + * name: "myk.base.eth", + * resolverAddress: BASENAME_RESOLVER_ADDRESS, + * resolverChain: base, + * }); + * ``` * @extension ENS * @returns A promise that resolves to the Ethereum address. */ @@ -58,7 +70,7 @@ export async function resolveAddress(options: ResolveAddressOptions) { return resolvedAddress; }, { - cacheKey: `ens:addr:${name}`, + cacheKey: `ens:addr:${resolverChain?.id || 1}:${name}`, // 1min cache cacheTime: 60 * 1000, }, diff --git a/packages/thirdweb/src/extensions/ens/resolve-l2-name.test.ts b/packages/thirdweb/src/extensions/ens/resolve-l2-name.test.ts new file mode 100644 index 00000000000..5875e599b4a --- /dev/null +++ b/packages/thirdweb/src/extensions/ens/resolve-l2-name.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "../../../test/src/test-clients.js"; +import { base } from "../../chains/chain-definitions/base.js"; +import { BASENAME_RESOLVER_ADDRESS } from "./constants.js"; +import { resolveL2Name } from "./resolve-l2-name.js"; + +// skip this test suite if there is no secret key available to test with +// TODO: remove reliance on secret key during unit tests entirely +describe.runIf(process.env.TW_SECRET_KEY)("ENS:resolve-l2-name", () => { + it("should resolve Basename", async () => { + const ens = await resolveL2Name({ + client: TEST_CLIENT, + // myk.base.eth + address: "0x653Ff253b0c7C1cc52f484e891b71f9f1F010Bfb", + resolverChain: base, + resolverAddress: BASENAME_RESOLVER_ADDRESS, + }); + expect(ens).toBe("myk.base.eth"); + }); + + it("should return null if no Basename exists for the address", async () => { + const ens = await resolveL2Name({ + client: TEST_CLIENT, + address: "0xc6248746A9CA5935ae722E2061347A5897548c03", + resolverChain: base, + resolverAddress: BASENAME_RESOLVER_ADDRESS, + }); + expect(ens).toBeNull(); + }); +}); diff --git a/packages/thirdweb/src/extensions/ens/resolve-l2-name.ts b/packages/thirdweb/src/extensions/ens/resolve-l2-name.ts new file mode 100644 index 00000000000..c3691440098 --- /dev/null +++ b/packages/thirdweb/src/extensions/ens/resolve-l2-name.ts @@ -0,0 +1,107 @@ +import type { Address } from "abitype"; +import { type Hex, encodePacked, keccak256, namehash } from "viem"; +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { getContract } from "../../contract/contract.js"; +import { withCache } from "../../utils/promise/withCache.js"; +import { name } from "./__generated__/L2Resolver/read/name.js"; + +/** + * @extension ENS + */ +export type ResolveL2NameOptions = { + client: ThirdwebClient; + address: Address; + resolverAddress: string; + resolverChain: Chain; +}; + +/** + * Convert an address to a reverse node for ENS resolution + * + * @internal + */ +export const convertReverseNodeToBytes = ( + address: Address, + chainId: number, +) => { + const addressFormatted = address.toLocaleLowerCase() as Address; + const addressNode = keccak256(addressFormatted.substring(2) as Hex); + const cointype = (0x80000000 | chainId) >>> 0; + + const chainCoinType = cointype.toString(16).toLocaleUpperCase(); + const reverseNode = namehash(`${chainCoinType.toLocaleUpperCase()}.reverse`); + + const addressReverseNode = keccak256( + encodePacked(["bytes32", "bytes32"], [reverseNode, addressNode]), + ); + return addressReverseNode; +}; + +/** + * Resolves the L2 name for a specified address. + * @param options - The options for resolving an L2 ENS address. + * @example + * ```ts + * import { resolveL2Name } from "thirdweb/extensions/ens"; + * const name = await resolveL2Name({ + * client, + * address: "0x1234...", + * resolverAddress: "0x...", + * resolverChain: base, + * }); + * ``` + * + * Resolve a Basename. + * ```ts + * import { resolveL2Name, BASENAME_RESOLVER_ADDRESS } from "thirdweb/extensions/ens"; + * import { base } from "thirdweb/chains"; + * const name = await resolveL2Name({ + * client, + * address: "0x1234...", + * resolverAddress: BASENAME_RESOLVER_ADDRESS, + * resolverChain: base, + * }); + * ``` + * @extension ENS + * @returns A promise that resolves to the Ethereum address. + */ +export async function resolveL2Name(options: ResolveL2NameOptions) { + const { client, address, resolverAddress, resolverChain } = options; + + return withCache( + async () => { + const contract = getContract({ + client, + chain: resolverChain, + address: resolverAddress, + }); + + const reverseName = convertReverseNodeToBytes( + address, + resolverChain.id || 1, + ); + + const resolvedName = await name({ + contract, + node: reverseName, + }).catch((e) => { + if ("data" in e && e.data === "0x7199966d") { + return null; + } + throw e; + }); + + if (resolvedName === "") { + return null; + } + + return resolvedName; + }, + { + cacheKey: `ens:name:${resolverChain}:${address}`, + // 1min cache + cacheTime: 60 * 1000, + }, + ); +} diff --git a/packages/thirdweb/src/extensions/ens/resolve-name.ts b/packages/thirdweb/src/extensions/ens/resolve-name.ts index 902d5e895fc..c37f98ef786 100644 --- a/packages/thirdweb/src/extensions/ens/resolve-name.ts +++ b/packages/thirdweb/src/extensions/ens/resolve-name.ts @@ -65,7 +65,7 @@ export async function resolveName(options: ResolveNameOptions) { return name; }, { - cacheKey: `ens:name:${address}`, + cacheKey: `ens:name:${resolverChain?.id || 1}:${address}`, // 1min cache cacheTime: 60 * 1000, },