How to Build an ERC-721 NFT Staking App

How to Build an ERC-721 NFT Staking App

In this guide, we'll walk through the process of building an ERC-721 staking application using thirdweb's pre-built contracts and Connect SDK. The app will allow users to:

  • Claim an ERC-721 NFT
  • Stake their NFTs
  • Earn ERC-20 tokens as staking rewards
  • Withdraw staked NFTs
  • Claim ERC-20 rewards

By the end, you'll have a fully functional web3 app that demonstrates the power and flexibility of thirdweb's tools.

You can follow along with the video tutorial for this guide here:

Build an ERC-721 Staking App with thirdweb

GitHub Repo:

GitHub - thirdweb-example/youtube-erc721-staking
Contribute to thirdweb-example/youtube-erc721-staking development by creating an account on GitHub.

Let's get started!

Prerequisites

Before diving in, make sure you have the following:

  • A thirdweb account
  • Node.js installed on your machine
  • Basic knowledge of React and Next.js
  • Familiarity with Solidity and smart contract development

Step 1: Deploying the Smart Contracts

For our staking app, we'll need to deploy three smart contracts:

  1. ERC-721 NFT Contract - This will be the NFT collection users can stake.
NFT Drop - ERC721 | Published Smart Contract
Release collection of unique NFTs for a set price. Deploy NFT Drop in one click with thirdweb.
  1. ERC-20 Token Contract - This token will be earned as staking rewards.
Token - ERC20 | Published Smart Contract
Create cryptocurrency compliant with ERC20 standard. Deploy Token in one click with thirdweb.
  1. Staking Contract - This contract will handle the staking logic.
StakeERC721 | Published Smart Contract
Contract for staking ERC721 NFTs, for ERC20 tokens as rewards.. Deploy StakeERC721 in one click with thirdweb.

That covers deploying and configuring our smart contracts. In the next part, we'll build out the staking app UI to interact with these contracts.

Step 2: Setting Up the Project

First, we'll set up a new Next.js project using the thirdweb CLI:

npx thirdweb create app

Choose `Next.js` as the framework when prompted.

Open the project in your preferred code editor.

Step 3: Get your contracts to interact with

Now lets create a wrapper for our contracts to use within the Connect SDK.

  1. Create a contracts.ts file and add the following code:
import { chain } from "@/app/chain";
import { client } from "@/app/client";
import { getContract } from "thirdweb";
import { stakingABI } from "./stakingABI";

const nftContractAddress = "";
const rewardTokenContractAddress = "";
const stakingContractAddress = "";

export const NFT_CONTRACT = getContract({
    client: client,
    chain: chain,
    address: nftContractAddress
});

export const REWARD_TOKEN_CONTRACT = getContract({
    client: client,
    chain: chain,
    address: rewardTokenContractAddress
});

export const STAKING_CONTRACT = getContract({
    client: client,
    chain: chain,
    address: stakingContractAddress,
    abi: stakingABI
});

Add your contract addresses to the corresponding variables above.

Step 4: Add a Connect Button

Add thirdweb's Connect SDK ConnectButton UI component. This will allow a user to connect their web3 wallet to your app to claim, stake, and earn rewards.

<ConnectButton
    client={client}
    chain={chain}
/>

Step 5: Build an NFT Claim

Next, lets build a button to allow a user to claim an ERC-721 NFT that they can later stake within our app.

<div>
    <h2>Claim NFT to Stake</h2>
    <TransactionButton
        transaction={() => (
            claimTo({
                contract: NFT_CONTRACT,
                to: account?.address || "",
                quantity: BigInt(1)
            })
        )}
        onTransactionConfirmed={async () => {
            alert("NFT claimed!");
        }}
    >Claim NFT</TransactionButton>
</div>

Step 6: Display Owned NFTs

Now that our users can claim NFTs, lets display the NFTs they own and create a button that lets them stake the NFTs.

  1. Get the wallet address of the connected account
const account = useActiveAccount();
  1. Create a function that gets the owned NFTs of connected account
const [ownedNFTs, setOwnedNFTs] = useState<NFT[]>([]);
    
const getOwnedNFTs = async () => {
    let ownedNFTs: NFT[] = [];

    const totalNFTSupply = await totalSupply({
        contract: NFT_CONTRACT,
    });
    const nfts = await getNFTs({
        contract: NFT_CONTRACT,
        start: 0,
        count: parseInt(totalNFTSupply.toString()),
    });
    
    for (let nft of nfts) {
        const owner = await ownerOf({
            contract: NFT_CONTRACT,
            tokenId: nft.id,
        });
        if (owner === account?.address) {
            ownedNFTs.push(nft);
        }
    }
    setOwnedNFTs(ownedNFTs);
};

useEffect(() => {
    if(account) {
        getOwnedNFTs();
    }
}, [account]);
  1. Create a component to display for each owned NFT. This component will show the image of the NFT along with a button to stake the NFT
type OwnedNFTsProps = {
    nft: NFT;
    refetch: () => void;
    refecthStakedInfo: () => void;
};

