Introduction
Let's build a straightforward application that allows you to purchase ENS names like radzion.eth
by interacting directly with ENS smart contracts using wagmi and viem. You can explore the complete code on GitHub and try out the live demo here. To streamline our development process, we'll use RadzionKit as our boilerplate, giving us access to all the necessary utilities and components.
Setting Up the Application
Our application needs to perform several blockchain operations, from checking name availability to executing purchase transactions. To enable these features, we'll set up two essential providers at the root of our app: the WagmiProvider
for handling blockchain interactions, and the RainbowKitProvider
for managing wallet connections.
import { Page } from "@lib/next-ui/Page"
import { GlobalStyle } from "@lib/ui/css/GlobalStyle"
import { DarkLightThemeProvider } from "@lib/ui/theme/DarkLightThemeProvider"
import { RainbowKitProvider } from "@rainbow-me/rainbowkit"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Inter } from "next/font/google"
import { ReactNode, useState } from "react"
import { WagmiProvider } from "wagmi"
import { wagmiConfig } from "../chain/wagmiConfig"
import type { AppProps } from "next/app"
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "800"],
})
interface MyAppProps extends AppProps {
Component: Page
}
function MyApp({ Component, pageProps }: MyAppProps) {
const [queryClient] = useState(() => new QueryClient())
const getLayout = Component.getLayout || ((page: ReactNode) => page)
const component = getLayout(<Component {...pageProps} />)
return (
<QueryClientProvider client={queryClient}>
<DarkLightThemeProvider value="dark">
<GlobalStyle fontFamily={inter.style.fontFamily} />
<WagmiProvider config={wagmiConfig}>
<RainbowKitProvider>{component}</RainbowKitProvider>
</WagmiProvider>
</DarkLightThemeProvider>
</QueryClientProvider>
)
}
export default MyApp
Configuring Blockchain Interactions
The wagmiConfig
object configures the core settings for our blockchain interactions. It defines our application's identity through the appName
, sets up authentication with Reown (formerly WalletConnect) using a project ID, specifies which blockchain networks we support, and establishes HTTP transport layers for communicating with each chain.
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { recordFromKeys } from "@lib/utils/record/recordFromKeys"
import { getDefaultConfig } from "@rainbow-me/rainbowkit"
import "@rainbow-me/rainbowkit/styles.css"
import { http } from "viem"
import { productName } from "../product/config"
import { chains } from "."
export const wagmiConfig = getDefaultConfig({
appName: productName,
projectId: shouldBePresent(process.env.NEXT_PUBLIC_REOWN_PROJECT_ID),
chains,
transports: recordFromKeys(
chains.map((chain) => chain.id),
() => http(),
),
})
Network Configuration and Type Safety
Our application will operate on two Ethereum networks: the Mainnet for production use and the Sepolia Testnet for development. To ensure type safety and maintain consistency across our codebase, we'll create a strongly-typed ChainId
type derived from our supported chains. This type system will help us catch potential errors at compile time, ensuring we've properly configured contract addresses for all supported networks.
import { findBy } from "@lib/utils/array/findBy"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { sepolia, mainnet } from "viem/chains"
export const chains = [sepolia, mainnet] as const
export const chainIds = chains.map((c) => c.id)
export type ChainId = (typeof chainIds)[number]
export const getChain = (chainId: number) =>
shouldBePresent(findBy(chains, "id", chainId))
Implementing the Registration Flow
Let's implement the ENS registration process as a two-step flow for a smooth user experience. In the first step, users will interact with a form to select their desired ENS name and registration duration. The second step handles the blockchain transaction, providing real-time progress updates and concluding with either a success or failure screen, ensuring users stay informed throughout the process.
import { Flow } from "@lib/ui/base/Flow"
import { useState } from "react"
import { NameRegistrationParams } from "../NameRegistrationParams"
import { RegistrationFlowExecutionStep } from "./RegistrationFlowExecutionStep"
import { RegistrationFlowNameStep } from "./RegistrationFlowNameStep"
type RegistrationFlowStep =
| {
id: "name"
name: string
}
| {
id: "execution"
params: NameRegistrationParams
}
export const RegistrationFlow = () => {
const [step, setStep] = useState<RegistrationFlowStep>({
id: "name",
name: "",
})
return (
<Flow
step={step}
steps={{
name: () => (
<RegistrationFlowNameStep
onFinish={(params) => setStep({ id: "execution", params })}
/>
),
execution: ({ params }) => (
<RegistrationFlowExecutionStep
onBack={() => setStep({ id: "name", name: params.name })}
onFinish={() => setStep({ id: "name", name: "" })}
params={params}
/>
),
}}
/>
)
}
Managing Multi-Step Flow
We're leveraging the abstract Flow
component from RadzionKit to manage our multi-step registration process in a type-safe and decoupled manner. This component eliminates the need for a complex state management provider by handling step transitions locally. Each step is implemented as an independent component with its own state and logic, communicating with other steps only through well-defined callbacks. This architecture ensures clean separation of concerns and makes our code more maintainable – the name selection form doesn't need to know about transaction execution details, and vice versa.
import { ReactNode } from "react"
export interface FlowStep<K extends string> {
id: K
}
type StepHandlers<K extends string, T extends FlowStep<K>> = {
[key in T["id"]]: (step: Extract<T, { id: key }>) => ReactNode
}
type FlowProps<K extends string, T extends FlowStep<K>> = {
step: T
steps: StepHandlers<K, T>
}
export function Flow<K extends string, T extends FlowStep<K>>({
step,
steps,
}: FlowProps<K, T>) {
const id = step.id
const render = steps[id]
return <>{render(step as Extract<T, { id: K }>)}</>
}
Building the Name Selection Interface
The RegistrationFlowNameStep
component implements a progressive disclosure pattern to streamline the user experience. Initially, it presents a clean interface with just a text input for the ENS name. As users type, the component automatically checks name availability in real-time. Once a valid and available name is found, the interface expands to reveal additional options: a duration selector and a registration button. This step-by-step revelation helps users focus on one decision at a time while preventing them from proceeding with unavailable names.
import { Button } from "@lib/ui/buttons/Button"
import { VStack } from "@lib/ui/css/stack"
import { getFormProps } from "@lib/ui/form/utils/getFormProps"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"
import { TextInput } from "@lib/ui/inputs/TextInput"
import { Center } from "@lib/ui/layout/Center"
import { OnFinishProp } from "@lib/ui/props"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { Text } from "@lib/ui/text"
import { addYears, differenceInSeconds } from "date-fns"
import { useState } from "react"
import { WalletDependantForm } from "../../chain/wallet/components/WalletDependantForm"
import { tld } from "../config"
import { NameRegistrationParams } from "../NameRegistrationParams"
import { useIsNameAvailableQuery } from "../queries/useIsNameAvailableQuery"
import { useRegistrationDuration } from "../state/registrationDuration"
import { ManageRegistrationDuration } from "./ManageRegistrationDuration"
import { RegistrationStepContainer } from "./RegistrationStepContainer"
import { RegistrationStepTitle } from "./RegistrationStepTitle"
export const RegistrationFlowNameStep = ({
onFinish,
}: OnFinishProp<NameRegistrationParams>) => {
const [name, setName] = useState("")
const isNameAvailableQuery = useIsNameAvailableQuery(name)
const [duration] = useRegistrationDuration()
return (
<Center>
<WalletDependantForm
submitText="Register"
onSubmit={({ walletClient }) => {
const now = new Date()
onFinish({
name,
walletClient,
duration: differenceInSeconds(addYears(now, duration), now),
})
}}
render={({ submitText, onSubmit }) => (
<RegistrationStepContainer
as="form"
{...getFormProps({
isDisabled: !isNameAvailableQuery.data,
onSubmit,
})}
>
<RegistrationStepTitle>Your web3 username</RegistrationStepTitle>
<VStack gap={8}>
<InputDebounce<string>
value={name}
onChange={setName}
render={({ value, onChange }) => (
<TextInput
value={value}
onValueChange={(newValue) =>
onChange(newValue.replace(/[.\s]/g, ""))
}
autoFocus
placeholder={`Search for a .${tld} name`}
/>
)}
/>
{name && (
<MatchQuery
value={isNameAvailableQuery}
pending={() => "Checking availability..."}
success={(isAvailable) => {
const fullName = `${name}.${tld}`
return (
<Text color={isAvailable ? "success" : "alert"}>
{isAvailable
? `${fullName} is available!`
: `${fullName} is already taken.`}
</Text>
)
}}
error={() => "Error checking availability."}
/>
)}
</VStack>
{!!isNameAvailableQuery.data && (
<>
<ManageRegistrationDuration />
<Button type="submit">{submitText}</Button>
</>
)}
</RegistrationStepContainer>
)}
/>
</Center>
)
}
Checking Name Availability
To verify ENS name availability, we interact with the ETHRegistrarController
smart contract using wagmi's useReadContract
hook. This hook provides a seamless integration with the blockchain, automatically managing the query lifecycle through react-query. By calling the contract's available
function with the desired name, we receive real-time availability status while benefiting from react-query's built-in caching and revalidation features.
import { type Abi } from "viem"
import { useReadContract } from "wagmi"
import { ChainId } from "../../chain"
import { useChainId } from "../../chain/hooks/useChainId"
import {
ethRegistrarControllerAbi,
ethRegistrarControllerAddresses,
} from "../contracts/ethRegistrarConroller"
export const useIsNameAvailableQuery = (name: string) => {
const chainId = useChainId()
return useReadContract({
address: ethRegistrarControllerAddresses[chainId as ChainId],
abi: ethRegistrarControllerAbi as Abi,
functionName: "available",
args: [name],
query: {
enabled: !!name,
},
})
}
Handling Network Selection
To enhance the user experience, we've implemented the useChainId
hook to ensure seamless name availability checks regardless of wallet connection status. This hook intelligently determines which network to query by first checking for a connected wallet's network (if it's supported) and gracefully falling back to our default supported chain when no wallet is connected. This approach allows users to explore name availability before connecting their wallet, reducing friction in the discovery phase.
import { isOneOf } from "@lib/utils/array/isOneOf"
import { useAccount } from "wagmi"
import { ChainId, chainIds } from ".."
export const useChainId = (): ChainId => {
const { chainId } = useAccount()
return isOneOf(chainId, chainIds) ?? chainIds[0]
}
Smart Contract Integration
The ETHRegistrarController
contract uses the same ABI across networks but has different deployment addresses. We map these addresses to their respective chain IDs in a type-safe configuration.
import { mainnet, sepolia } from "viem/chains"
import { ChainId } from "../../chain"
export const ethRegistrarControllerAddresses: Record<ChainId, `0x${string}`> = {
[mainnet.id]: "0x253553366Da8546fC250F225fe3d25d0C782303b",
[sepolia.id]: "0xFED6a969AaA60E4961FCD3EBF1A2e8913ac65B72",
}
export const ethRegistrarControllerAbi = [
// a long abi ...
]
Managing Wallet States
The WalletDependantForm
component uses the render prop pattern to consistently handle wallet states while maintaining the same UI structure. It always renders your form but adapts its behavior based on the wallet state: without a wallet, the submit button becomes a "Connect Wallet" trigger; on an unsupported network, it becomes a "Switch Network" trigger; and only with a proper connection does it execute the actual form submission. This approach ensures a seamless user experience while preserving your form's visual consistency across all wallet states.
import { IsDisabledProp } from "@lib/ui/props"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { ConnectButton } from "@rainbow-me/rainbowkit"
import {
useAccount,
useSwitchChain,
useWalletClient,
UseWalletClientReturnType,
} from "wagmi"
import { chains } from "../.."
type SubmitParams = {
walletClient: NonNullable<UseWalletClientReturnType["data"]>
}
type RenderParams = IsDisabledProp & {
submitText: string
onSubmit: () => void
}
type WalletDependantFormProps = IsDisabledProp & {
submitText: string
onSubmit: (params: SubmitParams) => void
render: (params: RenderParams) => React.ReactNode
}
export const WalletDependantForm = ({
render,
submitText,
onSubmit,
isDisabled,
}: WalletDependantFormProps) => {
const { isConnected, chainId } = useAccount()
const { switchChain } = useSwitchChain()
const walletClientQuery = useWalletClient()
if (!isConnected) {
return (
<ConnectButton.Custom>
{({ openConnectModal }) =>
render({
submitText: "Connect Wallet",
onSubmit: openConnectModal,
})
}
</ConnectButton.Custom>
)
}
const isSupportedChain = chains.some((chain) => chain.id === chainId)
if (!isSupportedChain) {
return render({
isDisabled: true,
submitText: "Switch Network",
onSubmit: () => switchChain({ chainId: chains[0].id }),
})
}
return (
<MatchQuery
value={walletClientQuery}
success={(walletClient) =>
render({
submitText,
isDisabled,
onSubmit: () => onSubmit({ walletClient }),
})
}
/>
)
}
Transaction Execution and Progress Tracking
Once the form is submitted, our application transitions to the RegistrationFlowExecutionStep
component, which handles the crucial transaction execution phase. This component automatically initiates the transaction when mounted and leverages our familiar MatchQuery
pattern to render appropriate UI states. Whether the transaction is pending, successful, or encounters an error, users receive clear visual feedback throughout the entire registration process.
import { Button } from "@lib/ui/buttons/Button"
import { ProgressList } from "@lib/ui/progress/list/ProgressList"
import { OnBackProp, OnFinishProp } from "@lib/ui/props"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { Text } from "@lib/ui/text"
import { useEffect } from "react"
import {
useRegisterNameMutation,
nameRegistrationSteps,
nameRegistrationStepText,
} from "../mutations/useRegisterNameMutation"
import { NameRegistrationParams } from "../NameRegistrationParams"
import { RegistrationFlowFailureState } from "./RegistrationFlowFailureState"
import { RegistrationFlowSuccessState } from "./RegistrationFlowSuccessState"
import { RegistrationStepContainer } from "./RegistrationStepContainer"
import { RegistrationStepTitle } from "./RegistrationStepTitle"
type RegistrationFlowExecutionStepProps = OnBackProp &
OnFinishProp & {
params: NameRegistrationParams
}
export const RegistrationFlowExecutionStep = ({
onBack,
onFinish,
params,
}: RegistrationFlowExecutionStepProps) => {
const { mutate: register, step, ...mutationState } = useRegisterNameMutation()
useEffect(() => {
register(params)
}, [params, register])
return (
<MatchQuery
value={mutationState}
success={() => (
<RegistrationFlowSuccessState value={params.name} onFinish={onFinish} />
)}
pending={() => (
<RegistrationStepContainer alignItems="center">
<RegistrationStepTitle>
Registering {params.name}.eth
</RegistrationStepTitle>
<Text centerHorizontally>
Please wait while we process your registration. This may take a few
minutes.
</Text>
{step && (
<ProgressList
items={nameRegistrationSteps}
value={step}
renderItem={(item) => nameRegistrationStepText[item]}
/>
)}
<Button kind="secondary" onClick={onBack}>
Cancel
</Button>
</RegistrationStepContainer>
)}
error={() => <RegistrationFlowFailureState onFinish={onBack} />}
/>
)
}
Visual Progress Indication
To enhance the user experience during the registration process, we implement a dynamic progress indicator using the step
variable returned by our mutation. This variable drives the ProgressList
component, which renders a visual timeline of the registration steps. As the transaction progresses through different stages, the list automatically updates to highlight the current step and mark completed ones, providing users with clear visibility into their registration status.
import { VStack } from "@lib/ui/css/stack"
import { ItemsProp, RenderItemProp, ValueProp } from "../../props"
import { ProgressListItem, ProgressListItemKind } from "./ProgressListItem"
type ProgressListProps<T extends string = string> = ValueProp<T> &
ItemsProp<T> &
RenderItemProp<T>
export const ProgressList = <T extends string = string>({
items,
value,
renderItem,
}: ProgressListProps<T>) => {
const currentStepIndex = items.findIndex((item) => item === value)
return (
<VStack gap={8}>
{items.map((item, index) => {
let kind: ProgressListItemKind = "pending"
if (index < currentStepIndex) {
kind = "completed"
} else if (index === currentStepIndex) {
kind = "active"
}
return (
<ProgressListItem key={item} kind={kind}>
{renderItem(item)}
</ProgressListItem>
)
})}
</VStack>
)
}
Each step in our progress list is rendered by the ProgressListItem
component, which provides visual cues about the step's status. The component uses styled-components to create a consistent visual hierarchy: completed steps show a checkmark in green, the active step displays an animated spinner, and pending steps remain empty.
import styled from "styled-components"
import { Match } from "../../base/Match"
import { centerContent } from "../../css/centerContent"
import { sameDimensions } from "../../css/sameDimensions"
import { HStack } from "../../css/stack"
import { CheckIcon } from "../../icons/CheckIcon"
import { Spinner } from "../../loaders/Spinner"
import { ChildrenProp, KindProp } from "../../props"
import { Text, TextColor } from "../../text"
import { getColor } from "../../theme/getters"
export type ProgressListItemKind = "completed" | "active" | "pending"
const IndicatorContainer = styled.div`
${sameDimensions(24)}
${centerContent}
`
const CompletedIndicator = styled(CheckIcon)`
color: ${getColor("success")};
`
const ActiveIndicator = styled(Spinner)`
color: ${getColor("contrast")};
`
const kindToColor: Record<ProgressListItemKind, TextColor> = {
completed: "regular",
active: "contrast",
pending: "shy",
}
export const ProgressListItem = ({
kind,
children,
}: KindProp<ProgressListItemKind> & ChildrenProp) => {
return (
<HStack gap={12} alignItems="center">
<IndicatorContainer>
<Match
value={kind}
completed={() => <CompletedIndicator />}
active={() => <ActiveIndicator />}
pending={() => <div />}
/>
</IndicatorContainer>
<Text color={kindToColor[kind]}>{children}</Text>
</HStack>
)
}
The success and error states share a similar structure but implement different navigation behaviors. The success state clears the form and returns to the initial step, while the error state preserves the entered name for retry. Both states utilize the RegistrationStepContainer
for consistent layout and styling.
import { Button } from "@lib/ui/buttons/Button"
import { getFormProps } from "@lib/ui/form/utils/getFormProps"
import { CheckCircleIcon } from "@lib/ui/icons/CheckCircleIcon"
import { OnFinishProp, ValueProp } from "@lib/ui/props"
import { Text } from "@lib/ui/text"
import { tld } from "../config"
import { RegistrationStepContainer } from "./RegistrationStepContainer"
import { RegistrationStepTitle } from "./RegistrationStepTitle"
export const RegistrationFlowSuccessState = ({
value,
onFinish,
}: ValueProp<string> & OnFinishProp) => {
return (
<RegistrationStepContainer
as="form"
{...getFormProps({ onSubmit: onFinish })}
alignItems="center"
>
<Text color="success" size={40}>
<CheckCircleIcon />
</Text>
<RegistrationStepTitle>Congratulations!</RegistrationStepTitle>
<Text centerHorizontally color="contrast">
You have successfully registered{" "}
<strong>
{value}.{tld}
</strong>
</Text>
<Button type="submit">Back to Home</Button>
</RegistrationStepContainer>
)
}
Implementing the Registration Process
At the core of our ENS registration system lies an elegant two-phase commit-reveal pattern – a clever blockchain mechanism that ensures fair name registration. When you decide to register a name, the process begins with a commitment phase: your desired name, chosen duration, and other registration parameters are hashed together and submitted to the blockchain, effectively reserving your intent without revealing the actual name. After a mandatory one-minute waiting period, the second phase begins where you reveal your true registration request along with the payment. This clever mechanism prevents front-running attacks, where others might try to snipe your desired name by seeing and copying your transaction. Our useRegisterNameMutation
hook orchestrates this dance, managing everything from commitment creation to price calculations (with a 10% buffer added to account for price fluctuations) and final registration. To keep you in the loop, it maintains a clear step-by-step progress indicator throughout the entire process, from preparation to final confirmation.
import { getRandomHex } from "@lib/chain/crypto/getRandomHex"
import { placeholderEvmAddress } from "@lib/chain/evm/utils/address"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { sleep } from "@lib/utils/sleep"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { type Abi } from "viem"
import { useAccount, usePublicClient } from "wagmi"
import { getChain } from "../../chain"
import { useChainId } from "../../chain/hooks/useChainId"
import {
ethRegistrarControllerAbi,
ethRegistrarControllerAddresses,
} from "../contracts/ethRegistrarConroller"
import { NameRegistrationParams } from "../NameRegistrationParams"
const data: `0x${string}`[] = []
const reverseRecord = false
const ownerControlledFuses = 0
export const nameRegistrationSteps = [
"preparing",
"committing",
"waiting",
"calculating",
"registering",
"confirming",
] as const
export type NameRegistrationStep = (typeof nameRegistrationSteps)[number]
export const nameRegistrationStepText: Record<NameRegistrationStep, string> = {
preparing: "Preparing commitment",
committing: "Committing registration",
waiting: "Waiting for confirmation",
calculating: "Calculating price",
registering: "Registering name",
confirming: "Finalizing registration",
}
export const useRegisterNameMutation = () => {
const chainId = useChainId()
const { address } = useAccount()
const publicClient = shouldBePresent(usePublicClient())
const [step, setStep] = useState<NameRegistrationStep | null>(null)
const mutation = useMutation({
mutationFn: async ({
name,
walletClient,
duration,
}: NameRegistrationParams) => {
const contractAddress = ethRegistrarControllerAddresses[chainId]
const secret = getRandomHex(32)
const commitmentHash = (await publicClient.readContract({
address: contractAddress,
abi: ethRegistrarControllerAbi as Abi,
functionName: "makeCommitment",
args: [
name,
address,
duration,
secret,
placeholderEvmAddress,
data,
reverseRecord,
ownerControlledFuses,
],
})) as `0x${string}`
setStep("committing")
const hash = await walletClient.writeContract({
address: contractAddress,
abi: ethRegistrarControllerAbi as Abi,
functionName: "commit",
args: [commitmentHash],
chain: getChain(chainId),
account: address,
})
setStep("waiting")
await publicClient.waitForTransactionReceipt({ hash })
await sleep(convertDuration(1, "min", "ms"))
setStep("calculating")
const rentPriceResult = (await publicClient.readContract({
address: contractAddress,
abi: ethRegistrarControllerAbi as Abi,
functionName: "rentPrice",
args: [name, duration],
})) as { base: bigint; premium: bigint }
const totalPrice =
((rentPriceResult.base + rentPriceResult.premium) * BigInt(110)) /
BigInt(100)
setStep("registering")
const registerHash = await walletClient.writeContract({
address: contractAddress,
abi: ethRegistrarControllerAbi as Abi,
functionName: "register",
args: [
name,
address,
duration,
secret,
placeholderEvmAddress,
data,
reverseRecord,
ownerControlledFuses,
],
value: totalPrice,
chain: getChain(chainId),
account: address,
})
setStep("confirming")
await publicClient.waitForTransactionReceipt({ hash: registerHash })
return name
},
onMutate: () => {
setStep("preparing")
},
onSettled: () => {
setStep(null)
},
})
return {
...mutation,
step,
}
}
Conclusion
In this tutorial, we've built a secure ENS registration application that combines modern web3 tools with an intuitive user interface. Using wagmi, RadzionKit, and TypeScript, we've implemented the commit-reveal pattern for secure name registration while keeping users informed with step-by-step progress tracking. The result is a robust application that makes ENS registration accessible without compromising on security.
Top comments (1)
Nice work! Did you make it completely or partially with Cursor? I wonder your Cursor experience...