How to Create Token Bound Accounts (ERC-6551)

How to Create Token Bound Accounts (ERC-6551)

ERC-6551 and ERC-4337 compatible Token Bound Accounts are easy to create using thirdweb — but how do you build them into your project, app, or game?

In this example app, built using both the thirdweb React SDK and Wallet SDK, we will

  • Create a user flow where the user connects a personal wallet (or TBA key).
  • View their owned NFTs from an NFT Drop contract with the ability to claim NFTs using this personal wallet.
  • Create functionality to select an NFT, connect to the associated smart wallet for that NFT, and perform an action using that TBA smart wallet.

A common use case for TBAs is in gaming - creating a wallet associated with the ERC-721 that users claim at the start of the game and holding all of the in-game assets within this TBA. In such examples, wanting a gasless and wallet-less user experience is common. This is now possible using TBAs combined with either email (for a Web2 type flow) or local wallet (for complete wallet abstraction)!

💡
Gasless and Wallet-less User Experience
In this guide, we will create an example app showing you how to integrate TBAs into a front end. For demonstration, this app is not 'wallet-less' to highlight which sections of the user experience are using TBAs, but this could be abstracted away from your users by enabling gasless transactions for the personal wallet in the ThirdwebProvider.

The full source code for this guide can be found here!

GitHub - thirdweb-example/token-bound-account-app: Create a frontend for users to create erc-6551 smart wallets for their NFTs & claim erc-20 tokens!
Create a frontend for users to create erc-6551 smart wallets for their NFTs & claim erc-20 tokens! - GitHub - thirdweb-example/token-bound-account-app: Create a frontend for users to create erc…

To learn how to build custom smart wallet contracts, like TBAs, visit this guide!

*The terms account, and wallet can be used interchangeably.

The App Structure

To create this demo app, we will wrap individual sections of the app in different providers so that we can access both the personal wallet and all TBAs as separate signers. When users connect their personal wallets, they can view and select each owned NFT in the drop contract. Upon selecting, they will be taken to a page showing the TBA for that token. This page will be wrapped in another provider so that the TBA signer can be used to perform actions such as claiming tokens and viewing the account's token balance.  

Pre Requisites

For this guide, you will need the following:

💡
An API key is required to use thirdweb's infrastructure services such as storage, RPCs, and Smart Wallet infrastructure. If you haven't created a key yet, you can do so for free from the thirdweb dashboard.

For this example, we will use the official ERC-6551 registry factory contract to issue clones of our implementation contract. This ensures that marketplaces like OpenSea have one source with which to obtain the predetermined smart wallet addresses.

* If you would like to add custom logic to this contract, clone this GitHub repo, customize the code, and use the following command to deploy your contract:

npx thirdweb deploy

We now require you to authenticate using your API secret key in order to use this command. Check out how to login here.

💡
**At the time of writing this guide, the ERC-6551 Registry contract is deployed to the following networks:
- Goerli
- Sepolia
- Mumbai
- Mainnet
- Polygon
All are deployed at 0x02101dfB77FDE026414827Fdc604ddAF224F0921

For more details, visit the Tokenbound documentation.

Set Up the App and Connect the Personal Wallet

To set up the app, run the following command from your terminal:

npx thirdweb create app

When prompted, select EVM blockchain, Next.js as the framework, and TypeScript as the language. Give your project a name, and once the dependencies have been installed, open this newly created directory in your code editor.

First, create a folder called const and a file called constants.ts inside this to hold all of your constant variables. The activeChain variable can be any EVM chain and has to match the chain that your implementation and factory exist on.

Add the following variables to this file:

import { Mumbai, Chain } from '@thirdweb-dev/chains'

// your token bound factory address
export const factoryAddress: string = '<your-factory-address>'

// Your thirdweb api key - you can get one at https://thirdweb.com/dashboard/api-keys
export const TWApiKey: string =
    process.env.CLIENT_ID
export const activeChain: Chain = Mumbai

export const nftDropAddress: string = '<your-nft-drop-contract-address>'
export const tokenAddress: string = '<your-token-drop-contract-address>'

Open _app.tsx and modify the ThirdwebProvider with a supportedWallets object containing an array of wallets that users can connect to as their personal wallet. This wallet will contain all the NFTs we will create TBAs for.

import type { AppProps } from "next/app";
import {
  ThirdwebProvider,
  coinbaseWallet,
  metamaskWallet,
  walletConnect,
} from "@thirdweb-dev/react";
import "../styles/globals.css";
import { Navbar } from "../components/Navbar/Navbar";
import { TWApiKey, activeChain } from "../const/constants";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThirdwebProvider
      activeChain={activeChain}
      supportedWallets={[metamaskWallet(), coinbaseWallet(), walletConnect()]}
      clientId={TWApiKey}
    >
      <Navbar />
      <Component {...pageProps} />
    </ThirdwebProvider>
  );
}

