Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Staking DApp UI Development

Hello! Welcome to the next lesson in building your dApp frontend.

In our last lesson, you built a fully functional UI for your token sale, mastering the crucial skill of sending Ether along with a transaction to a payable function. This was a major step in creating the economic foundation for your token.

Today, we will build on that foundation by adding utility to your token. We will develop a user interface that allows token holders to stake their tokens and claim rewards. This is a cornerstone of many DeFi applications, designed to incentivize holding and reward long-term supporters of your project.

By the end of this lesson, you will have created a staking dashboard where users can connect their wallet, view their token balances, stake tokens, see their rewards accumulate in real-time, and claim those rewards.

1. Designing the Staking Dashboard

A good staking interface should be transparent, intuitive, and provide users with all the information they need to make decisions. It's more than just a few buttons; it's a dashboard for managing a user's investment in your protocol.

SUPERPANCAKE Staking Platform User Interface
A professional staking dashboard like this one provides a comprehensive overview. We will build the core elements: balance displays, staking/unstaking actions, and a rewards section.

To understand the key components of a well-designed staking portal, it's helpful to see what experts recommend.

How to Build a Staking Portal with Clear Reward Visualization

The article 'How to Build a Staking Portal with Clear Reward Visualization' from ChainScore Labs outlines the essential UI components for a transparent staking experience. We will use this as a conceptual blueprint for our dashboard.

Please read the introduction, 'The Need for Transparency in Staking', to understand why a clear UI is so important. Then, review the section 'Core UI Components for Staking Transparency'. Focus on the descriptions for the 'Real-Time Reward Calculator' and 'Portfolio & Earnings History', as these are the features we'll be implementing.

Based on these best practices, our staking UI will consist of three main parts:

  1. Information Display: Cards showing the user's balance of the stakeable token and the reward token.
  2. Staking Actions: An interface to stake and unstake tokens, showing the current staked amount.
  3. Rewards Section: A display for claimable rewards that updates in real-time, with a button to claim them.

For a clear picture of the application we're about to build, let's watch a short demo.

How to create an ERC20 staking app

The video 'How to create an ERC20 staking app' by thirdweb provides an excellent demonstration of the kind of staking dApp we will build. Watching this will give you a clear visual target for our lesson.

Watch the demo from the beginning until 02:56. Pay attention to the three main components: the token balance display at the top, the stake/unstake section in the middle, and the accumulating rewards display with a 'Claim' button.

2. Reading Staking and Token Data

Let's begin by creating a new component, Staking.tsx. The first step is to fetch and display all the relevant data from our smart contracts. This will involve interacting with three contracts:

  • Your ERC-20 Token Contract (the token to be staked).
  • Your Reward Token Contract (which may be the same as the staking token).
  • Your Staking Contract, which you created in Module 8.

We need to read the user's balances from the token contracts and their staking information (staked amount, pending rewards) from the staking contract. The following article provides code snippets using thirdweb's hooks that we will adapt to use ethers.js.

How To Create An ERC20 Staking Smart Contract & Web ...

The article 'How To Create An ERC20 Staking Smart Contract & Web ...' from the thirdweb blog shows how to connect to contracts and read data. We will use the same logic but with the ethers.js library you're already using.

Review the section 'Connecting to the Smart Contract'. Notice the use of useContract to initialize the contracts and useTokenBalance and useContractRead to fetch data. We will replicate this functionality with ethers.Contract and direct contract calls.

Here is the initial structure for our Staking.tsx component, including the logic to fetch and display the necessary data.

// src/components/Staking.tsx

import { useState, useEffect } from 'react';
import { ethers } from 'ethers';

// Import ABIs and addresses
// import StakingABI from '../artifacts/contracts/Staking.sol/Staking.json';
// import TokenABI from '../artifacts/contracts/YourToken.sol/YourToken.json';

// const stakingContractAddress = 'YOUR_STAKING_CONTRACT_ADDRESS';
// const stakeTokenAddress = 'YOUR_STAKE_TOKEN_ADDRESS';
// const rewardTokenAddress = 'YOUR_REWARD_TOKEN_ADDRESS';

