diff --git a/components/molecules/registration/NameRegisteredAwaitingRecordsSettingComponent.tsx b/components/molecules/registration/NameRegisteredAwaitingRecordsSettingComponent.tsx index 22cb53d..9ac97e9 100644 --- a/components/molecules/registration/NameRegisteredAwaitingRecordsSettingComponent.tsx +++ b/components/molecules/registration/NameRegisteredAwaitingRecordsSettingComponent.tsx @@ -1,5 +1,5 @@ import { BackButton, BlockchainCTA } from "@/components/atoms"; -import { setDomainRecords } from "@/lib/utils/blockchain-txs"; +import { setDomainRecords } from "@/ens-sdk"; import { useNameRegistration } from "@/lib/name-registration/useNameRegistration"; import { TransactionErrorType, diff --git a/components/molecules/registration/NameSecuredToBeRegisteredComponent.tsx b/components/molecules/registration/NameSecuredToBeRegisteredComponent.tsx index fed691a..c2bda0f 100644 --- a/components/molecules/registration/NameSecuredToBeRegisteredComponent.tsx +++ b/components/molecules/registration/NameSecuredToBeRegisteredComponent.tsx @@ -1,5 +1,5 @@ import { BackButton, BlockchainCTA } from "@/components/atoms"; -import { register } from "@/lib/utils/blockchain-txs"; +import { register } from "@/ens-sdk"; import { useNameRegistration } from "@/lib/name-registration/useNameRegistration"; import { TransactionErrorType } from "@/lib/wallet/txError"; import { PublicClient, TransactionReceipt } from "viem"; diff --git a/components/molecules/registration/RecordsSetAwaitingPrimaryNameSetting.tsx b/components/molecules/registration/RecordsSetAwaitingPrimaryNameSetting.tsx index b41efdc..082282a 100644 --- a/components/molecules/registration/RecordsSetAwaitingPrimaryNameSetting.tsx +++ b/components/molecules/registration/RecordsSetAwaitingPrimaryNameSetting.tsx @@ -1,5 +1,5 @@ import { BackButton, BlockchainCTA } from "@/components/atoms"; -import { setDomainAsPrimaryName } from "@/lib/utils/blockchain-txs"; +import { setDomainAsPrimaryName } from "@/ens-sdk"; import { useNameRegistration } from "@/lib/name-registration/useNameRegistration"; import { TransactionErrorType, diff --git a/components/molecules/registration/RegistrationSummary.tsx b/components/molecules/registration/RegistrationSummary.tsx index 5eb6fb7..68e78a0 100644 --- a/components/molecules/registration/RegistrationSummary.tsx +++ b/components/molecules/registration/RegistrationSummary.tsx @@ -7,15 +7,16 @@ import { EthIcon, InfoCircleIcon, } from "@/components/atoms"; -import { - getGasPrice, - getNamePrice, - getNameRegistrationGasEstimate, -} from "@/lib/utils/blockchain-txs"; + import { useEffect, useState } from "react"; import { formatEther, PublicClient } from "viem"; import { usePublicClient } from "wagmi"; import { ClientWithEns } from "@ensdomains/ensjs/dist/types/contracts/consts"; +import { + getGasPrice, + getNamePrice, + getNameRegistrationGasEstimate, +} from "@/ens-sdk"; export const RegistrationSummary = () => { const { diff --git a/components/molecules/registration/RequestToRegisterComponent.tsx b/components/molecules/registration/RequestToRegisterComponent.tsx index 361ec56..ad333d3 100644 --- a/components/molecules/registration/RequestToRegisterComponent.tsx +++ b/components/molecules/registration/RequestToRegisterComponent.tsx @@ -1,16 +1,16 @@ -import { Button, WalletSVG } from "@ensdomains/thorin"; +import { Button } from "@ensdomains/thorin"; import { BackButton, BlockchainCTA, TransactionConfirmedInBlockchainCTA, } from "@/components/atoms"; -import { commit } from "@/lib/utils/blockchain-txs"; import { useNameRegistration } from "@/lib/name-registration/useNameRegistration"; import { useAccount, useBalance, usePublicClient } from "wagmi"; import { TransactionErrorType } from "@/lib/wallet/txError"; import { setNameRegistrationInLocalStorage } from "@/lib/name-registration/localStorage"; import { PublicClient } from "viem"; import { ClientWithEns } from "@ensdomains/ensjs/dist/types/contracts/consts"; +import { commit } from "@/ens-sdk"; interface RequestToRegisterComponentProps { handlePreviousStep: () => void; diff --git a/components/organisms/CreateSubdomainModalContent.tsx b/components/organisms/CreateSubdomainModalContent.tsx index d4a1421..3afcce1 100644 --- a/components/organisms/CreateSubdomainModalContent.tsx +++ b/components/organisms/CreateSubdomainModalContent.tsx @@ -1,4 +1,4 @@ -import { createSubdomain } from "@/lib/create-subdomain/service"; +import { createSubdomain } from "@/ens-sdk"; import { Button, Input, Spinner } from "@ensdomains/thorin"; import { buildENSName } from "@namehash/ens-utils"; import { normalize } from "viem/ens"; diff --git a/components/organisms/EditModalContent.tsx b/components/organisms/EditModalContent.tsx index 003b4ea..cf2ba65 100644 --- a/components/organisms/EditModalContent.tsx +++ b/components/organisms/EditModalContent.tsx @@ -26,7 +26,7 @@ import { TransactionErrorType, getBlockchainTransactionError, } from "@/lib/wallet/txError"; -import { setDomainRecords } from "@/lib/utils/blockchain-txs"; +import { setDomainRecords } from "@/ens-sdk"; import { buildENSName } from "@namehash/ens-utils"; import { getResolver } from "@ensdomains/ensjs/public"; import { useAccount, usePublicClient } from "wagmi"; diff --git a/ens-sdk/ensOperations/commit.ts b/ens-sdk/ensOperations/commit.ts new file mode 100644 index 0000000..fa4156a --- /dev/null +++ b/ens-sdk/ensOperations/commit.ts @@ -0,0 +1,115 @@ +import ETHRegistrarABI from "@/lib/abi/eth-registrar.json"; +import { + DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, + nameRegistrationSmartContracts, +} from "../../lib/name-registration/constants"; + +import { + publicActions, + createWalletClient, + custom, + Address, + Chain, +} from "viem"; +import { SupportedNetwork } from "../../lib/wallet/chains"; +import { ENSName } from "@namehash/ens-utils"; +import { + TransactionErrorType, + getBlockchainTransactionError, +} from "../../lib/wallet/txError"; +import { getNameRegistrationSecret } from "@/lib/name-registration/localStorage"; +import { parseAccount } from "viem/utils"; + +import { EnsPublicClient } from "../types"; +import { makeCommitment } from "./makeCommitment"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/* + 1st step of a name registration +*/ + +interface CommitParams { + ensName: ENSName; + durationInYears: bigint; + resolverAddress: Address; + authenticatedAddress: Address; + registerAndSetAsPrimaryName: boolean; + publicClient: EnsPublicClient; + chain: Chain; +} + +/** + * Commits to registering an ENS name + * + * This is the first step in the two-step ENS registration process. It creates and submits + * a commitment to the blockchain, which must be followed by the actual registration after + * a waiting period. + * + * @param {CommitParams} params - The parameters for the commit operation + * @returns {Promise<`0x${string}` | TransactionErrorType>} - The transaction hash or an error + */ +export const commit = async ({ + ensName, + durationInYears, + resolverAddress, + authenticatedAddress, + registerAndSetAsPrimaryName, + publicClient, + chain, +}: CommitParams): Promise<`0x${string}` | TransactionErrorType> => { + try { + const walletClient = createWalletClient({ + account: authenticatedAddress, + chain: chain, + transport: custom(window.ethereum), + }); + + const client = walletClient.extend(publicActions); + + if (!client) throw new Error("WalletClient not found"); + + const nameWithoutTLD = ensName.name.replace(".eth", ""); + + const commitmentWithConfigHash = await makeCommitment({ + name: nameWithoutTLD, + data: [], + authenticatedAddress, + durationInYears: durationInYears, + secret: getNameRegistrationSecret(), + reverseRecord: registerAndSetAsPrimaryName, + resolverAddress: resolverAddress, + ownerControlledFuses: DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, + publicClient: publicClient, + }); + + if (!Object.values(SupportedNetwork).includes(chain.id)) { + throw new Error(`Unsupported network: ${chain.id}`); + } + + const nameRegistrationContracts = + nameRegistrationSmartContracts[chain.id as SupportedNetwork]; + + const { request } = await client.simulateContract({ + account: parseAccount(authenticatedAddress), + address: nameRegistrationContracts.ETH_REGISTRAR, + args: [commitmentWithConfigHash], + functionName: "commit", + abi: ETHRegistrarABI, + gas: 70000n, + }); + + const txHash = await client.writeContract(request); + + return txHash; + } catch (error) { + console.error(error); + const errorType = getBlockchainTransactionError(error); + return errorType; + } +}; diff --git a/lib/create-subdomain/service.ts b/ens-sdk/ensOperations/createSubdomain.ts similarity index 75% rename from lib/create-subdomain/service.ts rename to ens-sdk/ensOperations/createSubdomain.ts index 6532c8d..9ea15ec 100644 --- a/lib/create-subdomain/service.ts +++ b/ens-sdk/ensOperations/createSubdomain.ts @@ -3,7 +3,6 @@ import { Chain, createWalletClient, custom, - defineChain, encodeFunctionData, fromBytes, Hash, @@ -11,21 +10,21 @@ import { keccak256, namehash, publicActions, - PublicClient, stringToHex, toHex, } from "viem"; -import { getRevertErrorData, handleDBStorage } from "../utils/blockchain-txs"; -import { DomainData, MessageData } from "../utils/types"; -import L1ResolverABI from "../abi/arbitrum-resolver.json"; +import { EnsPublicClient, storeDataInDb } from "@/ens-sdk"; +import { DomainData, MessageData } from "@/lib/utils/types"; +import L1ResolverABI from "../../lib/abi/arbitrum-resolver.json"; import toast from "react-hot-toast"; import { getCoderByCoinName } from "@ensdomains/address-encoder"; -import { ClientWithEns } from "@ensdomains/ensjs/dist/types/contracts/consts"; import * as chains from "viem/chains"; import { packetToBytes } from "viem/ens"; import { SECONDS_PER_YEAR } from "@namehash/ens-utils"; -import { getNameRegistrationSecret } from "../name-registration/localStorage"; -import { DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES } from "../name-registration/constants"; +import { getNameRegistrationSecret } from "../../lib/name-registration/localStorage"; +import { DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES } from "../../lib/name-registration/constants"; +import { getChain } from "../utils"; +import { getRevertErrorData } from "../utils/getRevertErrorData"; interface CreateSubdomainArgs { resolverAddress: Address; @@ -34,11 +33,27 @@ interface CreateSubdomainArgs { address: string; website: string; description: string; - client: PublicClient & ClientWithEns; + client: EnsPublicClient; chain: Chain; } -// TO-DO: Fix function later to accept more text / address params +/** + * Creates a subdomain with associated records (website, description, and address) for an ENS name. + * This function is specifically designed to handle offchain and L2 domains. + * + * The function performs the following steps: + * 1. Prepares call data for setting text records (website and description) if provided. + * 2. Prepares call data for setting the address record if provided. + * 3. Calculates the registration fee and retrieves necessary parameters from the resolver contract. + * 4. Attempts to simulate the contract call for subdomain creation. + * 5. Handles two specific scenarios based on the simulation result: + * - If storage is handled by an off-chain database, it processes the request accordingly. + * - If storage is handled by an L2 network, it switches to the appropriate network, + * performs the transaction, and switches back to the original network. + * + * Note: This function does not support standard L1 ENS registrations. It is specifically + * tailored for offchain storage solutions and L2 network integrations. + */ export const createSubdomain = async ({ resolverAddress, signerAddress, @@ -142,7 +157,7 @@ export const createSubdomain = async ({ MessageData, ]; - const response = await handleDBStorage({ + const response = await storeDataInDb({ domain, url, message, @@ -192,36 +207,8 @@ export const createSubdomain = async ({ return { ok: true }; } else { - toast.error("error"); + toast.error("Unsupported domain type"); console.error("writing failed: ", { error }); } } }; - -export function getChain(chainId: number) { - return [ - ...Object.values(chains), - defineChain({ - id: Number(chainId), - name: "Arbitrum Local", - nativeCurrency: { - name: "Arbitrum Sepolia Ether", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: { - default: { - http: [ - `https://arb-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_TESTNET_KEY}`, - ], - }, - }, - }), - ].find((chain) => chain.id === chainId); -} - -// gather the first part of the domain (e.g. floripa.blockful.eth -> floripa, floripa.normal.blockful.eth -> floripa.normal) -const extractLabelFromName = (name: string): string => { - const [, label] = /^(.+?)\.\w+\.\w+$/.exec(name) || []; - return label; -}; diff --git a/ens-sdk/ensOperations/index.ts b/ens-sdk/ensOperations/index.ts new file mode 100644 index 0000000..01b7bd8 --- /dev/null +++ b/ens-sdk/ensOperations/index.ts @@ -0,0 +1,8 @@ +export * from "./setDomainAsPrimaryName"; +export * from "./setDomainRecords"; +export * from "./storeDataInDb"; +export * from "./commit"; +export * from "./makeCommitment"; +export * from "./register"; +export * from "./sendCcipRequest"; +export * from "./createSubdomain"; diff --git a/ens-sdk/ensOperations/makeCommitment.ts b/ens-sdk/ensOperations/makeCommitment.ts new file mode 100644 index 0000000..4772bf3 --- /dev/null +++ b/ens-sdk/ensOperations/makeCommitment.ts @@ -0,0 +1,95 @@ +/* eslint-disable import/no-named-as-default */ +/* eslint-disable import/named */ +import ETHRegistrarABI from "@/lib/abi/eth-registrar.json"; +import { nameRegistrationSmartContracts } from "../../lib/name-registration/constants"; + +import { Address } from "viem"; +import { SupportedNetwork } from "../../lib/wallet/chains"; +import { SECONDS_PER_YEAR } from "@namehash/ens-utils"; +import { getBlockchainTransactionError } from "../../lib/wallet/txError"; +import { parseAccount } from "viem/utils"; + +import { EnsPublicClient } from "../types"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/* + The commitment value is used in both 'commit' and 'register' + functions in the registrar contract. It acts as a secret + to prevent frontrunning attacks. The commitment value must + be the same in both function calls, which is why we store + it in local storage. +*/ + +interface MakeCommitmentParams { + name: string; + data: string[]; + secret: string; + reverseRecord: boolean; + resolverAddress: Address; + durationInYears: bigint; + ownerControlledFuses: number; + authenticatedAddress: Address; + publicClient: EnsPublicClient; +} + +/** + * Generates a commitment for ENS name registration + * + * This function creates a commitment hash that is used in the two-step ENS registration process. + * It helps prevent front-running attacks by keeping the details of the registration secret until + * the actual registration takes place. + * + * @param {MakeCommitmentParams} params - The parameters required for making the commitment + * @returns {Promise} - The generated commitment hash or an error + */ +export async function makeCommitment({ + name, + data, + secret, + reverseRecord, + resolverAddress, + durationInYears, + ownerControlledFuses, + authenticatedAddress, + publicClient, +}: MakeCommitmentParams) { + const chain = publicClient.chain; + + if (!Object.values(SupportedNetwork).includes(chain.id)) { + throw new Error(`Unsupported network: ${chain.id}`); + } + + const nameRegistrationContracts = + nameRegistrationSmartContracts[chain.id as SupportedNetwork]; + + return publicClient + .readContract({ + account: parseAccount(authenticatedAddress), + address: nameRegistrationContracts.ETH_REGISTRAR, + abi: ETHRegistrarABI, + args: [ + name, + authenticatedAddress, + durationInYears * SECONDS_PER_YEAR.seconds, + secret, + resolverAddress, + data, + reverseRecord, + ownerControlledFuses, + ], + functionName: "makeCommitment", + }) + .then((generatedReservationNumber) => { + return generatedReservationNumber; + }) + .catch((error) => { + const errorType = getBlockchainTransactionError(error); + return errorType || error; + }); +} diff --git a/ens-sdk/ensOperations/register.ts b/ens-sdk/ensOperations/register.ts new file mode 100644 index 0000000..2f2fe35 --- /dev/null +++ b/ens-sdk/ensOperations/register.ts @@ -0,0 +1,144 @@ +import ETHRegistrarABI from "@/lib/abi/eth-registrar.json"; +import { + DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, + nameRegistrationSmartContracts, +} from "../../lib/name-registration/constants"; + +import { + publicActions, + createWalletClient, + custom, + Address, + Chain, +} from "viem"; +import { SupportedNetwork } from "../../lib/wallet/chains"; +import { SECONDS_PER_YEAR, ENSName } from "@namehash/ens-utils"; +import { + TransactionErrorType, + getBlockchainTransactionError, +} from "../../lib/wallet/txError"; +import { getNameRegistrationSecret } from "@/lib/name-registration/localStorage"; + +import { DomainData, MessageData } from "../../lib/utils/types"; + +import { getNamePrice, getChain } from "../utils"; +import { getRevertErrorData } from "../utils/getRevertErrorData"; +import { EnsPublicClient } from "../types"; +import { storeDataInDb } from "./storeDataInDb"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/* + 2nd step of a name registration +*/ + +interface RegisterParams { + ensName: ENSName; + resolverAddress: Address; + durationInYears: bigint; + authenticatedAddress: Address; + registerAndSetAsPrimaryName: boolean; + publicClient: EnsPublicClient; + chain: Chain; +} + +/** + * Registers an ENS name + * + * This is the second step in the two-step ENS registration process. It finalizes the registration + * of the ENS name after the commitment has been made and the waiting period has passed. + * + * @param {RegisterParams} params - The parameters for the registration + * @returns {Promise<`0x${string}` | TransactionErrorType>} - The transaction hash or an error + */ +export const register = async ({ + ensName, + resolverAddress, + durationInYears, + authenticatedAddress, + registerAndSetAsPrimaryName, + publicClient, + chain, +}: RegisterParams): Promise<`0x${string}` | TransactionErrorType> => { + try { + const walletClient = createWalletClient({ + account: authenticatedAddress, + chain: chain, + transport: custom(window.ethereum), + }); + + const client = walletClient.extend(publicActions); + + if (!client) throw new Error("WalletClient not found"); + + const nameWithoutTLD = ensName.name.replace(".eth", ""); + + const namePrice = await getNamePrice({ + ensName, + durationInYears, + publicClient, + }); + + if (!Object.values(SupportedNetwork).includes(chain.id)) { + throw new Error(`Unsupported network: ${chain.id}`); + } + + const nameRegistrationContracts = + nameRegistrationSmartContracts[chain.id as SupportedNetwork]; + + const txHash = await client.writeContract({ + address: nameRegistrationContracts.ETH_REGISTRAR, + chain: chain, + account: authenticatedAddress, + args: [ + nameWithoutTLD, + authenticatedAddress, + durationInYears * SECONDS_PER_YEAR.seconds, + getNameRegistrationSecret(), + resolverAddress, + [], + registerAndSetAsPrimaryName, + DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, + ], + value: namePrice, + abi: ETHRegistrarABI, + functionName: "register", + gas: 500000n, + }); + + return txHash; + } catch (error: unknown) { + const data = getRevertErrorData(error); + + if (data?.errorName === "StorageHandledByOffChainDatabase") { + const [domain, url, message] = data.args as [ + DomainData, + string, + MessageData, + ]; + + const signedData = await storeDataInDb({ + domain, + url, + message, + authenticatedAddress, + chain: chain, + }); + + if (typeof signedData === "string") { + return signedData as `0x${string}`; + } else { + throw new Error("Error handling off-chain storage"); + } + } else { + console.error(error); + const errorType = getBlockchainTransactionError(error); + return errorType; + } + } +}; diff --git a/ens-sdk/ensOperations/sendCcipRequest.ts b/ens-sdk/ensOperations/sendCcipRequest.ts new file mode 100644 index 0000000..9ad9a96 --- /dev/null +++ b/ens-sdk/ensOperations/sendCcipRequest.ts @@ -0,0 +1,38 @@ +import { Address, Hex } from "viem"; +import { TypedSignature } from "../../lib/utils/types"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +type CcipRequestParameters = { + body: { data: Hex; signature: TypedSignature; sender: Address }; + url: string; +}; + +/** + * Sends a CCIP (Cross-Chain Interoperability Protocol) request + * + * This function is used to send off-chain requests, typically for resolving ENS names + * or storing data related to ENS operations. + * + * @param {CcipRequestParameters} params - The parameters for the CCIP request + * @returns {Promise} - The response from the CCIP request + */ +export async function sendCcipRequest({ + body, + url, +}: CcipRequestParameters): Promise { + return fetch(url.replace("/{sender}/{data}.json", ""), { + body: JSON.stringify(body, (_, value) => + typeof value === "bigint" ? value.toString() : value, + ), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); +} diff --git a/ens-sdk/ensOperations/setDomainAsPrimaryName.ts b/ens-sdk/ensOperations/setDomainAsPrimaryName.ts new file mode 100644 index 0000000..6cd518f --- /dev/null +++ b/ens-sdk/ensOperations/setDomainAsPrimaryName.ts @@ -0,0 +1,86 @@ +import ENSReverseRegistrarABI from "@/lib/abi/ens-reverse-registrar.json"; +import { nameRegistrationSmartContracts } from "../../lib/name-registration/constants"; +import { publicActions, createWalletClient, custom, Chain } from "viem"; +import { SupportedNetwork } from "../../lib/wallet/chains"; +import { ENSName } from "@namehash/ens-utils"; +import { getBlockchainTransactionError } from "../../lib/wallet/txError"; + +import { normalize } from "viem/ens"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/* + 4th step of a name registration - set domain as primary name if user wants +*/ + +interface SetDomainAsPrimaryNameParams { + authenticatedAddress: `0x${string}`; + ensName: ENSName; + chain: Chain; +} + +/** + * Sets an ENS domain as the primary name for an Ethereum address + * + * This function allows a user to set their newly registered or existing ENS domain + * as the primary name associated with their Ethereum address. This is typically done + * after registering a new ENS name or when changing the primary name. + * + * @param {SetDomainAsPrimaryNameParams} params - The parameters for setting the primary name + * @returns {Promise} - A status code or an error + */ +export const setDomainAsPrimaryName = async ({ + authenticatedAddress, + ensName, + chain, +}: SetDomainAsPrimaryNameParams) => { + try { + if (!Object.values(SupportedNetwork).includes(chain.id)) { + throw new Error(`Unsupported network: ${chain.id}`); + } + + // Create a wallet client for sending transactions to the blockchain + const walletClient = createWalletClient({ + chain: chain, + transport: custom(window.ethereum), + account: authenticatedAddress, + }); + + const client = walletClient.extend(publicActions); + + if (!client) throw new Error("WalletClient not found"); + + const nameWithTLD = ensName.name.includes(".eth") + ? ensName.name + : `${ensName.name}.eth`; + + const network = chain.id as SupportedNetwork; + + const publicAddress = normalize(nameWithTLD); + + // Simulate the setName contract interaction + const { request } = await client.simulateContract({ + address: nameRegistrationSmartContracts[network].ENS_REVERSE_REGISTRAR, + account: authenticatedAddress, + abi: ENSReverseRegistrarABI, + functionName: "setName", + args: [publicAddress], + }); + + // Execute the setName transaction + const setAsPrimaryNameResult = await client.writeContract(request); + + if (!!setAsPrimaryNameResult) { + return 200; + } + } catch (error) { + console.error("writing failed: ", { error }); + const errorType = getBlockchainTransactionError(error); + return errorType; + } +}; diff --git a/ens-sdk/ensOperations/setDomainRecords.ts b/ens-sdk/ensOperations/setDomainRecords.ts new file mode 100644 index 0000000..66684a4 --- /dev/null +++ b/ens-sdk/ensOperations/setDomainRecords.ts @@ -0,0 +1,220 @@ +import L1ResolverABI from "../../lib/abi/arbitrum-resolver.json"; + +import { + namehash, + publicActions, + type WalletClient, + encodeFunctionData, + createWalletClient, + custom, + Hash, + fromBytes, + Address, + PublicClient, + Chain, + stringToHex, +} from "viem"; +import { ENSName } from "@namehash/ens-utils"; +import { getBlockchainTransactionError } from "../../lib/wallet/txError"; + +import DomainResolverABI from "../../lib/abi/offchain-resolver.json"; +import { normalize } from "viem/ens"; +import { supportedCoinTypes } from "../../lib/domain-page"; +import { + coinNameToTypeMap, + getCoderByCoinName, +} from "@ensdomains/address-encoder"; +import { DomainData, MessageData } from "../../lib/utils/types"; +import toast from "react-hot-toast"; +import { sepolia } from "viem/chains"; +import { getChain } from "../utils"; +import { getRevertErrorData } from "../utils/getRevertErrorData"; +import { storeDataInDb } from "./storeDataInDb"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/* + 3rd step of a name registration - set text records +*/ + +interface SetDomainRecordsParams { + ensName: ENSName; + resolverAddress?: Address; + domainResolverAddress?: `0x${string}`; + authenticatedAddress: Address; + textRecords: Record; + addresses: Record; + others: Record; + client: PublicClient & WalletClient; + chain: Chain; +} + +/** + * Sets various records for an ENS domain + * + * This function allows setting text records, addresses, and other records for an ENS domain. + * It can handle both on-chain and off-chain storage depending on the resolver configuration. + * + * @param {SetDomainRecordsParams} params - The parameters for setting domain records + * @returns {Promise} - A status code or an error + */ +export const setDomainRecords = async ({ + ensName, + resolverAddress, + domainResolverAddress, + authenticatedAddress, + textRecords, + addresses, + others, + client, + chain, +}: SetDomainRecordsParams) => { + try { + const publicAddress = normalize(ensName.name); + + // Prepare calls for setting various records + const calls: Hash[] = []; + + // Add calls for text records + for (let i = 0; i < Object.keys(textRecords).length; i++) { + const key = Object.keys(textRecords)[i]; + const value = textRecords[key]; + + if (value !== null && value !== undefined) { + const callData = encodeFunctionData({ + functionName: "setText", + abi: DomainResolverABI, + args: [namehash(publicAddress), key, value], + }); + + calls.push(callData); + } + } + + // Add calls for address records + for (let i = 0; i < Object.keys(addresses).length; i++) { + const [cryptocurrencyName, address] = Object.entries(addresses)[i]; + if (supportedCoinTypes.includes(cryptocurrencyName.toUpperCase())) { + console.error(`cryptocurrency ${cryptocurrencyName} not supported`); + continue; + } + + const coinType = + coinNameToTypeMap[cryptocurrencyName as keyof typeof coinNameToTypeMap]; + + const coder = getCoderByCoinName(cryptocurrencyName.toLocaleLowerCase()); + const addressEncoded = fromBytes(coder.decode(address), "hex"); + const callData = encodeFunctionData({ + functionName: "setAddr", + abi: DomainResolverABI, + args: [namehash(publicAddress), BigInt(coinType), addressEncoded], + }); + calls.push(callData); + } + + // Add calls for other records (e.g., contenthash) + for (let i = 0; i < Object.keys(others).length; i++) { + const [key, value] = Object.entries(others)[i]; + const callData = encodeFunctionData({ + functionName: "setContenthash", + abi: L1ResolverABI, + args: [namehash(publicAddress), stringToHex(value)], // value = url + }); + calls.push(callData); + } + + try { + let localResolverAddress; + + if (resolverAddress) { + localResolverAddress = resolverAddress; + } else if (domainResolverAddress) { + localResolverAddress = domainResolverAddress; + } else { + throw new Error("No domain resolver informed"); + } + + // Simulate the multicall contract interaction + await client.simulateContract({ + functionName: "multicall", + abi: L1ResolverABI, + args: [calls], + account: authenticatedAddress, + address: localResolverAddress, + }); + } catch (error) { + const data = getRevertErrorData(error); + if (data?.errorName === "StorageHandledByOffChainDatabase") { + // Handle off-chain storage + const [domain, url, message] = data.args as [ + DomainData, + string, + MessageData, + ]; + + try { + await storeDataInDb({ + domain, + url, + message, + authenticatedAddress, + chain: chain, + }); + + return 200; + } catch (error) { + console.error("writing failed: ", { error }); + const errorType = getBlockchainTransactionError(error); + return errorType; + } + } else if (data?.errorName === "StorageHandledByL2") { + // Handle L2 storage + const [chainId, contractAddress] = data.args as [bigint, `0x${string}`]; + + const selectedChain = getChain(Number(chainId)); + + if (!selectedChain) { + toast.error("error"); + return; + } + + const clientWithWallet = createWalletClient({ + chain: selectedChain, + transport: custom(window.ethereum), + }).extend(publicActions); + + await clientWithWallet.addChain({ chain: selectedChain }); + + try { + const { request } = await clientWithWallet.simulateContract({ + functionName: "multicall", + abi: L1ResolverABI, + args: [calls], + account: authenticatedAddress, + address: contractAddress, + }); + await clientWithWallet.writeContract(request); + } catch { + await clientWithWallet.switchChain({ id: sepolia.id }); + } + + await clientWithWallet.switchChain({ id: sepolia.id }); + + return 200; + } else { + console.error("writing failed: ", { error }); + const errorType = getBlockchainTransactionError(error); + return errorType; + } + } + } catch (error: unknown) { + console.error(error); + const errorType = getBlockchainTransactionError(error); + return errorType; + } +}; diff --git a/ens-sdk/ensOperations/storeDataInDb.ts b/ens-sdk/ensOperations/storeDataInDb.ts new file mode 100644 index 0000000..c04452b --- /dev/null +++ b/ens-sdk/ensOperations/storeDataInDb.ts @@ -0,0 +1,56 @@ +import { DomainData, MessageData } from "@/lib/utils/types"; +import { Chain, createWalletClient, custom } from "viem"; +import { sendCcipRequest } from "./sendCcipRequest"; + +interface StoreDataInDbParams { + domain: DomainData; + url: string; + message: MessageData; + authenticatedAddress: `0x${string}`; + chain: Chain; +} + +/** + * Stores ENS-related data in an off-chain database + * + * This function is used when certain ENS operations require off-chain storage. + * It signs the data with the user's wallet and sends it to a specified URL for storage. + * + * @param {StoreDataInDbParams} params - The parameters for storing data + * @returns {Promise} - The response from the storage request + */ +export async function storeDataInDb({ + domain, + url, + message, + authenticatedAddress, + chain, +}: StoreDataInDbParams): Promise { + const client = createWalletClient({ + account: authenticatedAddress, + chain: chain, + transport: custom(window.ethereum), + }); + + const signature = await client.signTypedData({ + domain, + message, + types: { + Message: [ + { name: "callData", type: "bytes" }, + { name: "sender", type: "address" }, + { name: "expirationTimestamp", type: "uint256" }, + ], + }, + primaryType: "Message", + }); + + return await sendCcipRequest({ + body: { + data: message.callData, + signature: { message, domain, signature }, + sender: message.sender, + }, + url, + }); +} diff --git a/ens-sdk/index.ts b/ens-sdk/index.ts new file mode 100644 index 0000000..41ed17c --- /dev/null +++ b/ens-sdk/index.ts @@ -0,0 +1,3 @@ +export * from "./ensOperations"; +export * from "./types"; +export * from "./utils"; diff --git a/ens-sdk/package.json b/ens-sdk/package.json new file mode 100644 index 0000000..cc2edd3 --- /dev/null +++ b/ens-sdk/package.json @@ -0,0 +1,11 @@ +{ + "name": "@nameful/sdk", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "" +} diff --git a/ens-sdk/types.ts b/ens-sdk/types.ts new file mode 100644 index 0000000..04f6eb2 --- /dev/null +++ b/ens-sdk/types.ts @@ -0,0 +1,9 @@ +import { ClientWithEns } from "@ensdomains/ensjs/dist/types/contracts/consts"; +import { PublicClient } from "viem"; + +export type EnsPublicClient = PublicClient & ClientWithEns; + +export interface NamePrice { + base: bigint; + premium: bigint; +} diff --git a/ens-sdk/utils/createNameRegistrationSecret.ts b/ens-sdk/utils/createNameRegistrationSecret.ts new file mode 100644 index 0000000..0dffcad --- /dev/null +++ b/ens-sdk/utils/createNameRegistrationSecret.ts @@ -0,0 +1,18 @@ +import { namehash } from "viem"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +export const createNameRegistrationSecret = (): string => { + const platformHex = namehash("blockful-ens-external-resolver").slice(2, 10); + const platformBytes = platformHex.length; + const randomHex = [...Array(64 - platformBytes)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(""); + + return "0x" + platformHex + randomHex; +}; diff --git a/ens-sdk/utils/extractLabelFromName.ts b/ens-sdk/utils/extractLabelFromName.ts new file mode 100644 index 0000000..bfbbd12 --- /dev/null +++ b/ens-sdk/utils/extractLabelFromName.ts @@ -0,0 +1,5 @@ +// gather the first part of the domain (e.g. floripa.blockful.eth -> floripa, floripa.normal.blockful.eth -> floripa.normal) +export const extractLabelFromName = (name: string): string => { + const [, label] = /^(.+?)\.\w+\.\w+$/.exec(name) || []; + return label; +}; diff --git a/ens-sdk/utils/getChain.ts b/ens-sdk/utils/getChain.ts new file mode 100644 index 0000000..a087c85 --- /dev/null +++ b/ens-sdk/utils/getChain.ts @@ -0,0 +1,32 @@ +import * as chains from "viem/chains"; + +import { defineChain } from "viem"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +export function getChain(chainId: number) { + return [ + ...Object.values(chains), + defineChain({ + id: Number(chainId), + name: "Arbitrum Local", + nativeCurrency: { + name: "Arbitrum Sepolia Ether", + symbol: "ETH", + decimals: 18, + }, + rpcUrls: { + default: { + http: [ + `https://arb-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_TESTNET_KEY}`, + ], + }, + }, + }), + ].find((chain) => chain.id === chainId); +} diff --git a/ens-sdk/utils/getGasPrice.ts b/ens-sdk/utils/getGasPrice.ts new file mode 100644 index 0000000..6fc923e --- /dev/null +++ b/ens-sdk/utils/getGasPrice.ts @@ -0,0 +1,25 @@ +import { EnsPublicClient } from "../types"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +interface GetGasPriceParams { + publicClient: EnsPublicClient; +} + +export const getGasPrice = async ({ + publicClient, +}: GetGasPriceParams): Promise => { + return publicClient + .getGasPrice() + .then((gasPrice) => { + return gasPrice; + }) + .catch((error) => { + return error; + }); +}; diff --git a/ens-sdk/utils/getNamePrice.ts b/ens-sdk/utils/getNamePrice.ts new file mode 100644 index 0000000..c2ccac6 --- /dev/null +++ b/ens-sdk/utils/getNamePrice.ts @@ -0,0 +1,48 @@ +import ETHRegistrarABI from "@/lib/abi/eth-registrar.json"; +import { nameRegistrationSmartContracts } from "../../lib/name-registration/constants"; +import { SupportedNetwork } from "../../lib/wallet/chains"; +import { ENSName, SECONDS_PER_YEAR } from "@namehash/ens-utils"; + +import { EnsPublicClient, NamePrice } from "../types"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +interface GetNamePriceParams { + ensName: ENSName; + durationInYears: bigint; + publicClient: EnsPublicClient; +} + +export const getNamePrice = async ({ + ensName, + durationInYears, + publicClient, +}: GetNamePriceParams): Promise => { + const ensNameDirectSubname = ensName.name.split(".eth")[0]; + + const chain = publicClient.chain; + + if (!Object.values(SupportedNetwork).includes(chain.id)) { + throw new Error(`Unsupported network: ${chain.id}`); + } + + const nameRegistrationContracts = + nameRegistrationSmartContracts[chain.id as SupportedNetwork]; + + const price = await publicClient.readContract({ + args: [ensNameDirectSubname, durationInYears * SECONDS_PER_YEAR.seconds], + address: nameRegistrationContracts.ETH_REGISTRAR, + functionName: "rentPrice", + abi: ETHRegistrarABI, + }); + if (price) { + return (price as NamePrice).base + (price as NamePrice).premium; + } else { + throw new Error("Error getting name price"); + } +}; diff --git a/ens-sdk/utils/getNameRegistrationGasEstimate.ts b/ens-sdk/utils/getNameRegistrationGasEstimate.ts new file mode 100644 index 0000000..36190b8 --- /dev/null +++ b/ens-sdk/utils/getNameRegistrationGasEstimate.ts @@ -0,0 +1,3 @@ +export const getNameRegistrationGasEstimate = (): bigint => { + return 47606n + 324230n; +}; diff --git a/ens-sdk/utils/getRevertErrorData.ts b/ens-sdk/utils/getRevertErrorData.ts new file mode 100644 index 0000000..fa6d884 --- /dev/null +++ b/ens-sdk/utils/getRevertErrorData.ts @@ -0,0 +1,25 @@ +import { BaseError, RawContractError } from "viem"; + +// Validate required environment variable is present +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +/** + * Extracts revert error data from a contract error + * + * This function takes an unknown error and attempts to extract structured revert data + * if it originated from a contract call. It walks the error chain to find the root + * cause and returns the error name and arguments if available. + * + * @param err - The error to extract data from + * @returns The error data containing errorName and args if available, undefined otherwise + */ +export function getRevertErrorData(err: unknown) { + if (!(err instanceof BaseError)) return undefined; + const error = err.walk() as RawContractError; + return error?.data as { errorName: string; args: unknown[] }; +} diff --git a/ens-sdk/utils/index.ts b/ens-sdk/utils/index.ts new file mode 100644 index 0000000..172e85b --- /dev/null +++ b/ens-sdk/utils/index.ts @@ -0,0 +1,8 @@ +export * from "./getChain"; +export * from "./extractLabelFromName"; +export * from "./getNamePrice"; +export * from "./getGasPrice"; +export * from "./createNameRegistrationSecret"; +export * from "./getNameRegistrationGasEstimate"; +export * from "./isNameAvailable"; +export * from "./getRevertErrorData"; diff --git a/ens-sdk/utils/isNameAvailable.ts b/ens-sdk/utils/isNameAvailable.ts new file mode 100644 index 0000000..2f9e51e --- /dev/null +++ b/ens-sdk/utils/isNameAvailable.ts @@ -0,0 +1,23 @@ +import { getAvailable } from "@ensdomains/ensjs/public"; +import { EnsPublicClient } from "../types"; + +const walletConnectProjectId = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!walletConnectProjectId) { + throw new Error("No wallet connect project ID informed"); +} + +interface IsNameAvailableParams { + ensName: string; + publicClient: EnsPublicClient; +} + +export const isNameAvailable = async ({ + ensName, + publicClient, +}: IsNameAvailableParams): Promise => { + const result = await getAvailable(publicClient, { name: ensName }); + + return result; +}; diff --git a/lib/name-registration/localStorage.tsx b/lib/name-registration/localStorage.tsx index 66641d5..59f86de 100644 --- a/lib/name-registration/localStorage.tsx +++ b/lib/name-registration/localStorage.tsx @@ -3,7 +3,7 @@ import { ENS_REGISTRATIONS_SECRET_KEY, OPEN_REGISTRATIONS_LOCAL_STORAGE_KEY, } from "@/lib/name-registration/constants"; -import { createNameRegistrationSecret } from "@/lib/utils/blockchain-txs"; +import { createNameRegistrationSecret } from "@/ens-sdk"; import { LocalNameRegistrationData } from "./types"; // GET diff --git a/lib/utils/blockchain-txs.ts b/lib/utils/blockchain-txs.ts deleted file mode 100644 index 56ee8fd..0000000 --- a/lib/utils/blockchain-txs.ts +++ /dev/null @@ -1,674 +0,0 @@ -/* eslint-disable import/no-named-as-default */ -/* eslint-disable import/named */ -import ENSReverseRegistrarABI from "@/lib/abi/ens-reverse-registrar.json"; -import ETHRegistrarABI from "@/lib/abi/eth-registrar.json"; -import L1ResolverABI from "../abi/arbitrum-resolver.json"; -import { - DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, - nameRegistrationSmartContracts, -} from "../name-registration/constants"; - -import { - namehash, - publicActions, - type WalletClient, - encodeFunctionData, - createWalletClient, - custom, - BaseError, - RawContractError, - Hash, - fromBytes, - Address, - PublicClient, - Chain, - stringToHex, -} from "viem"; -import { SupportedNetwork } from "../wallet/chains"; -import { SECONDS_PER_YEAR, ENSName } from "@namehash/ens-utils"; -import { - TransactionErrorType, - getBlockchainTransactionError, -} from "../wallet/txError"; -import { getNameRegistrationSecret } from "@/lib/name-registration/localStorage"; -import { parseAccount } from "viem/utils"; -import DomainResolverABI from "../abi/offchain-resolver.json"; -import { normalize } from "viem/ens"; -import { supportedCoinTypes } from "../domain-page"; -import { - coinNameToTypeMap, - getCoderByCoinName, -} from "@ensdomains/address-encoder"; -import { CcipRequestParameters, DomainData, MessageData } from "./types"; -import { ClientWithEns } from "@ensdomains/ensjs/dist/types/contracts/consts"; -import { getAvailable } from "@ensdomains/ensjs/public"; -import { getChain } from "../create-subdomain/service"; -import toast from "react-hot-toast"; -import { sepolia } from "viem/chains"; - -const walletConnectProjectId = - process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; - -if (!walletConnectProjectId) { - throw new Error("No wallet connect project ID informed"); -} - -/* - commitment value is used in both 'commit' and 'register' - functions in the registrar contract. It works as a secret - to prevent frontrunning attacks. The commitment value must - be the same in both functions calls, this is why we store - it in the local storage. -*/ - -interface MakeCommitmentParams { - name: string; - data: string[]; - secret: string; - reverseRecord: boolean; - resolverAddress: Address; - durationInYears: bigint; - ownerControlledFuses: number; - authenticatedAddress: Address; - publicClient: PublicClient & ClientWithEns; -} - -export async function makeCommitment({ - name, - data, - secret, - reverseRecord, - resolverAddress, - durationInYears, - ownerControlledFuses, - authenticatedAddress, - publicClient, -}: MakeCommitmentParams) { - const chain = publicClient.chain; - - if (!Object.values(SupportedNetwork).includes(chain.id)) { - throw new Error(`Unsupported network: ${chain.id}`); - } - - const nameRegistrationContracts = - nameRegistrationSmartContracts[chain.id as SupportedNetwork]; - - return publicClient - .readContract({ - account: parseAccount(authenticatedAddress), - address: nameRegistrationContracts.ETH_REGISTRAR, - abi: ETHRegistrarABI, - args: [ - name, - authenticatedAddress, - durationInYears * SECONDS_PER_YEAR.seconds, - secret, - resolverAddress, - data, - reverseRecord, - ownerControlledFuses, - ], - functionName: "makeCommitment", - }) - .then((generatedReservationNumber) => { - return generatedReservationNumber; - }) - .catch((error) => { - const errorType = getBlockchainTransactionError(error); - return errorType || error; - }); -} - -export async function ccipRequest({ - body, - url, -}: CcipRequestParameters): Promise { - return fetch(url.replace("/{sender}/{data}.json", ""), { - body: JSON.stringify(body, (_, value) => - typeof value === "bigint" ? value.toString() : value, - ), - method: "POST", - headers: { - "Content-Type": "application/json", - }, - }); -} - -interface HandleDBStorageParams { - domain: DomainData; - url: string; - message: MessageData; - authenticatedAddress: `0x${string}`; - chain: Chain; -} - -export async function handleDBStorage({ - domain, - url, - message, - authenticatedAddress, - chain, -}: HandleDBStorageParams): Promise { - const client = createWalletClient({ - account: authenticatedAddress, - chain: chain, - transport: custom(window.ethereum), - }); - - const signature = await client.signTypedData({ - domain, - message, - types: { - Message: [ - { name: "callData", type: "bytes" }, - { name: "sender", type: "address" }, - { name: "expirationTimestamp", type: "uint256" }, - ], - }, - primaryType: "Message", - }); - - return await ccipRequest({ - body: { - data: message.callData, - signature: { message, domain, signature }, - sender: message.sender, - }, - url, - }); -} - -/* - 1st step of a name registration -*/ - -interface CommitParams { - ensName: ENSName; - durationInYears: bigint; - resolverAddress: Address; - authenticatedAddress: Address; - registerAndSetAsPrimaryName: boolean; - publicClient: PublicClient & ClientWithEns; - chain: Chain; -} - -export const commit = async ({ - ensName, - durationInYears, - resolverAddress, - authenticatedAddress, - registerAndSetAsPrimaryName, - publicClient, - chain, -}: CommitParams): Promise<`0x${string}` | TransactionErrorType> => { - try { - const walletClient = createWalletClient({ - account: authenticatedAddress, - chain: chain, - transport: custom(window.ethereum), - }); - - const client = walletClient.extend(publicActions); - - if (!client) throw new Error("WalletClient not found"); - - const nameWithoutTLD = ensName.name.replace(".eth", ""); - - const commitmentWithConfigHash = await makeCommitment({ - name: nameWithoutTLD, - data: [], - authenticatedAddress, - durationInYears: durationInYears, - secret: getNameRegistrationSecret(), - reverseRecord: registerAndSetAsPrimaryName, - resolverAddress: resolverAddress, - ownerControlledFuses: DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, - publicClient: publicClient, - }); - - if (!Object.values(SupportedNetwork).includes(chain.id)) { - throw new Error(`Unsupported network: ${chain.id}`); - } - - const nameRegistrationContracts = - nameRegistrationSmartContracts[chain.id as SupportedNetwork]; - - const { request } = await client.simulateContract({ - account: parseAccount(authenticatedAddress), - address: nameRegistrationContracts.ETH_REGISTRAR, - args: [commitmentWithConfigHash], - functionName: "commit", - abi: ETHRegistrarABI, - gas: 70000n, - }); - - const txHash = await client.writeContract(request); - - return txHash; - } catch (error) { - console.error(error); - const errorType = getBlockchainTransactionError(error); - return errorType; - } -}; - -/* - 2nd step of a name registration -*/ - -interface RegisterParams { - ensName: ENSName; - resolverAddress: Address; - durationInYears: bigint; - authenticatedAddress: Address; - registerAndSetAsPrimaryName: boolean; - publicClient: PublicClient & ClientWithEns; - chain: Chain; -} - -export const register = async ({ - ensName, - resolverAddress, - durationInYears, - authenticatedAddress, - registerAndSetAsPrimaryName, - publicClient, - chain, -}: RegisterParams): Promise<`0x${string}` | TransactionErrorType> => { - try { - const walletClient = createWalletClient({ - account: authenticatedAddress, - chain: chain, - transport: custom(window.ethereum), - }); - - const client = walletClient.extend(publicActions); - - if (!client) throw new Error("WalletClient not found"); - - const nameWithoutTLD = ensName.name.replace(".eth", ""); - - const namePrice = await getNamePrice({ - ensName, - durationInYears, - publicClient, - }); - - if (!Object.values(SupportedNetwork).includes(chain.id)) { - throw new Error(`Unsupported network: ${chain.id}`); - } - - const nameRegistrationContracts = - nameRegistrationSmartContracts[chain.id as SupportedNetwork]; - - const txHash = await client.writeContract({ - address: nameRegistrationContracts.ETH_REGISTRAR, - chain: chain, - account: authenticatedAddress, - args: [ - nameWithoutTLD, - authenticatedAddress, - durationInYears * SECONDS_PER_YEAR.seconds, - getNameRegistrationSecret(), - resolverAddress, - [], - registerAndSetAsPrimaryName, - DEFAULT_REGISTRATION_DOMAIN_CONTROLLED_FUSES, - ], - value: namePrice, - abi: ETHRegistrarABI, - functionName: "register", - gas: 500000n, - }); - - return txHash; - } catch (error: unknown) { - const data = getRevertErrorData(error); - - if (data?.errorName === "StorageHandledByOffChainDatabase") { - const [domain, url, message] = data.args as [ - DomainData, - string, - MessageData, - ]; - - const signedData = await handleDBStorage({ - domain, - url, - message, - authenticatedAddress, - chain: chain, - }); - - if (typeof signedData === "string") { - return signedData as `0x${string}`; - } else { - throw new Error("Error handling off-chain storage"); - } - } else { - console.error(error); - const errorType = getBlockchainTransactionError(error); - return errorType; - } - } -}; - -/* - 3rd step of a name registration - set text records -*/ - -interface SetDomainRecordsParams { - ensName: ENSName; - resolverAddress?: Address; - domainResolverAddress?: `0x${string}`; - authenticatedAddress: Address; - textRecords: Record; - addresses: Record; - others: Record; - client: PublicClient & WalletClient; - chain: Chain; -} - -export const setDomainRecords = async ({ - ensName, - resolverAddress, - domainResolverAddress, - authenticatedAddress, - textRecords, - addresses, - others, - client, - chain, -}: SetDomainRecordsParams) => { - try { - const publicAddress = normalize(ensName.name); - - // duplicated function logic on service.ts - createSubdomain - const calls: Hash[] = []; - - for (let i = 0; i < Object.keys(textRecords).length; i++) { - const key = Object.keys(textRecords)[i]; - const value = textRecords[key]; - - if (value !== null && value !== undefined) { - const callData = encodeFunctionData({ - functionName: "setText", - abi: DomainResolverABI, - args: [namehash(publicAddress), key, value], - }); - - calls.push(callData); - } - } - - for (let i = 0; i < Object.keys(addresses).length; i++) { - const [cryptocurrencyName, address] = Object.entries(addresses)[i]; - if (supportedCoinTypes.includes(cryptocurrencyName.toUpperCase())) { - console.error(`cryptocurrency ${cryptocurrencyName} not supported`); - continue; - } - - const coinType = - coinNameToTypeMap[cryptocurrencyName as keyof typeof coinNameToTypeMap]; - - const coder = getCoderByCoinName(cryptocurrencyName.toLocaleLowerCase()); - const addressEncoded = fromBytes(coder.decode(address), "hex"); - const callData = encodeFunctionData({ - functionName: "setAddr", - abi: DomainResolverABI, - args: [namehash(publicAddress), BigInt(coinType), addressEncoded], - }); - calls.push(callData); - } - - for (let i = 0; i < Object.keys(others).length; i++) { - const [key, value] = Object.entries(others)[i]; - const callData = encodeFunctionData({ - functionName: "setContenthash", - abi: L1ResolverABI, - args: [namehash(publicAddress), stringToHex(value)], // vallue = url - }); - calls.push(callData); - } - - try { - let localResolverAddress; - - if (resolverAddress) { - localResolverAddress = resolverAddress; - } else if (domainResolverAddress) { - localResolverAddress = domainResolverAddress; - } else { - throw new Error("No domain resolver informed"); - } - - await client.simulateContract({ - functionName: "multicall", - abi: L1ResolverABI, - args: [calls], - account: authenticatedAddress, - address: localResolverAddress, - }); - } catch (err) { - const data = getRevertErrorData(err); - if (data?.errorName === "StorageHandledByOffChainDatabase") { - const [domain, url, message] = data.args as [ - DomainData, - string, - MessageData, - ]; - - try { - await handleDBStorage({ - domain, - url, - message, - authenticatedAddress, - chain: chain, - }); - - return 200; - } catch (error) { - console.error("writing failed: ", { err }); - const errorType = getBlockchainTransactionError(err); - return errorType; - } - } else if (data?.errorName === "StorageHandledByL2") { - const [chainId, contractAddress] = data.args as [bigint, `0x${string}`]; - - const selectedChain = getChain(Number(chainId)); - - if (!selectedChain) { - toast.error("error"); - return; - } - - const clientWithWallet = createWalletClient({ - chain: selectedChain, - transport: custom(window.ethereum), - }).extend(publicActions); - - await clientWithWallet.addChain({ chain: selectedChain }); - - try { - const { request } = await clientWithWallet.simulateContract({ - functionName: "multicall", - abi: L1ResolverABI, - args: [calls], - account: authenticatedAddress, - address: contractAddress, - }); - await clientWithWallet.writeContract(request); - } catch { - await clientWithWallet.switchChain({ id: sepolia.id }); - } - - await clientWithWallet.switchChain({ id: sepolia.id }); - - return 200; - } else { - console.error("writing failed: ", { err }); - const errorType = getBlockchainTransactionError(err); - return errorType; - } - } - } catch (error: unknown) { - console.error(error); - const errorType = getBlockchainTransactionError(error); - return errorType; - } -}; - -/* - 4th step of a name registration - set domain as primary name if user wants -*/ - -interface SetDomainAsPrimaryNameParams { - authenticatedAddress: `0x${string}`; - ensName: ENSName; - chain: Chain; -} - -export const setDomainAsPrimaryName = async ({ - authenticatedAddress, - ensName, - chain, -}: SetDomainAsPrimaryNameParams) => { - try { - // Create a wallet client for sending transactions to the blockchain - const walletClient = createWalletClient({ - chain: chain, - transport: custom(window.ethereum), - account: authenticatedAddress, - }); - - const client = walletClient.extend(publicActions); - - if (!client) throw new Error("WalletClient not found"); - - const nameWithTLD = ensName.name.includes(".eth") - ? ensName.name - : `${ensName.name}.eth`; - - if (!Object.values(SupportedNetwork).includes(chain.id)) { - throw new Error(`Unsupported network: ${chain.id}`); - } - - const network = chain.id as SupportedNetwork; - - const publicAddress = normalize(nameWithTLD); - - const { request } = await client.simulateContract({ - address: nameRegistrationSmartContracts[network].ENS_REVERSE_REGISTRAR, - account: authenticatedAddress, - abi: ENSReverseRegistrarABI, - functionName: "setName", - args: [publicAddress], - }); - - const setAsPrimaryNameRes = await client.writeContract(request); - - if (!!setAsPrimaryNameRes) { - return 200; - } - } catch (err) { - console.error("writing failed: ", { err }); - const errorType = getBlockchainTransactionError(err); - return errorType; - } -}; - -// Error handling ⬇️ - -export function getRevertErrorData(err: unknown) { - if (!(err instanceof BaseError)) return undefined; - const error = err.walk() as RawContractError; - return error?.data as { errorName: string; args: unknown[] }; -} - -// Utils ⬇️ - -export const createNameRegistrationSecret = (): string => { - const platformHex = namehash("blockful-ens-external-resolver").slice(2, 10); - const platformBytes = platformHex.length; - const randomHex = [...Array(64 - platformBytes)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(""); - - return "0x" + platformHex + randomHex; -}; - -interface NamePrice { - base: bigint; - premium: bigint; -} - -interface GetNamePriceParams { - ensName: ENSName; - durationInYears: bigint; - publicClient: PublicClient & ClientWithEns; -} - -export const getNamePrice = async ({ - ensName, - durationInYears, - publicClient, -}: GetNamePriceParams): Promise => { - const ensNameDirectSubname = ensName.name.split(".eth")[0]; - - const chain = publicClient.chain; - - if (!Object.values(SupportedNetwork).includes(chain.id)) { - throw new Error(`Unsupported network: ${chain.id}`); - } - - const nameRegistrationContracts = - nameRegistrationSmartContracts[chain.id as SupportedNetwork]; - - const price = await publicClient.readContract({ - args: [ensNameDirectSubname, durationInYears * SECONDS_PER_YEAR.seconds], - address: nameRegistrationContracts.ETH_REGISTRAR, - functionName: "rentPrice", - abi: ETHRegistrarABI, - }); - if (price) { - return (price as NamePrice).base + (price as NamePrice).premium; - } else { - throw new Error("Error getting name price"); - } -}; - -interface GetGasPriceParams { - publicClient: PublicClient & ClientWithEns; -} - -export const getGasPrice = async ({ - publicClient, -}: GetGasPriceParams): Promise => { - return publicClient - .getGasPrice() - .then((gasPrice) => { - return gasPrice; - }) - .catch((error) => { - return error; - }); -}; - -export const getNameRegistrationGasEstimate = (): bigint => { - return 47606n + 324230n; -}; - -interface IsNameAvailableParams { - ensName: string; - publicClient: PublicClient & ClientWithEns; -} - -export const isNameAvailable = async ({ - ensName, - publicClient, -}: IsNameAvailableParams): Promise => { - const result = await getAvailable(publicClient, { name: ensName }); - - return result; -}; diff --git a/pages/index.tsx b/pages/index.tsx index 4ece762..3a3061a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useState } from "react"; import { CrossCircleSVG } from "@ensdomains/thorin"; import { HomepageBg } from "@/components/atoms"; -import { isNameAvailable } from "@/lib/utils/blockchain-txs"; +import { isNameAvailable } from "@/ens-sdk"; import { ENSName, buildENSName } from "@namehash/ens-utils"; import { DebounceInput } from "react-debounce-input"; import { useRouter } from "next/router"; diff --git a/pages/register/[name]/index.tsx b/pages/register/[name]/index.tsx index 90ace66..8d029e4 100644 --- a/pages/register/[name]/index.tsx +++ b/pages/register/[name]/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { isNameAvailable } from "@/lib/utils/blockchain-txs"; +import { isNameAvailable } from "@/ens-sdk"; import { ProgressBar } from "@/components/atoms"; import { ProgressBlock,