export default MyApp;

Note that we also passed our thirdweb API Key so that we could use the thirdweb Smart Wallet infrastructure services such as the bundler and provider.

This array can be any wallet from the thirdweb Wallet SDK. These wallets will now be included as part of the ConnectWallet button - let's add this to our app.

Inside index.tsx, add the following code:

import type { NextPage } from "next";
import styles from "../styles/Main.module.css";
import {
  ConnectWallet,
  useAddress,
  useContract,
  useOwnedNFTs,
} from "@thirdweb-dev/react";
import { nftDropAddress } from "../const/constants";

/**
 * The home page of the application.
 */
const Home: NextPage = () => {

  return (
    <div>
      {address ? (
        <div className={styles.container}>
          <h1>Your NFTs</h1>
          <p>
            Browse the NFTs inside your personal wallet, select one to connect a
            token bound smart wallet & view it&apos;s balance.
          </p>
          <div className={styles.btnContainer}>
            {/*the ability to claim NFTs here*/}
          </div>
        </div>
      ) : (
        <div className={styles.container}>
          <h2>Connect a personal wallet to view your owned NFTs</h2>
          <ConnectWallet />
        </div>
      )}
    </div>
  );
};

export default Home;

Users will now initially be prompted to connect a personal wallet when they enter the app.

We have left a space for giving users the ability to claim NFTs and view their owned NFTs if they have connected their wallet - let's do that now.

Claiming and Viewing Owned NFTs

To claim an NFT, we will use the Web3Button UI component. We also need to view the connected wallet's owned NFTs. To do this, we will use the useOwnedNfts hook. This hook takes a contract as an argument, so we will need to get a contract from our NFT Drop contract address using the useContract hook. Putting all of this together, our index.ts file should now look like this:

import type { NextPage } from "next";
import styles from "../styles/Main.module.css";
import NFTGrid from "../components/NFT/NFTGrid";
import {
  ConnectWallet,
  Web3Button,
  useAddress,
  useContract,
  useOwnedNFTs,
  useClaimNFT,
} from "@thirdweb-dev/react";
import { nftDropAddress } from "../const/constants";

/**
 * The home page of the application.
 */
const Home: NextPage = () => {
  const address = useAddress();
  const { contract: nftDropContract } = useContract(nftDropAddress, "nft-drop");
  const { data: nfts, isLoading } = useOwnedNFTs(nftDropContract, address);

  return (
    <div>
      {address ? (
        <div className={styles.container}>
          <h1>Your NFTs</h1>
          <p>
            Browse the NFTs inside your personal wallet, select one to connect a
            token bound smart wallet & view it&apos;s balance.
          </p>
          <NFTGrid
            nfts={nfts}
            isLoading={isLoading}
            emptyText={
              "Looks like you don't own any NFTs. Did you import your contract on the thirdweb dashboard? https://thirdweb.com/dashboard"
            }
          />
          <div className={styles.btnContainer}>
            <Web3Button
              contractAddress={nftDropAddress}
              action={async (contract) => await contract?.erc721.claim(1)}
            >
              Claim NFT
            </Web3Button>
          </div>
        </div>
      ) : (
        <div className={styles.container}>
          <h2>Connect a personal wallet to view your owned NFTs</h2>
          <ConnectWallet />
        </div>
      )}
    </div>
  );
};

export default Home;

We have used a component called NFTGrid to display the owned NFTs - let's create this now.

Create a folder called components , and inside this, create another folder called NFT. In this folder, create two files: NFT.tsx and NFTGrid.tsx. Here, we are separating the individual NFT components from the grid itself. First, let's create the NFT component for a single NFT inside NFT.tsx:

import { ThirdwebNftMedia } from "@thirdweb-dev/react";
import { NFT } from "@thirdweb-dev/sdk";
import React from "react";
import styles from "./NFT.module.css";

type Props = {
  nft: NFT;
};

// Each NFT component shows the NFT image, name, and token ID.
export default function NFTComponent({ nft }: Props) {
  return (
    <>
      <ThirdwebNftMedia metadata={nft.metadata} className={styles.nftImage} />

      <p className={styles.nftTokenId}>Token ID #{nft.metadata.id}</p>
      <p className={styles.nftName}>{nft.metadata.name}</p>
    </>
  );
}

This component renders a ThirdwebNftMedia UI component to display the NFT.