const Staking = () => {
    const [account, setAccount] = useState<string | null>(null);
    const [provider, setProvider] = useState<ethers.BrowserProvider | null>(null);
    
    // Token balances
    const [stakeTokenBalance, setStakeTokenBalance] = useState<string>('0');
    const [rewardTokenBalance, setRewardTokenBalance] = useState<string>('0');

    // Staking info
    const [stakedAmount, setStakedAmount] = useState<string>('0');
    const [earnedRewards, setEarnedRewards] = useState<string>('0');

    // User input
    const [amountToStake, setAmountToStake] = useState<string>('');
    const [amountToUnstake, setAmountToUnstake] = useState<string>('');

    // UI State
    const [isProcessing, setIsProcessing] = useState(false);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        if (window.ethereum) {
            const provider = new ethers.BrowserProvider(window.ethereum);
            setProvider(provider);
            
            provider.send("eth_requestAccounts", [])
                .then(accounts => setAccount(accounts[0]))
                .catch(err => console.error(err));
        }
    }, []);

    const fetchData = async () => {
        if (!provider || !account) return;

        const stakeTokenContract = new ethers.Contract(stakeTokenAddress, TokenABI.abi, provider);
        const rewardTokenContract = new ethers.Contract(rewardTokenAddress, TokenABI.abi, provider);
        const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, provider);

        // Fetch balances
        const stakeBal = await stakeTokenContract.balanceOf(account);
        const rewardBal = await rewardTokenContract.balanceOf(account);
        setStakeTokenBalance(ethers.formatEther(stakeBal));
        setRewardTokenBalance(ethers.formatEther(rewardBal));

        // Fetch staking info from your contract's functions
        // Assuming your contract has `staked(address)` and `earned(address)` view functions
        const staked = await stakingContract.staked(account);
        const earned = await stakingContract.earned(account);
        setStakedAmount(ethers.formatEther(staked));
        setEarnedRewards(ethers.formatEther(earned));
    };

    useEffect(() => {
        fetchData();
        const interval = setInterval(fetchData, 5000); // Refresh data every 5 seconds
        return () => clearInterval(interval);
    }, [provider, account]);

    // We will implement handler functions next
    const handleStake = async () => {};
    const handleUnstake = async () => {};
    const handleClaim = async () => {};

    return (
        <div className="staking-container" style={{ border: '1px solid #ccc', borderRadius: '8px', padding: '20px', width: '500px' }}>
            <h2>Staking Dashboard</h2>
            
            <div className="balances" style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
                <div><strong>Stake Token Balance:</strong> {parseFloat(stakeTokenBalance).toFixed(4)}</div>
                <div><strong>Reward Token Balance:</strong> {parseFloat(rewardTokenBalance).toFixed(4)}</div>
            </div>

            <div className="staking-info" style={{ marginBottom: '20px' }}>
                <p><strong>Currently Staked:</strong> {parseFloat(stakedAmount).toFixed(4)} Tokens</p>
                <p><strong>Rewards Earned:</strong> {parseFloat(earnedRewards).toFixed(6)} Tokens</p>
            </div>
            
            {/* Staking/Unstaking/Claiming Forms will go here */}
        </div>
    );
};

export default Staking;

3. Implementing Stake and Unstake Actions

This is where we encounter a new, fundamental concept of ERC-20 interactions. Unlike the token sale where you sent ETH (msg.value), you cannot "send" tokens directly with a function call. Instead, you must first approve the staking contract to withdraw tokens from your wallet, and then call the stake function, which will internally use transferFrom to pull the approved amount.

This approve -> action pattern is central to DeFi.

Test your understanding!

Why is the approve step necessary before calling stake, but was not needed when buying tokens with ETH in the previous lesson?

Show answer

ETH is the native asset of the blockchain. When you call a payable function, you can attach ETH directly to the transaction, which is accessible inside the contract via msg.value. ERC-20 tokens, however, are not native; their balances are managed by a separate smart contract. The staking contract cannot unilaterally take tokens from your wallet. You must first call the approve function on the token contract itself, granting the staking contract permission to withdraw a specific amount on your behalf. The subsequent stake function call then triggers this withdrawal.

Let's implement the handleStake and handleUnstake functions. For handleStake, we'll perform two transactions: approve and stake.

// Inside the Staking component

const handleStake = async () => {
    if (!provider || !account || !amountToStake || parseFloat(amountToStake) <= 0) {
        setError("Please enter a valid amount to stake.");
        return;
    }
    setError(null);
    setIsProcessing(true);

    try {
        const signer = await provider.getSigner();
        const stakeTokenContract = new ethers.Contract(stakeTokenAddress, TokenABI.abi, signer);
        const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, signer);

        const amountInWei = ethers.parseEther(amountToStake);

        // 1. Approve the staking contract to spend tokens
        console.log("Approving token spend...");
        const approveTx = await stakeTokenContract.approve(stakingContractAddress, amountInWei);
        await approveTx.wait();
        console.log("Approval successful!");

        // 2. Stake the tokens
        console.log("Staking tokens...");
        const stakeTx = await stakingContract.stake(amountInWei);
        await stakeTx.wait();
        console.log("Staking successful!");
        
        // Reset input and refresh data
        setAmountToStake('');
        fetchData();

    } catch (err: any) {
        console.error("Staking failed:", err);
        setError(err.reason || "An error occurred during staking.");
    } finally {
        setIsProcessing(false);
    }
};