export const NFTCard = ({ nft, refetch, refecthStakedInfo }: OwnedNFTsProps) => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [isApproved, setIsApproved] = useState(false);
  
  return (
      <div>
          <MediaRenderer
              client={client}
              src={nft.metadata.image}
              style={{
          />
          <p>{nft.metadata.name}</p>
          <button
              onClick={() => setIsModalOpen(true)}
          >Stake</button>
      </div>
  )
}
  1. Create a modal that opens and shows the NFT about to be staked. Checks if NFT has been approved to stake. If not it will show a button to approve before showing stake button.
{isModalOpen && (
    <div>
        <div>
            <div>
                <button
                    onClick={() => setIsModalOpen(false)}
                >Close</button>
            </div>
            <h3>You about to stake:</h3>
            <MediaRenderer
                client={client}
                src={nft.metadata.image}
            />
            {!isApproved ? (
                <TransactionButton
                    transaction={() => (
                        approve({
                            contract: NFT_CONTRACT,
                            to: STAKING_CONTRACT.address,
                            tokenId: nft.id
                        })
                    )}
                    onTransactionConfirmed={async () => setIsApproved(true)}
                >Approve</TransactionButton>
            ) : (
                <TransactionButton
                    transaction={() => (
                        prepareContractCall({
                            contract: STAKING_CONTRACT,
                            method: "stake",
                            params: [[nft.id]]
                        })
                    )}
                    onTransactionConfirmed={async () => {
                        alert("Staked!");
                        setIsModalOpen(false);
                        refetch();
                        refecthStakedInfo();
                    }}
                >Stake</TransactionButton>
            )}
        </div>
    </div>
)}
  1. Now, lets map through user's owned NFTs and display our NFT component
<div>
    <h2>Owned NFTs</h2>
    <div>
        {ownedNFTs && ownedNFTs.length > 0 ? (
            ownedNFTs.map((nft) => (
                
            ))
        ) : (
            <p>You own 0 NFTs</p>
        )}
    </div>
</div>

Step 7: Display Staked NFTs

Once we have an NFT staked we want to show the NFTs that are staked along with the rewards the user has earned.

  1. First, we need to get the information of what NFTs are staked and how much of the reward token the user has earned
const {
    data: stakedInfo,
    refetch: refetchStakedInfo,
} = useReadContract({
    contract: STAKING_CONTRACT,
    method: "getStakeInfo",
    params: [account?.address || ""],
});
  1. Next, we'll create another NFT component to show for each staked NFT. This component will show the image of the NFT along with a button to withdraw the staked NFT.
type StakedNFTCardProps = {
    tokenId: bigint;
    refetchStakedInfo: () => void;
    refetchOwnedNFTs: () => void;
};

export const StakedNFTCard: React.FC<StakedNFTCardProps> = ({ tokenId, refetchStakedInfo, refetchOwnedNFTs }) => {}
  1. Using the tokenID of the NFT we can get the metadata of the NFT from the contract
const { data: nft } = useReadContract(
    getNFT,
    {
        contract: NFT_CONTRACT,
        tokenId: tokenId,
    }
);
  1. Then display the image of the NFT and create a Transaction Button to withdraw the NFT from the staking contract
<div>
    <MediaRenderer
        client={client}
        src={nft?.metadata.image}
    />
    <p>{nft?.metadata.name}</p>
    <TransactionButton
        transaction={() => (
            prepareContractCall({
                contract: STAKING_CONTRACT,
                method: "withdraw",
                params: [[tokenId]]
            })
        )}
        onTransactionConfirmed={() => {
            refetchOwnedNFTs();
            refetchStakedInfo();
            alert("Withdrawn!");
        }}
    >Withdraw</TransactionButton>
</div>
  1. Using the staked info we got earlier we can map through the user's staked NFTs and display our staked NFT component
<div>
    <h2>Staked NFTs</h2>
    <div>
        {stakedInfo && stakedInfo[0].length > 0 ? (
            stakedInfo[0].map((nft: any, index: number) => (
                
            ))
        ) : (
            <p>No NFTs staked</p>
        )}
    </div>
</div>

Step 8: Build Stake Rewards Component

Next, we'll use the staked information from earlier and create a component to display the rewards the user has earned and a button to claim those rewards.

export const StakeRewards = () => {
    const account = useActiveAccount();

    const {
        data: tokenBalance,
        isLoading: isTokenBalanceLoading,
        refetch: refetchTokenBalance,
    } = useReadContract(
        balanceOf,
        {
            contract: REWARD_TOKEN_CONTRACT,
            owner: account?.address || "",
        }
    )
    
    const {
        data: stakedInfo,
        refetch: refetchStakedInfo,
    } = useReadContract({
        contract: STAKING_CONTRACT,
        method: "getStakeInfo",
        params: [account?.address || ""],
    });

    useEffect(() => {
        refetchStakedInfo();
        const interval = setInterval(() => {
            refetchStakedInfo();
        }, 1000);
        return () => clearInterval(interval);
    }, []);

    return (
        <div>
            {!isTokenBalanceLoading && (
                <p>Wallet Balance: {toEther(BigInt(tokenBalance!.toString()))}</p>
            )}
            <h2>Stake Rewards: {stakedInfo && toEther(BigInt(stakedInfo[1].toString()))}</h2>
            <TransactionButton
                transaction={() => (
                    prepareContractCall({
                        contract:STAKING_CONTRACT,
                        method: "claimRewards",
                    })
                )}
                onTransactionConfirmed={() => {
                    alert("Rewards claimed!")
                    refetchStakedInfo();
                    refetchTokenBalance();
                }}
            >Claim Rewards</TransactionButton>
        </div>
    )
};

Conclusion

And there you have it! We've built an ERC-721 NFT staking app that allows users to:

  1. Connect their wallet
  2. Claim an ERC-721 NFT
  3. Stake their owned NFTs
  4. Earn and claim ERC-20 rewards from staking contract

By using thirdweb's Connect SDK, we were able to easily interact with our smart contract to read data and make transactions.

You can build an app like this on any EVM-compatible blockchain, including popular L2s like Optimism, Base, Arbitrum, Avalanche and more.

The thirdweb Connect SDK provides a simple and powerful way to build web3 apps with a great developer experience.

I hope you enjoyed this tutorial and found it valuable!