Now let's create the grid:

import type { NFT as NFTType } from "@thirdweb-dev/sdk";
import Link from "next/link";
import React from "react";
import { nftDropAddress } from "../../const/constants";
import NFT from "./NFT";
import styles from "../../styles/Main.module.css";

type Props = {
  isLoading: boolean;
  nfts: NFTType[] | undefined;
  overrideOnclickBehavior?: (nft: NFTType) => void;
  emptyText?: string;
};

// NFTGrid component shows a grid of the connected wallet's owned NFTs.
export default function NFTGrid({ nfts, emptyText = "No owned NFTS." }: Props) {
  return (
    <div className={styles.nftGridContainer}>
      {nfts && nfts.length > 0 ? (
        nfts.map((nft) => (
          <Link
            href={`/token/${nftDropAddress}/${nft.metadata.id}`}
            key={nft.metadata.id}
            className={styles.nftContainer}
          >
            <NFT nft={nft} />
          </Link>
        ))
      ) : (
        <p>{emptyText}</p>
      )}
    </div>
  );
}

Here, we are mapping over each NFT to render an NFT component for each that has a clickable link. We now need to create a destination for this link.

Selecting an NFT to Connect to the Token-Bound Account

When someone clicks an individual NFT, they need to be taken to a page allowing them to connect the associated smart wallet and view and interact with it. Create a folder called token inside pages and inside this folder, create a folder called [contractAddress]. This is a dynamic route, so it will take the contractAddress and use it as part of the URL. Inside this folder, create a file called [tokenId].tsx, another dynamic route that will append the tokenId for the NFT that the user clicked on and append it to the URL.

Inside this file, add the following two functions:

  • getStaticProps: fetch the metadata for the NFT Drop contract and get the NFT data
  • getStaticPaths: statically pre-render pages since we are using dynamic routes
💡
This is server side rendering therefore, to be able to use the thirdweb storage and infrastructure services, we will need to pass a secretKey when instantiating the thirdweb SDK.
export const getStaticProps: GetStaticProps = async (context) => {
  const tokenId = context.params?.tokenId as string;

  const sdk = new ThirdwebSDK(activeChain, {
    secretKey: process.env.SECRET_KEY,
  });

  const contract = await sdk.getContract(nftDropAddress);

  const nft = await contract.erc721.get(tokenId);

  let contractMetadata;

  try {
    contractMetadata = await contract.metadata.get();
  } catch (e) {}

  return {
    props: {
      nft,
      contractMetadata: contractMetadata || null,
    },
    revalidate: 1, // https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const sdk = new ThirdwebSDK(activeChain, {
    secretKey: process.env.SECRET_KEY,
  });

  const contract = await sdk.getContract(nftDropAddress);

  const nfts = await contract.erc721.getAll();

  const paths = nfts.map((nft) => {
    return {
      params: {
        contractAddress: nftDropAddress,
        tokenId: nft.metadata.id,
      },
    };
  });

  return {
    paths,
    fallback: "blocking", // can also be true or 'blocking'
  };
};

Now let's create a useEffect so that on page render, a new smart wallet is created for that token. We will create a function createSmartWallet that will check if the smart wallet for this token already exists, the NFT itself exists, and the wallet is connected. In this function, we will call another function, newSmartWallet, which we will create in a moment, to create a smart wallet:

// create a smart wallet for the NFT
  useEffect(() => {
    const createSmartWallet = async (nft: NFT) => {
      if (nft && smartWalletAddress == null && address && wallet) {
        const smartWallet = newSmartWallet(nft);
        console.log("personal wallet", address);
        await smartWallet.connect({
          personalWallet: wallet,
        });
        setSigner(await smartWallet.getSigner());
        console.log("signer", signer);
        setSmartWalletAddress(await smartWallet.getAddress());
        console.log("smart wallet address", await smartWallet.getAddress());
        return smartWallet;
      } else {
        console.log("smart wallet not created");
      }
    };
    createSmartWallet(nft);
  }, [nft, smartWalletAddress, address, wallet]);

We need a  useState to update the signer using setSigner, which we will use when performing actions with our TBA by passing it to the SmartWalletConnected component, and another useState to keep track of the smart wallet address which we set using setSmartWalletAddress. Putting this together, your [tokenId].tsx  file should look like this:

import {
  MediaRenderer,
  ThirdwebNftMedia,
  useAddress,
  useWallet,
} from "@thirdweb-dev/react";
import React, { useEffect, useState } from "react";
import { GetStaticProps, GetStaticPaths } from "next";
import { NFT, ThirdwebSDK } from "@thirdweb-dev/sdk";
import { activeChain, nftDropAddress } from "../../../const/constants";
import styles from "../../../styles/Token.module.css";
import { Signer } from "ethers";
import newSmartWallet from "../../../components/SmartWallet/SmartWallet";
import SmartWalletConnected from "../../../components/SmartWallet/smartConnected";

