Cover photo

Builder Guide: Programmatically Register Basenames in your dApp

This builder guide outlines the key considerations and best practices for working with Basenames. It also provides a guide for subsidizing gas fees using the CDP Paymaster.

What if navigating the onchain economy was as simple as remembering a username?

Imagine a world where sending crypto, connecting with communities, and managing your digital assets are as intuitive as using social media. This isn't a distant dream - it's already taking shape, thanks to Basenames in the Base ecosystem.

By turning complex wallet addresses into easy, human-readable names, Basenames are transforming how we interact with the onchain economy. They unlock a future where anyone, from blockchain novices to seasoned pros, can engage seamlessly in a decentralized digital landscape. Whether you’re sending assets, collaborating on projects, or building your digital identity, Basenames are the gateway to a more intuitive, inclusive, and efficient blockchain experience.

In this Builder Guide, I’ll walk you through how to allow users of your dApp to programatically register for a Basename, along with the key considerations and best practices to keep in mind when building. Plus, I’ll also show you how to integrate CDP Paymaster to sponsor gas fees, making the user experience 1000x smoother. Whether you’re a seasoned developer or a newbie, this guide will equip you with the tools to build a more user-friendly onchain ecosystem.

High-Level Overview of Basenames

Essentially, Basenames leverages the ENS (Ethereum Name Service) and functions as human-readable subdomains (*.base.eth) where users can register and manage their onchain name.

The diagram below provides a high-level overview of how Basenames work, while the open-source GitHub repository delves into the detailed implementation, consisting of the contracts for resolving, managing, and tokenising subdomains as ERC721 tokens. These names are fully onchain, which means they are compatible across the Base network and other EVM-compatible blockchains, ensuring seamless cross-chain integration. With support for public resolvers, you as a developer can set various records (address, text, DNS) tied to your user's Basename, making it a versatile tool for dApps and decentralised identities.

Step-By-Step Guide: Implementing Basenames in your dApp

For context, in this guide we will be on Base Sepolia network, using NextJS (Typescript) as the frontend framework, with Wagmi + Viem as the frontend hooks and low-level interface to interact with anything Ethereum.

Essentially, the main smart contract that you have to interact with to programatically register Basenames via your dApp is Registrar.sol that contains the register() function.

However, it’s not as straightforward as simply passing a string into the register function to register the Basenames. There are numerous nuances to consider in preparing your arguments before properly passing into the function.

Prerequisites

  • Install the CDP SDK.

  • Install Onchainkit.

  • Have an 'ETH funded' wallet (minimum of 0.005 ETH) to get started.
    Reason: The _validatePayment() function in the RegistrarController.sol has a require statement that requires the msg.value to be more than the price of the Basename.

        /// @notice Internal helper for validating ETH payments
        ///
        /// @dev Emits `ETHPaymentProcessed` after validating the payment.
        ///
        /// @param price The expected value.
        function _validatePayment(uint256 price) internal {
            if (msg.value < price) {
                revert InsufficientValue();
            }
            emit ETHPaymentProcessed(msg.sender, price);
        }

Create basename.ts and Import Relevant Modules

Create a basename.ts in your lib folder and import the following modules

import {
  encodeFunctionData,
  namehash,
  Address,
  createPublicClient,
  http,
} from "viem";
import { baseSepolia, base } from "viem/chains";
import { Basename } from "@coinbase/onchainkit/identity";

Define Contract ABIs and Contract Addresses

In your basename.ts file, include the following code, which specifies the contract addresses for RegistrarController.sol and L2Resolver.sol, along with the ABIs for each of these contracts.

// Chain ID
export const BASE_SEPOLIA_CHAIN_ID = 84532

// Contract Addresses
export const BASE_SEPOLIA_REGISTRAR_CONTROLLER_ADDRESS =
	"0x49ae3cc2e3aa768b1e5654f5d3c6002144a59581";
export const BASE_SEPOLIA_L2_RESOLVER_ADDRESS =
	"0x6533C94869D28fAA8dF77cc63f9e2b2D6Cf77eBA";

// ABIs
export const BASE_SEPOLIA_L2_RESOLVER_ABI = [
  {
    inputs: [
      { internalType: "bytes32", name: "node", type: "bytes32" },
      { internalType: "uint256", name: "coinType", type: "uint256" },
      { internalType: "bytes", name: "a", type: "bytes" },
    ],
    name: "setAddr",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      { internalType: "bytes32", name: "node", type: "bytes32" },
      { internalType: "string", name: "newName", type: "string" },
    ],
    name: "setName",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
];