const handleUnstake = async () => {
    // Unstaking is simpler, as it doesn't require prior approval
    if (!provider || !account || !amountToUnstake || parseFloat(amountToUnstake) <= 0) {
        setError("Please enter a valid amount to unstake.");
        return;
    }
    setError(null);
    setIsProcessing(true);

    try {
        const signer = await provider.getSigner();
        const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, signer);
        const amountInWei = ethers.parseEther(amountToUnstake);
        
        console.log("Unstaking tokens...");
        const unstakeTx = await stakingContract.withdraw(amountInWei); // Or unstake()
        await unstakeTx.wait();
        console.log("Unstaking successful!");

        setAmountToUnstake('');
        fetchData();

    } catch (err: any) {
        console.error("Unstaking failed:", err);
        setError(err.reason || "An error occurred during unstaking.");
    } finally {
        setIsProcessing(false);
    }
};

Now let's add the UI for these actions inside our main return statement.

// Inside the return statement of Staking.tsx

<div className="actions" style={{ display: 'flex', justifyContent: 'space-between', gap: '20px' }}>
    <div className="stake-action" style={{ flex: 1 }}>
        <input
            type="text"
            value={amountToStake}
            onChange={(e) => setAmountToStake(e.target.value)}
            placeholder="Amount to Stake"
            style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
        />
        <button onClick={handleStake} disabled={isProcessing} style={{ width: '100%', padding: '10px' }}>
            {isProcessing ? 'Processing...' : 'Stake'}
        </button>
    </div>
    <div className="unstake-action" style={{ flex: 1 }}>
        <input
            type="text"
            value={amountToUnstake}
            onChange={(e) => setAmountToUnstake(e.target.value)}
            placeholder="Amount to Unstake"
            style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
        />
        <button onClick={handleUnstake} disabled={isProcessing} style={{ width: '100%', padding: '10px' }}>
            {isProcessing ? 'Processing...' : 'Unstake'}
        </button>
    </div>
</div>
{error && <p style={{ color: 'red', marginTop: '10px' }}>{error}</p>}

4. Claiming Rewards

The final piece of our dashboard is the ability for users to claim the rewards they've earned.

Staking Rewards Claim UI Example
A simple and clear UI like this is perfect for claiming rewards. It shows what's available and provides a clear call to action.

The logic for claiming is a straightforward transaction call to your staking contract's claimReward (or getReward) function.

// Inside the Staking component

const handleClaim = async () => {
    if (!provider || !account) return;
    setError(null);
    setIsProcessing(true);

    try {
        const signer = await provider.getSigner();
        const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, signer);

        console.log("Claiming rewards...");
        const claimTx = await stakingContract.claimReward(); // Or getReward()
        await claimTx.wait();
        console.log("Rewards claimed successfully!");

        fetchData();

    } catch (err: any) {
        console.error("Claiming failed:", err);
        setError(err.reason || "An error occurred during claim.");
    } finally {
        setIsProcessing(false);
    }
};

And here is the UI to add below the staking/unstaking section:

// Inside the return statement, below the actions div

<div className="claim-action" style={{ marginTop: '20px' }}>
    <button onClick={handleClaim} disabled={isProcessing || parseFloat(earnedRewards) <= 0} style={{ width: '100%', padding: '10px', backgroundColor: '#28a745', color: 'white' }}>
        {isProcessing ? 'Processing...' : `Claim ${parseFloat(earnedRewards).toFixed(6)} Rewards`}
    </button>
</div>

The video below demonstrates this entire flow—staking, unstaking, and claiming—in a live application, which will help you verify your implementation.

How to create an ERC20 staking app

Now, let's watch the complete staking flow in action. This part of the 'How to create an ERC20 staking app' video shows exactly what you should experience when you test your component.

Watch the segment from 22:28 to the end. The first part (until 31:24) explains the code for the main component, which we've just built using ethers.js. The second part (from 31:24 onwards) shows a live demonstration of staking, rewards accumulating, and claiming. Use this to guide your testing.

Conclusion

Congratulations! You have now built a sophisticated staking dashboard, adding significant utility and a powerful incentive mechanism to your token ecosystem. This is a huge step forward in creating a real, valuable DeFi application.

Key Takeaways:

  • The approve Pattern: You learned and implemented the essential approve and stake workflow, a fundamental pattern for any dApp that interacts with ERC-20 tokens.
  • Multi-Contract Interaction: You successfully orchestrated reading and writing to multiple contracts (Token, Staking) within a single UI component.
  • Data-Driven UI: You built a dynamic dashboard that fetches, displays, and periodically refreshes on-chain data to give the user a live view of their investment.
  • Complete Staking Lifecycle: Your UI now supports the full user journey: staking tokens, watching rewards grow, and claiming them.

Preview of the Next Lesson:
In our components, we've included basic isProcessing and error states. However, for a production-grade dApp, we need a more robust and user-friendly way to communicate transaction status. In the next lesson, we will focus on handling transaction states and providing feedback to the user, creating a polished experience with notifications for pending, successful, and failed transactions.

Can't find a good explanation? Sign up and we'll make it for you

Sign up