type Props = {
  nft: NFT;
  contractMetadata: any;
};

export default function TokenPage({ nft, contractMetadata }: Props) {
  const [smartWalletAddress, setSmartWalletAddress] = useState<string | null>(
    null
  );
  const [signer, setSigner] = useState<Signer>();

  // get the currently connected wallet
  const address = useAddress();
  const wallet = useWallet();

  // create a smart wallet for the NFT
  useEffect(() => {
    const createSmartWallet = async (nft: NFT) => {
      if (nft && smartWalletAddress == null && address && wallet) {
        const smartWallet = newSmartWallet(nft);
        console.log("personal wallet", address);
        await smartWallet.connect({
          personalWallet: wallet,
        });
        setSigner(await smartWallet.getSigner());
        console.log("signer", signer);
        setSmartWalletAddress(await smartWallet.getAddress());
        console.log("smart wallet address", await smartWallet.getAddress());
        return smartWallet;
      } else {
        console.log("smart wallet not created");
      }
    };
    createSmartWallet(nft);
  }, [nft, smartWalletAddress, address, wallet]);

  return (
    <>
      <div>
        <div className={styles.container}>
          <div className={styles.metadataContainer}>
            <ThirdwebNftMedia
              metadata={nft.metadata}
              className={styles.image}
            />
          </div>

          <div className={styles.listingContainer}>
            {contractMetadata && (
              <div className={styles.contractMetadataContainer}>
                <MediaRenderer
                  src={contractMetadata.image}
                  className={styles.collectionImage}
                />
                <p className={styles.collectionName}>{contractMetadata.name}</p>
              </div>
            )}
            <h1 className={styles.title}>{nft.metadata.name}</h1>
            <p className={styles.collectionName}>Token ID #{nft.metadata.id}</p>
            {smartWalletAddress ? (
              <SmartWalletConnected signer={signer} />
            ) : (
              <div className={styles.btnContainer}>
                <p>Loading...</p>
              </div>
            )}
          </div>
        </div>
      </div>
    </>
  );
}