export const BASE_SEPOLIA_REGISTRAR_CONTROLLER_ABI = [
   {
    inputs: [
      {
        components: [
          { internalType: "string", name: "name", type: "string" },
          { internalType: "address", name: "owner", type: "address" },
          { internalType: "uint256", name: "duration", type: "uint256" },
          { internalType: "address", name: "resolver", type: "address" },
          { internalType: "bytes[]", name: "data", type: "bytes[]" },
          { internalType: "bool", name: "reverseRecord", type: "bool" },
        ],
        internalType: "struct RegistrarController.RegisterRequest",
        name: "request",
        type: "tuple",
      },
    ],
    name: "register",
    outputs: [],
    stateMutability: "payable",
    type: "function",
  },
  {
    inputs: [
      { internalType: "string", name: "name", type: "string" },
      { internalType: "uint256", name: "duration", type: "uint256" },
    ],
    name: "registerPrice",
    outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
    stateMutability: "view",
    type: "function",
  },
];

Function to Create Arguments for register() method

Before implementing the function to generate arguments for the register() method, we first need to define a constant USERNAME_DOMAINS to record the domain name associated with the network. For Base Sepolia, this will be basetest.eth, and for Base, it will be base.eth.

Next, we will create a helper function, formatBaseEthDomain(), to properly format the Base domain.

Finally, we'll construct the arguments for the register() method in RegistrarController.sol. These arguments will leverage the setName() and setAddr() methods from L2Resolver.sol.

// username domains
export const USERNAME_DOMAINS: Record<number, string> = {
  [baseSepolia.id]: "basetest.eth",
  [base.id]: "base.eth",
};

// format base eth domain name
export const formatBaseEthDomain = (
  name: string,
  chainId: number
): Basename => {
  return `${name}.${
    USERNAME_DOMAINS[chainId] ?? ".base.eth"
  }`.toLocaleLowerCase() as Basename;
};

// Create register contract method arguments
export function createRegisterContractMethodArgs(
  baseName: string,
  addressId: Address
): RegistrationArgs {
  const addressData = encodeFunctionData({
    abi: L2ResolverAbi,
    functionName: "setAddr",
    args: [namehash(formatBaseEthDomain(baseName, baseSepolia.id)), addressId],
  });

  const nameData = encodeFunctionData({
    abi: L2ResolverAbi,
    functionName: "setName",
    args: [
      namehash(formatBaseEthDomain(baseName, baseSepolia.id)),
      formatBaseEthDomain(baseName, baseSepolia.id),
    ],
  });

  const registerArgs: RegistrationArgs = {
    name: baseName,
    owner: addressId,
    duration: BigInt(31557600),
    resolver: BASE_SEPOLIA_L2_RESOLVER_ADDRESS,
    data: [addressData, nameData],
    reverseRecord: true,
  };

  console.log(`Register contract method arguments constructed: `, registerArgs);

  return registerArgs;
}

Function to Estimate Basename Registration Price

As noted earlier, the _validatePayment() function in RegistrarController.sol includes a require statement ensuring that msg.value is greater than the price of the basename. To ensure the user sends enough ETH when calling the register() function, we will create a final helper function in basename.ts called estimateMintValue(). This function will be used to estimate the amount of ETH required to pay when minting a basename.

// Estimate registration price
export async function estimateMintValue(
  baseName: string,
  duration: bigint
): Promise<bigint> {
  const publicClient = createPublicClient({
    chain: baseSepolia,
    transport: http(),
  });

  const price = await publicClient.readContract({
    address: BASE_SEPOLIA_REGISTRAR_CONTROLLER_ADDRESS,
    abi: RegistrarControllerAbi,
    functionName: "registerPrice",
    args: [baseName, duration],
  });

  console.log(`Estimated mint value for ${baseName}: `, price);

  return price;
}

Onchainkit <Transaction /> Component w/ Paymaster

Next, in register-basename.tsx component, we will utilize the Onchainkit Transaction Component to simplify the integration of basename registration on the frontend. You will need your Paymaster Endpoint that you can get from Coinbase Developer Platform.

Here's how you can set up the register-basename.tsx component:

To further customize the <Transaction /> component to suit your preferences, you may refer to the Onchainkit documentation. It provides guidance on tailoring the component to meet your specific requirements.

"use client";

import React, { useMemo, useEffect, useState } from "react";
import { encodeFunctionData } from "viem";
import { useAccount } from "wagmi";
import { Input } from "@/components/ui/input";
import {
  Transaction,
  TransactionButton,
  TransactionError,
  TransactionResponse,
  TransactionStatus,
  TransactionStatusAction,
  TransactionStatusLabel,
} from "@coinbase/onchainkit/transaction";
import {
  createRegisterContractMethodArgs,
  estimateMintValue,
  BASE_SEPOLIA_CHAIN_ID,
  BASE_SEPOLIA_REGISTRAR_CONTROLLER_ADDRESS,
  RegistrarControllerAbi,
} from "../basename";