export const getStaticProps: GetStaticProps = async (context) => {
  const tokenId = context.params?.tokenId as string;

  const sdk = new ThirdwebSDK(activeChain);

  const contract = await sdk.getContract(nftDropAddress);

  const nft = await contract.erc721.get(tokenId);

  let contractMetadata;

  try {
    contractMetadata = await contract.metadata.get();
  } catch (e) {}

  return {
    props: {
      nft,
      contractMetadata: contractMetadata || null,
    },
    revalidate: 1, // https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const sdk = new ThirdwebSDK(activeChain);

  const contract = await sdk.getContract(nftDropAddress);

  const nfts = await contract.erc721.getAll();

  const paths = nfts.map((nft) => {
    return {
      params: {
        contractAddress: nftDropAddress,
        tokenId: nft.metadata.id,
      },
    };
  });

  return {
    paths,
    fallback: "blocking", // can also be true or 'blocking'
  };
};

There are two missing pieces:

  • creating a new TBA with the newSmartWallet function
  • SmartWalletConnected - the component to display the wallet information, including claiming tokens and displaying the wallet's balance

Creating a TBA with the ERC-6551 Registry

Let's create the newSmartWallet function. To create a TBA smart wallet by creating a clone of our implementation with the ERC-6551 registry factory contract, we are going to create a file inside components called SmartWallet and inside this, create a file called SmartWallet.ts. This file will contain the logic for creating our smart wallet. To set up a smart wallet using the ERC-6551 registry add the following:

import { ethers } from "ethers";
import { SmartWallet } from "@thirdweb-dev/wallets";
import {
  TWApiKey,
  factoryAddress,
  activeChain,
  nftDropAddress,
  implementation,
} from "../../const/constants";
import { SmartContract, NFT } from "@thirdweb-dev/sdk";
import { WalletOptions } from "@thirdweb-dev/wallets";
import type { SmartWalletConfig } from "@thirdweb-dev/wallets";
import type { BaseContract } from "ethers";

export default function newSmartWallet(token: NFT) {
  //Smart Wallet config object
  const config: WalletOptions<SmartWalletConfig> = {
    chain: activeChain, // the chain where your smart wallet will be or is deployed
    factoryAddress: factoryAddress, // your own deployed account factory address
    thirdwebApiKey: TWApiKey, // obtained from the thirdweb dashboard
    gasless: true, // enable or disable gasless transactions
    factoryInfo: {
      createAccount: async (
        factory: SmartContract<BaseContract>,
        owner: string
      ) => {
        const account = factory.prepare("createAccount", [
          implementation,
          activeChain.chainId,
          nftDropAddress,
          token.metadata.id,
          0,
          ethers.utils.toUtf8Bytes("")
        ]);
        console.log("here", account);
        return account;
      }, // the factory method to call to create a new account
      getAccountAddress: async (
        factory: SmartContract<BaseContract>,
        owner: string
      ) => {
        return factory.call("account", [
          implementation,
          activeChain.chainId,
          nftDropAddress,
          token.metadata.id,
          0
        ]);
      }, // the factory method to call to get the account address
    },
  };
  return new SmartWallet(config);
}

The NFT token information is passed to the getAddress and createAccount functions used for determining the deterministic wallet address and creating a wallet for that NFT, respectively. The function newSmartWallet contains the configuration for creating a new smart wallet for this NFT using the data.

Claiming Tokens with the TBA and Viewing Balance

To render the information for the smart wallet and connect this smart wallet to our app, create a file called smartWalletConnected.tsx inside the SmartWallet folder. This file will contain the logic and components for when a smart wallet is created and connected.

To connect the TBA to our app, create a component called SmartWalletConnected which will take a signer as a prop - the signer that we updated inside [token].tsx. This signer will be passed to a new ThirdwebSDKProvider which will wrap the ClaimTokens component so that the new TBA signer can perform operations in this part of the app.

interface ConnectedProps {
  signer: Signer | undefined;
}

// ThirdwebSDKProvider is a wrapper component that provides the smart wallet signer and active chain to the Thirdweb SDK.
const SmartWalletConnected: React.FC<ConnectedProps> = ({ signer }) => {
  return (
    <ThirdwebSDKProvider 
    	signer={signer} 
        activeChain={activeChain}
        clientID={TWApiKey}
    >
      <ClaimTokens />
    </ThirdwebSDKProvider>
  );
};

Finally, let's create the ClaimTokens component to view the TBA wallet's balance and claim tokens. To do this, we will use the useAddress hook to get the smart wallet address and the useBalance hook to get the balance of the TBA. We will use another Web3Button to claim tokens when the button is clicked using the action prop. The file should contain the following:

import {
  ThirdwebSDKProvider,
  useAddress,
  useBalance,
  Web3Button,
} from "@thirdweb-dev/react";
import React from "react";
import { activeChain, tokenAddress } from "../../const/constants";
import { Signer } from "ethers";
import style from "../../styles/Token.module.css";
interface ConnectedProps {
  signer: Signer | undefined;
}

// ThirdwebSDKProvider is a wrapper component that provides the smart wallet signer and active chain to the Thirdweb SDK.
const SmartWalletConnected: React.FC<ConnectedProps> = ({ signer }) => {
  return (
    <ThirdwebSDKProvider 
    	signer={signer} 
        activeChain={activeChain}
        clientId={process.env.CLIENT_ID}
        >
      <ClaimTokens />
    </ThirdwebSDKProvider>
  );
};

// This is the main component that shows the user's token bound smart wallet.
const ClaimTokens = () => {
  const address = useAddress();
  const { data: tokenBalance, isLoading: loadingBalance } =
    useBalance(tokenAddress);

  return (
    <div className={style.walletContainer}>
      <h2>This is Your Token Bound Smart Wallet!</h2>
      {address ? (
        loadingBalance ? (
          <h2>Loading Balance...</h2>
        ) : (
          <div className={style.pricingContainer}>
            <h2>Balance: {tokenBalance?.displayValue}</h2>
            <Web3Button
              contractAddress={tokenAddress}
              action={async (contract) => await contract.erc20.claim(10)}
            >
              Claim 10 Tokens
            </Web3Button>
          </div>
        )
      ) : null}
    </div>
  );
};

export default SmartWalletConnected;

And that's it! Add a few CSS styles, onSuccess and onLoading components, details of which can be found in the full source code, and you have a fully functioning app that uses TBAs!

Additionally, you could abstract away the wallet aspect completely by using a local or email wallet as the smart wallet's personal wallet to claim tokens and sign messages. You could also make this personal wallet gasless by passing the gasless option to the ThirdwebProvider in _app.tsx.

To view the full source code, including styling and small UX improvements, visit the GitHub repo.

Wrapping Up

That's it! 🥳 Great work.

You've set up a front end to create token-bound accounts for your users!

Got questions? Jump into our Discord to speak with our team directly!