interface RegistrationArgs {
  name: string;
  owner: `0x${string}`;
  duration: bigint;
  resolver: `0x${string}`;
  data: readonly `0x${string}`[];
  reverseRecord: boolean;
}

const RegistrationForm = () => {
  const [baseName, setBaseName] = useState("");
  const [registrationArgs, setRegistrationArgs] =
    useState<RegistrationArgs | null>(null);
  const [estimatedValue, setEstimatedValue] = useState<number | null>(null);
  const account = useAccount();

  // Handle input change
  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setBaseName(event.target.value);
  };

  // Fetch registration data whenever baseName or account address changes
  useEffect(() => {
    const fetchRegistrationData = async () => {
      if (baseName && account.address) {
        const args = createRegisterContractMethodArgs(
          baseName,
          account.address
        );
        const value = await estimateMintValue(baseName, args.duration);
        setRegistrationArgs(args);
        setEstimatedValue(Number(value));
      }
    };

    fetchRegistrationData();
  }, [baseName, account.address]);

  // Prepare the transaction call
  const registerBasenameCall = useMemo(() => {
    if (
      !BASE_SEPOLIA_REGISTRAR_CONTROLLER_ADDRESS ||
      !account.address ||
      !registrationArgs ||
      estimatedValue === null
    ) {
      return [];
    }
    return [
      {
        to: BASE_SEPOLIA_REGISTRAR_CONTROLLER_ADDRESS as `0x${string}`,
        data: encodeFunctionData({
          abi: RegistrarControllerAbi,
          functionName: "register",
          args: [
            {
              name: registrationArgs.name,
              owner: registrationArgs.owner,
              duration: registrationArgs.duration,
              resolver: registrationArgs.resolver,
              data: registrationArgs.data,
              reverseRecord: registrationArgs.reverseRecord,
            },
          ],
        }),
        value: BigInt(estimatedValue),
      },
    ];
  }, [registrationArgs, account.address, estimatedValue]);

  // Handle transaction success
  const handleSuccess = async (transactionResponse: TransactionResponse) => {
    console.log("Transaction Response:", transactionResponse);
  };

  // Handle transaction error
  const handleError = (error: TransactionError) => {
    console.error("Transaction Error:", error);
  };

  return (
    <div>
      <Input
        placeholder="Enter base name"
        value={baseName}
        onChange={handleInputChange}
      />
      <Transaction
        chainId={BASE_SEPOLIA_CHAIN_ID}
        calls={registerBasenameCall}
        onError={handleError}
        onSuccess={handleSuccess}
        capabilities={{
          paymasterService: {
            url: process.env
              .NEXT_PUBLIC_CDP_PAYMASTER_AND_BUNDLER_ENDPOINT as string,
          },
        }}
      >
        <TransactionButton text="Register" />
        <TransactionStatus>
          <TransactionStatusLabel />
          <TransactionStatusAction />
        </TransactionStatus>
      </Transaction>
    </div>
  );
};

export default RegistrationForm;

This <RegistrationForm /> component will allow users to register a basename programatically. The form utilizes the Coinbase Transaction component to handle the blockchain interaction, including transaction status and error handling.

Key Functions of <RegistrationForm />

  • fetchRegistrationData() - responsible for preparing the contract call arguments and estimating the gas fee required for the transaction. It is triggered whenever baseName or account.address changes. It is put in a useEffect hook and contains the helper functions estimateMintValue() and createRegisterContractMethodArgs().

  • registerBasenameCall() - uses useMemo to create the transaction data only when all required information is available.

  • handleSuccess() - triggered when the transaction is successfully completed. It logs the transaction response to the console. (You can add additional logic in this function)

  • handleError() - triggered if the transaction fails. It logs the error details. (You can add additional logic in this function)

Putting it all together in the <Transaction /> component, this component from Onchainkit handles the process of sending the transaction to the blockchain. It uses the callback functions defined earlier to manage the success and error states.

Conclusion

Building the onchain economy is not just an aspiration; it’s happening now. By simplifying the process of obtaining a decentralized identity via your dApp, Basenames can unlock new opportunities for developers and users alike.

I hope this guide is able to make your developer experience on Base 1000x smoother!

Special thanks to Jesse Pollak, Will Binns, Wilson Cusack, Tina He, and the entire Onchainkit and Coinbase Developer Platform team for their invaluable guidance throughout this project.

There’s never been a better time to start building on Base 😆

Loading...
highlight
Collect this post to permanently own it.
Buidling Onchain logo
Subscribe to Buidling Onchain and never miss a post.
#basenames#onchainkit#coinbase#cdp#wagmi#viem#nextjs#typescript