Building a Staking Dapp on Rootstock: A Developer's Guide

AkanimoAkanimo
22 min read

Welcome to this tutorial on building a simple staking dApp on Rootstock. In this guide, we'll walk through the process of creating a basic staking dApp that allows users to stake, unstake, and claim rewards. By the end of this tutorial, you'll have a functional dApp that interacts with smart contracts on the Rootstock testnet. In this staking dapp, 2 custom ERC20 tokens are used namely, Staking Token - STK and Reward Token - RTK. While, STK is used for staking, the RTK token is used to reward the users for staking their STK tokens.

Glossary

  • dApp (Decentralized Application): An application that runs on a blockchain network, utilizing smart contracts to function without a central authority.

  • Rootstock: A smart contract platform that is compatible with the Ethereum Virtual Machine (EVM) and operates on the Bitcoin network.

  • ERC20 Token: A standard for fungible tokens on the Ethereum blockchain, which can be used for creating tokens that are interoperable with other ERC20 tokens.

  • Staking: The process of locking up a cryptocurrency to support the operations of a blockchain network, often in exchange for rewards.

  • Unstake: The process of withdrawing staked cryptocurrency from a blockchain network.

  • Smart Contract: A self-executing contract with the terms of the agreement directly written into code, running on a blockchain.

  • Ethers.js: A library for interacting with the Ethereum blockchain and its ecosystem, including smart contracts.

  • MetaMask: A cryptocurrency wallet and gateway to blockchain apps, allowing users to interact with the Ethereum blockchain through a browser extension.

  • tRBTC: Testnet version of Rootstock's native cryptocurrency, used for testing purposes.

  • Faucet: A service that provides free cryptocurrency, usually in small amounts, to users for testing and development purposes.

  • ABI (Application Binary Interface): A standard way to interact with contracts in the Ethereum ecosystem, defining how data should be encoded/decoded and how functions can be called.

Learning Takeaways

  • Smart Contract Development and deployment

  • Building a frontend for integration with smart contracts

  • Using Ethers.js library for communicating with smart contracts

  • Integrating Metamask for secure user transactions and interactions

  • Read and Write data to/from smart contracts

Software Prerequisites

Setting up Dev Environment

1. Initialize the Project

mkdir staking-dapp
cd staking-dapp
npm init -y
npm install --save-dev hardhat
npx hardhat init

2. Install the OpenZeppelin contracts library.

This is required for the smart contracts. In the hardhat project, open the terminal and run:

npm install @openzeppelin/contracts

3. Install and Configure MetaMask

  • Install and configure MetaMask Chrome Extension to use with Rootstock Testnet.

  • Refer here for a detailed guide.

4. Create a Secret File

  • Create a secret.json file in the root folder and store the private key of your MetaMask wallet in it.

  • Refer here for details on how to get MetaMask account's private key.

{"PrivateKey":"you private key, do not leak this file, do keep it absolutely safe"}

Do not forget to add this file to the .gitignore file in the root folder of your project so that you don't accidentally check your private keys/secret phrases into a public repository. Make sure you keep this file in an absolutely safe place!

4. Update .gitignore

  • Update your .gitignore file to ensure that your secret.json file and other sensitive files are not committed to version control.

  • Make sure to add secret.json to the .gitignore file.

node_modules
.env
secret.json

# Hardhat files
/cache
/artifacts

# TypeChain files
/typechain
/typechain-types

# solidity-coverage files
/coverage
/coverage.json

# Hardhat Ignition default folder for deployments against a local node
ignition/deployments/chain-31337

5. Update Hardhat.config

Replace the contents of hardhat.config.js with the following configuration. Ensure that the network settings are configured correctly for Rootstock Testnet.

/**
 * @type import('hardhat/config').HardhatUserConfig
 */

require('@nomiclabs/hardhat-ethers');
require("@nomiclabs/hardhat-waffle");

const { PrivateKey } = require('./secret.json');

module.exports = {

   networks: {
      hardhat: {
      },
      rskTestnet: {
         url: 'https://rpc.testnet.rootstock.io',
         accounts: [PrivateKey],
         chainId: 31,
      }
   },
   solidity: {
      compilers: [
        {
           version: '0.8.24',
           settings: {
            evmVersion: 'paris',
            optimizer: {
                 enabled: true,
                 runs: 200,
              },
           },
        },
      ],
   },
   paths: {
      sources: './contracts',
      cache: './cache',
      artifacts: './artifacts',
   },
   mocha: {
      timeout: 20000,
   },
};

Writing Smart Contracts

In the contracts folder, create the following smart contracts.

Staking Token Contract

  • For this dapp, we create a custom ERC20 token, named as Staking Token - STK. Users will stake their STK tokens to earn rewards.

  • Create a StakingToken.sol file and update its contents with the following.

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract StakingToken is ERC20, Ownable, ERC20Permit {
    constructor(string memory name, string memory symbol)
        ERC20(name, symbol)
        Ownable(msg.sender)
        ERC20Permit(name)
    {}

    function mint(address to, uint256 amount) public onlyOwner {
        require(amount <= 100 * 10 ** 18, "amount must be less than 100");
        _mint(to, amount);
    }
}

Reward Token Contract

  • For this dapp, we create a custom ERC20 token, named as Reward Token - RTK. Users will earn rewards as RTK tokens for staking their STK tokens.

  • Create a RewardToken.sol file and update its contents with the following.

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract RewardToken is ERC20, Ownable, ERC20Permit {
    address public stakingDapp;
    event Minted(address indexed to, uint256 amount);

    constructor(string memory name, string symbol)
        ERC20(name, symbol)
        Ownable(msg.sender)
        ERC20Permit(name)
    {}

    modifier onlyStakingDapp() {
        require(msg.sender == stakingDapp, "Caller is not the staking dapp");
        _;
    }

    function setStakingDapp(address _stakingDapp) external onlyOwner {
        require(_stakingDapp != address(0), "Invalid staking dapp address");
        stakingDapp = _stakingDapp;
    }

    function mint(address to, uint256 amount) public onlyStakingDapp {
        _mint(to, amount);
        emit Minted(to, amount);
    }
}
💡
Note: The mint function emits a Minted event to log the address receiving tokens and the amount minted, improving transparency and auditability.

Staking Dapp Contract

  • Create a StakingDapp.sol file and update its contents with the following.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

interface IRewardToken is IERC20 {
    function mint(address to, uint256 amount) external;
}

contract StakingDapp is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public stakingToken;
    IRewardToken public rewardToken;

    struct Stake {
        uint256 amount;
        uint256 lastRewardTime;
    }

    mapping(address => Stake) public stakes;
    mapping(address => uint256) public rewardBalance;

    uint256 public constant REWARD_AMOUNT = 5 * 10 ** 18; // 5 RTK tokens per interval
    uint256 public constant REWARD_INTERVAL = 3600; // 1 hour

    event Staked(address indexed user, uint256 amount);
    event Unstaked(address indexed user, uint256 amount);
    event RewardClaimed(address indexed user, uint256 amount);

    constructor(address _stakingToken, address _rewardToken) Ownable(msg.sender) {
        require(_stakingToken != address(0) && _rewardToken != address(0), "Invalid token addresses");
        stakingToken = IERC20(_stakingToken);
        rewardToken = IRewardToken(_rewardToken);
        RewardToken(_rewardToken).setStakingDapp(address(this));
    }

    function getStakedAmount(address user) external view returns (uint256) {
        return stakes[user].amount;
    }

    function stake(uint256 amount) external {
        require(amount > 0, "Amount must be greater than 0");

        if (stakes[msg.sender].amount > 0) {
            uint256 pendingReward = calculateReward(msg.sender);
            rewardBalance[msg.sender] += pendingReward;
        }

        stakingToken.safeTransferFrom(msg.sender, address(this), amount);

        stakes[msg.sender].amount += amount;
        stakes[msg.sender].lastRewardTime = block.timestamp;

        emit Staked(msg.sender, amount);
    }

    function unstake(uint256 amount) external {
        require(stakes[msg.sender].amount >= amount, "Insufficient balance");

        uint256 pendingReward = calculateReward(msg.sender);
        rewardBalance[msg.sender] += pendingReward;

        stakes[msg.sender].amount -= amount;
        stakes[msg.sender].lastRewardTime = block.timestamp;

        stakingToken.safeTransfer(msg.sender, amount);

        emit Unstaked(msg.sender, amount);
    }

    function claimReward() external nonReentrant {
        uint256 reward = calculateReward(msg.sender) + rewardBalance[msg.sender];
        require(reward > 0, "No reward available");

        rewardBalance[msg.sender] = 0;
        stakes[msg.sender].lastRewardTime = block.timestamp;

        rewardToken.mint(msg.sender, reward);

        emit RewardClaimed(msg.sender, reward);
    }

    function calculateReward(address user) internal view returns (uint256) {
        Stake memory userStake = stakes[user];
        if (userStake.amount == 0) {
            return 0;
        }

        uint256 currentTime = block.timestamp;
        uint256 timeSinceLastReward = currentTime - userStake.lastRewardTime;
        uint256 intervalsPassed = timeSinceLastReward / REWARD_INTERVAL;
        return (intervalsPassed * REWARD_AMOUNT * userStake.amount) / 10 ** 18;
    }

    function getRewardAmount(address user) external view returns (uint256) {
        uint256 reward = calculateReward(user);
        return reward;
    }
}

Explanation

The StakingDapp contract is a basic implementation of a staking mechanism in Solidity. It allows users to stake an ERC20 token (stakingToken), earn rewards in another ERC20 token (rewardToken), and claim those rewards.

State Variables
  • stakingToken: The ERC-20 token that users will stake.

  • rewardToken: The ERC-20 token used to distribute rewards.

  • stakes: A mapping from user addresses to their staking details, including the amount staked and the last time rewards were calculated.

  • rewardBalance: A mapping to keep track of the reward balance for each user that they have accumulated but not yet claimed.

  • REWARD_AMOUNT: The amount of reward tokens distributed per reward interval.

  • REWARD_INTERVAL: The time interval (in seconds) between reward distributions.

Functions
  1. getStakedAmount(address user): Returns the amount of STK tokens staked by a user.

  2. stake(uint256 amount): Allows users to stake a specified amount of staking tokens.

    • Updates the user's reward balance before modifying the stake.

    • Transfers the tokens from the user to the contract.

    • Updates the staking details and the last reward calculation time.

    • Emits the Staked event.

  3. unstake(uint256 amount): Allows users to unstake a specified amount of stakingToken.

    • Ensures the user has sufficient staked tokens.

    • Updates the reward balance before modifying the stake.

    • Transfers the STK tokens back to the user.

    • Updates the staking details and the last reward calculation time.

    • Emits the Unstaked event.

  4. claimReward(): Allows users to claim their accumulated rewards.

    • Calculates the total reward available for the user.

    • Mints the reward tokens and transfers them to the user.

    • Resets the user's reward balance.

    • Updates the last reward calculation time.

    • Emits the RewardClaimed event.

  5. calculateReward(address user): Calculates the reward amount for a user based on the time elapsed since the last reward calculation and the amount staked.

    • The REWARD_AMOUNT is set to 5 * 10^18 (5 RTK tokens, scaled for 18 decimals) per reward interval. The REWARD_INTERVAL is set to 3600 seconds (1 hour), meaning rewards are calculated every hour based on the staked amount. The reward formula is:
    reward = (intervalsPassed * REWARD_AMOUNT * stakedAmount) / 10^18

This ensures proper decimal handling since both STK and RTK tokens use 18 decimals. To adjust the reward rate, modify REWARD_AMOUNT (e.g., 1 * 10^18 for 1 RTK per interval) or REWARD_INTERVAL (e.g., 86400 for daily rewards). Be cautious with large values to avoid excessive token minting.

Note: A nonReentrant modifier is added on claimReward() to prevent reentrancy attacks.

Testing Smart Contracts

To ensure the smart contracts work as expected, we include unit tests using Hardhat. Create a test/StakingDapp.test.js file in the test folder and add tests for the following scenarios:

  • Staking tokens and verifying the staked amount.

  • Unstaking tokens and verifying the updated staked amount.

  • Calculating and claiming rewards after a reward interval.

  • Attempting to unstake more tokens than staked (should revert).

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("StakingDapp", function () {
  let StakingToken, RewardToken, StakingDapp;
  let stakingToken, rewardToken, stakingDapp;
  let owner, user1, user2;

  const REWARD_AMOUNT = ethers.utils.parseUnits("5", 18);
  const REWARD_INTERVAL = 3600; // 1 hour

  beforeEach(async function () {
    [owner, user1, user2] = await ethers.getSigners();

    // Deploy StakingToken
    StakingToken = await ethers.getContractFactory("StakingToken");
    stakingToken = await StakingToken.deploy("Staking Token", "STK");
    await stakingToken.deployed();

    // Deploy RewardToken
    RewardToken = await ethers.getContractFactory("RewardToken");
    rewardToken = await RewardToken.deploy("Reward Token", "RTK");
    await rewardToken.deployed();

    // Deploy StakingDapp
    StakingDapp = await ethers.getContractFactory("StakingDapp");
    stakingDapp = await StakingDapp.deploy(stakingToken.address, rewardToken.address);
    await stakingDapp.deployed();

    // Mint initial tokens to user1
    await stakingToken.mint(user1.address, ethers.utils.parseUnits("100", 18));
    await stakingToken.connect(user1).approve(stakingDapp.address, ethers.utils.parseUnits("100", 18));
  });

  it("should allow staking and update staked amount", async function () {
    const stakeAmount = ethers.utils.parseUnits("50", 18);
    await stakingDapp.connect(user1).stake(stakeAmount);
    const staked = await stakingDapp.getStakedAmount(user1.address);
    expect(staked).to.equal(stakeAmount);
  });

  it("should allow unstaking and update staked amount", async function () {
    const stakeAmount = ethers.utils.parseUnits("50", 18);
    await stakingDapp.connect(user1).stake(stakeAmount);
    const unstakeAmount = ethers.utils.parseUnits("20", 18);
    await stakingDapp.connect(user1).unstake(unstakeAmount);
    const staked = await stakingDapp.getStakedAmount(user1.address);
    expect(staked).to.equal(stakeAmount.sub(unstakeAmount));
  });

  it("should calculate and claim rewards correctly", async function () {
    const`ethers.provider.send("setBlockTimestamp", [Math.floor(Date.now() / 1000) + REWARD_INTERVAL]);
    const stakeAmount = ethers.utils.parseUnits("10", 18);
    await stakingDapp.connect(user1).stake(stakeAmount);

    // Fast forward time
    await ethers.provider.send("evm_increaseTime", [REWARD_INTERVAL]);
    await ethers.provider.send("evm_mine");

    const reward = await stakingDapp.getRewardAmount(user1.address);
    const expectedReward = stakeAmount.mul(REWARD_AMOUNT).div(ethers.utils.parseUnits("1", 18));
    expect(reward).to.be.closeTo(expectedReward, ethers.utils.parseUnits("0.1", 18));

    await stakingDapp.connect(user1).claimReward();
    const rewardBalance = await rewardToken.balanceOf(user1.address);
    expect(rewardBalance).to.be.closeTo(expectedReward, ethers.utils.parseUnits("0.1", 18));
  });

  it("should revert when unstaking more than staked", async function () {
    const stakeAmount = ethers.utils.parseUnits("10", 18);
    await stakingDapp.connect(user1).stake(stakeAmount);
    const overUnstakeAmount = ethers.utils.parseUnits("20", 18);
    await expect(stakingDapp.connect(user1).unstake(overUnstakeAmount)).to.be.revertedWith("Insufficient balance");
  });
});

Run the tests with:

npx hardhat test

These tests validate the core functionality and edge cases, ensuring the contract behaves correctly.

Compile and Deploy Smart Contracts

Compiling Smart Contracts

To compile the smart contracts, run the command npx hardhat compile

Deploying Smart Contracts

  • Create a scripts folder in the root of your project.

  • Create a file deploy.js in the scripts folder.

  • Update the contents of the deploy.js file with the following:

const { ethers } = require("hardhat");

async function main() {
    const [deployer] = await ethers.getSigners();

    console.log("Deploying contracts with the account:", deployer.address);

    const StakingToken = await ethers.getContractFactory("StakingToken");
    const stakingToken = await StakingToken.deploy("Staking Token", "STK");
    await stakingToken.deployed();

    const RewardToken = await ethers.getContractFactory("RewardToken");
    const rewardToken = await RewardToken.deploy("Reward Token", "RTK");
    await rewardToken.deployed();

    const StakingDapp = await ethers.getContractFactory("StakingDapp");
    const stakingDapp = await StakingDapp.deploy(stakingToken.address, rewardToken.address);
    await stakingDapp.deployed();

    console.log("Contracts deployed:");
    console.log("Staking Token:", stakingToken.address);
    console.log("Reward Token:", rewardToken.address);
    console.log("Staking Dapp:", stakingDapp.address);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error);
        process.exit(1);
    });
  • Make sure your MetaMask wallet has rBTC test token for the Rootstock Testnet.

  • Run the following command from the root directory of your project, to deploy smart contracts on the Rootstock blockchain.

npx hardhat run scripts/deploy.js
  • If successfully deployed, you will get the following output

  • Save the addresses for the deployed contracts for use with the frontend.

Interacting with Smart Contract through Frontend

⚡️ Let's create a frontend interface for interacting with the smart contract.

Setting up frontend

Create a simple react application using the following command

npx create-react-app frontend
cd frontend
  • Install Dependencies, the Ethers.js library for communicating with the deployed smart contracts.
  npm install --save-dev ethers@5.6.9
  • Create a contracts folder inside the frontend/src folder.
mkdir components 
mkdir contracts

Copy the ABIs in the form of .json files, of your deployed smart contracts, from artifacts/contracts/RewardToken.sol artifacts/contracts/StakingToken.sol and artifacts/contracts/RewardToken.sol directories into the frontend/src/contracts directory.

  • Create a components folder in the frontend/src directory.
mkdir components
cd components

Adding Frontend Functionality

  • Update the App.js with this:
import { useEffect, useState, useCallback } from 'react';
import { ethers } from 'ethers';
import StakingToken from './contracts/StakingToken.json';
import StakingDapp from './contracts/StakingDapp.json';
import RewardToken from './contracts/RewardToken.json';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import Modal from './components/Modal';
import './App.css';

const stakingDappAddress = <YourstakingDappAddress>;
const stakingTokenAddress = <yourStakingTokenAddress>;
const rewardTokenAddress = <yourRewardTokenAddress>; 

function App() {
  const [stakingAmount, setStakingAmount] = useState('');
  const [unstakingAmount, setUnstakingAmount] = useState('');
  const [currentAccount, setCurrentAccount] = useState(null);
  const [stakedAmount, setStakedAmount] = useState('0');
  const [rewardAmount, setRewardAmount] = useState('0');
  const [totalStkBalance, setTotalStkBalance] = useState('0');
  const [network, setNetwork] = useState('');
  const [faucetAmount, setFaucetAmount] = useState('');
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [stakingTokenDecimals, setStakingTokenDecimals] = useState(18);
  const [rewardTokenDecimals, setRewardTokenDecimals] = useState(18);


  // Check if wallet is connected
  const checkWalletIsConnected = async () => {
    const { ethereum } = window;

    if (!ethereum) {
      console.log('Make sure you have Metamask installed!');
      return;
    }

    try {
      const accounts = await ethereum.request({ method: 'eth_accounts' });

      if (accounts.length !== 0) {
        const account = accounts[0];
        setCurrentAccount(account);
      } else {
        console.log('No authorized account found');
      }
    } catch (error) {
      console.error('Error fetching accounts:', error);
    }
  };

  // Check network
  const checkNetwork = async () => {
    const { ethereum } = window;

    if (!ethereum) {
      console.log('Ethereum object does not exist');
      return;
    }

    try {
      const provider = new ethers.providers.Web3Provider(ethereum);
      const { chainId } = await provider.getNetwork();

      if (chainId !== 31) {
        alert('Please connect to the Rootstock Testnet');
      } else {
        setNetwork('Rootstock Testnet');
      }
    } catch (error) {
      console.error('Error fetching network:', error);
    }
  };

  // Connect wallet
  const connectWalletHandler = async () => {
    const { ethereum } = window;

    if (!ethereum) {
      alert('Please install Metamask!');
      return;
    }

    try {
      const accounts = await ethereum.request({ method: 'eth_requestAccounts' });
      setCurrentAccount(accounts[0]);
    } catch (error) {
      console.error('Error connecting wallet:', error);
    }
  };

  // Disconnect wallet
  const disconnectWalletHandler = () => {
    setCurrentAccount(null);
    setStakedAmount('0');
    setRewardAmount('0');
    setTotalStkBalance('0');
    setNetwork('');
  };

  // Fetch staked and reward amounts
  const fetchStakedAndRewardAmounts = useCallback(async () => {
    try {
      const { ethereum } = window;

      if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = provider.getSigner();
        const stakingDappContract = new ethers.Contract(stakingDappAddress, StakingDapp.abi, signer);

        const stakedAmount = await stakingDappContract.getStakedAmount(currentAccount);
        const rewardAmount = await stakingDappContract.getRewardAmount(currentAccount);

        setStakedAmount(ethers.utils.formatUnits(stakedAmount, stakingTokenDecimals));
        setRewardAmount(ethers.utils.formatUnits(rewardAmount, rewardTokenDecimals));
      } else {
        console.log('Ethereum object does not exist');
      }
    } catch (error) {
      console.error('Error fetching staked and reward amounts:', error);
    }
  }, [currentAccount, stakingTokenDecimals, rewardTokenDecimals]);

  // Fetch staking token balance
  const fetchStkBalance = useCallback(async () => {
    try {
      const { ethereum } = window;

      if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const stakingTokenContract = new ethers.Contract(stakingTokenAddress, StakingToken.abi, provider);

        const balance = await stakingTokenContract.balanceOf(currentAccount);
        const decimals = await stakingTokenContract.decimals();
        setStakingTokenDecimals(decimals);
        setTotalStkBalance(ethers.utils.formatUnits(balance, decimals));
      } else {
        console.log('Ethereum object does not exist');
      }
    } catch (error) {
      console.error('Error fetching token balance:', error);
    }
  }, [currentAccount]);

  // Fetch reward token decimals
  const fetchRewardTokenDecimals = useCallback(async () => {
    try {
      const { ethereum } = window;

      if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const rewardTokenContract = new ethers.Contract(rewardTokenAddress, RewardToken.abi, provider);

        const decimals = await rewardTokenContract.decimals();
        setRewardTokenDecimals(decimals);
      } else {
        console.log('Ethereum object does not exist');
      }
    } catch (error) {
      console.error('Error fetching reward token decimals:', error);
    }
  }, []);

  useEffect(() => {
    checkWalletIsConnected();
  }, []);

  useEffect(() => {
    if (currentAccount) {
      checkNetwork();
      fetchStakedAndRewardAmounts();
      fetchStkBalance();
      fetchRewardTokenDecimals();
    }
  }, [currentAccount, fetchStakedAndRewardAmounts, fetchStkBalance, fetchRewardTokenDecimals]);


  // Stake tokens
  const stakeTokens = async () => {
  try {
    if (!isValidAmount(stakingAmount)) {
      toast.error('Invalid staking amount. Please enter a positive number.');
      return;
    }

    const { ethereum } = window;

    if (ethereum) {
      const provider = new ethers.providers.Web3Provider(ethereum);
      const signer = provider.getSigner();
      const stakingDappContract = new ethers.Contract(stakingDappAddress, StakingDapp.abi, signer);
      const tokenContract = new ethers.Contract(stakingTokenAddress, StakingToken.abi, signer);

      const amount = ethers.utils.parseUnits(stakingAmount, stakingTokenDecimals);
      // Estimate gas for approve
      const approveGasEstimate = await tokenContract.estimateGas.approve(stakingDappAddress, amount);
      const approveGasLimit = approveGasEstimate.mul(120).div(100); // Add 20% buffer
      await tokenContract.approve(stakingDappAddress, amount, { gasLimit: approveGasLimit });

      // Estimate gas for stake
      const stakeGasEstimate = await stakingDappContract.estimateGas.stake(amount);
      const stakeGasLimit = stakeGasEstimate.mul(120).div(100); // Add 20% buffer
      const tx = await stakingDappContract.stake(amount, { gasLimit: stakeGasLimit });
      await tx.wait();
      toast.success('Staked successfully');
      fetchStakedAndRewardAmounts();
      fetchStkBalance();
    } else {
      console.log('Ethereum object does not exist');
    }
  } catch (error) {
    console.error('Error staking tokens:', error);
    toast.error('Error staking tokens');
  }
};

  // Unstake tokens
  const unstakeTokens = async () => {
  try {
    if (!isValidAmount(unstakingAmount)) {
      toast.error('Invalid unstaking amount. Please enter a positive number.');
      return;
    }

    if (parseFloat(unstakingAmount) > parseFloat(stakedAmount)) {
      toast.error('Enter value equal to or less than the Staked STK.');
      return;
    }

    const { ethereum } = window;

    if (ethereum) {
      const provider = new ethers.providers.Web3Provider(ethereum);
      const signer = provider.getSigner();
      const stakingDappContract = new ethers.Contract(stakingDappAddress, StakingDapp.abi, signer);

      const amount = ethers.utils.parseUnits(unstakingAmount, stakingTokenDecimals);
      // Estimate gas for unstake
      const gasEstimate = await stakingDappContract.estimateGas.unstake(amount);
      const gasLimit = gasEstimate.mul(120).div(100); // Add 20% buffer
      const tx = await stakingDappContract.unstake(amount, { gasLimit });
      await tx.wait();
      toast.success('Unstaked successfully');
      fetchStakedAndRewardAmounts();
      fetchStkBalance();
    } else {
      console.log('Ethereum object does not exist');
    }
  } catch (error) {
    console.error('Error unstaking tokens:', error);
    toast.error('Error unstaking tokens');
  }
};

  // Open reward modal
  const openRewardModal = async () => {
    try {
      const { ethereum } = window;

      if (ethereum) {
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = provider.getSigner();
        const stakingDappContract = new ethers.Contract(stakingDappAddress, StakingDapp.abi, signer);

        const reward = await stakingDappContract.getRewardAmount(currentAccount);
        const formattedReward = ethers.utils.formatUnits(reward, rewardTokenDecimals);
        console.log(formattedReward);
        if (parseFloat(formattedReward) > 0) {
          setRewardAmount(formattedReward);
          setIsModalOpen(true);
        } else {
          toast.info('No rewards available to claim.');
        }
      } else {
        console.log('Ethereum object does not exist');
      }
    } catch (error) {
      console.error('Error fetching reward amount:', error);
      toast.error('Error fetching reward amount');
    }
  };

  // Claim reward
  const claimReward = async () => {
  try {
    if (parseFloat(rewardAmount) <= 0) {
      toast.error('Cannot claim reward. Amount must be greater than zero.');
      return;
    }

    const { ethereum } = window;

    if (ethereum) {
      const provider = new ethers.providers.Web3Provider(ethereum);
      const signer = provider.getSigner();
      const stakingDappContract = new ethers.Contract(stakingDappAddress, StakingDapp.abi, signer);

      // Estimate gas with a buffer
      const gasEstimate = await stakingDappContract.estimateGas.claimReward();
      const gasLimit = gasEstimate.mul(120).div(100); // Add 20% buffer

      const tx = await stakingDappContract.claimReward({ gasLimit });
      await tx.wait();
      toast.success('Reward claimed successfully');
      setIsModalOpen(false);
      fetchStakedAndRewardAmounts();
      fetchStkBalance();
    } else {
      console.log('Ethereum object does not exist');
    }
  } catch (error) {
    console.error('Error claiming reward:', error);
    toast.error('Error claiming reward. Please check the console for details.');
  }
};

  // Faucet tokens
 const faucetTokens = async (amount) => {
  try {
    if (!isValidAmount(amount)) {
      toast.error('Invalid faucet amount. Please enter a positive number less than 100.');
      return;
    }

    const parsedAmount = parseFloat(amount);
    if (parsedAmount >= 100) {
      toast.error('Request amount must be less than 100.');
      return;
    }

    const { ethereum } = window;

    if (ethereum) {
      const provider = new ethers.providers.Web3Provider(ethereum);
      const signer = provider.getSigner();
      const stakingTokenContract = new ethers.Contract(stakingTokenAddress, StakingToken.abi, signer);

      // Estimate gas with a buffer
      const gasEstimate = await stakingTokenContract.estimateGas.mint(
        currentAccount,
        ethers.utils.parseUnits(amount, stakingTokenDecimals)
      );
      const gasLimit = gasEstimate.mul(120).div(100); // Add 20% buffer

      const tx = await stakingTokenContract.mint(
        currentAccount,
        ethers.utils.parseUnits(amount, stakingTokenDecimals),
        { gasLimit }
      );
      await tx.wait();
      toast.success('Tokens minted successfully');
      fetchStkBalance();
    } else {
      console.log('Ethereum object does not exist');
    }
  } catch (error) {
    console.error('Error minting tokens:', error);
    toast.error('Error minting tokens');
  }
};

  // Validate amount
  const isValidAmount = (amount) => {
    return !isNaN(Number(amount)) && parseFloat(amount) > 0;
  };

  return (
    <div className="App">
      <header className="App-header">
        <h1>Staking Dapp</h1>
        {currentAccount && (
          <button onClick={disconnectWalletHandler} className="btn-primary disconnect-btn">
            Disconnect Wallet
          </button>
        )}
      </header>
      <main>
        {!currentAccount ? (
          <button onClick={connectWalletHandler} className="btn-primary">
            Connect Wallet
          </button>
        ) : (
          <>
            <div className="ticker-container">
              <p>STK Balance: {totalStkBalance} STK</p>
              <p>Staked STK Amount: {stakedAmount} STK</p>
              <p>Reward Amount to be Claimed: {rewardAmount} RTK</p>
              <button onClick={openRewardModal} className="btn-primary">
                Claim Reward
              </button>
            </div>
            <div>
              <input
                type="text"
                placeholder="Amount to stake"
                value={stakingAmount}
                onChange={(e) => setStakingAmount(e.target.value)}
                className="input-field"
              />
              <button onClick={stakeTokens} className="btn-primary">
                Stake
              </button>
            </div>
            <div>
              <input
                type="text"
                placeholder="Amount to unstake"
                value={unstakingAmount}
                onChange={(e) => setUnstakingAmount(e.target.value)}
                className="input-field"
              />
              <button onClick={unstakeTokens} className="btn-primary">
                Unstake
              </button>
            </div>
            <div>
              <input
                type="text"
                placeholder="Faucet amount"
                value={faucetAmount}
                onChange={(e) => setFaucetAmount(e.target.value)}
                className="input-field"
              />
              <button onClick={() => faucetTokens(faucetAmount)} className="btn-primary">
                STK Faucet
              </button>
            </div>
          </>
        )}
      </main>
      <ToastContainer />
      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        onClaim={claimReward}
        rewardAmount={rewardAmount}
      />
    </div>
  );
}

export default App;
  • Update the App.css with this:
/* App.css */
body {
  margin: 0;
  font-family: Arial, sans-serif;
  background-color: #f4f4f9;
  color: #333;
}

.App {
  text-align: center;
  padding: 20px;
  position: relative; /* Ensure the App container is positioned relatively for absolute children */
}

.logo-container {
  text-align: center;
  margin-bottom: 20px;
}

.logo {
  max-width: 150px; /* Adjust as needed for logo size */
}

.App-header {
  background-color: #f8f9fa;
  padding: 20px;
  border-bottom: 2px solid #eaeaea;
  position: relative; /* To position elements inside it */
  margin-bottom: 20px; /* Space between header and content */
}



.btn-primary {
  background-color: #ff6f00; 
  color: white;
  border: none;
  padding: 12px 20px;
  font-size: 16px;
  cursor: pointer;
  border-radius: 5px;
  width: 200px; /* Set a consistent width for all buttons */
  height: 40px; /* Set a consistent height for all buttons */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  margin: 10px; /* Add margin to separate buttons */
}

.btn-primary:hover {
  background-color: #e65c00; /* Darker orange for hover effect */
}

.input-field {
  padding: 10px;
  font-size: 16px;
  border: 1px solid #ccc;
  border-radius: 5px;
  width: 200px;
  margin: 5px;
}

.input-field:focus {
  border-color: #ff6f00;
  outline: none;
}

.amount-ticker {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.amount-ticker p {
  margin: 5px 20px;
  font-size: 18px;
}

.stake-container,
.unstake-container {
  margin: 20px 0;
}

.stake-container input,
.unstake-container input {
  width: 200px;
}

.modal-btns {
  display: flex;
  justify-content: center;
  gap: 10px;
}

.disconnect-btn {
  position: absolute;
  top: 20px;
  right: 20px;
}

@media (max-width: 768px) {
  .amount-ticker {
    flex-direction: column;
    align-items: flex-start;
  }

  .amount-ticker p {
    margin: 5px 0;
  }

  .disconnect-btn {
    position: relative;
    top: auto;
    right: auto;
  }
}
  • Create a Modal.js file inside the components directory. Update the file with this:
import React from 'react';

const Modal = ({ isOpen, onClose, onClaim, rewardAmount }) => {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-content">
        <h2>Claim Reward</h2>
        <p>Your reward amount is: {rewardAmount} REWARD</p>
        <button onClick={onClaim} className="btn-primary">
          Claim
        </button>
        <button onClick={onClose} className="btn-secondary">
          Close
        </button>
      </div>
    </div>
  );
};

export default Modal;
  • Create a Modal.css file in the components directory. Update the file with this:
/* components/Modal.module.css */
.modalOverlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .modalContent {
    background: white;
    padding: 20px;
    border-radius: 8px;
    width: 400px;
    text-align: center;
  }

  .inputField {
    width: 100%;
    padding: 10px;
    margin-top: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }

  .btnPrimary {
    background-color: #007bff;
    color: white;
    border: none;
    padding: 10px 20px;
    margin: 10px;
    border-radius: 4px;
    cursor: pointer;
  }

  .btnSecondary {
    background-color: #6c757d;
    color: white;
    border: none;
    padding: 10px 20px;
    margin: 10px;
    border-radius: 4px;
    cursor: pointer;
  }

  .errorMessage {
    color: red;
    margin-top: 10px;
  }

Frontend Explanation

The frontend, built with ReactJs and Ethers, enables users to interact with the deployed smart contracts. Here’s how it works:

1. Wallet Connection

  • On app load, it checks for MetaMask installation and connection.

  • If no wallet is connected, a "Connect Wallet" button is displayed.

  • Once connected, the user's wallet address is stored, and the app listens for account or network changes.

2. Network Detection

  • Verifies if the user is on the Rootstock Testnet (chain ID 31).

  • Displays a warning if the wrong network is selected.

3. Staking Tokens

  • Users input the amount of STK tokens to stake.

  • The app prompts MetaMask to approve the staking contract to spend the tokens.

  • A transaction is sent to the stake function, with gas estimated dynamically.

4. Unstaking

  • Users input the amount to unstake, which must not exceed their staked balance.

  • The app sends a transaction to the unstake function, with gas estimated dynamically.

5. Claiming Rewards

  • Users earn RTK rewards based on their staked STK and time elapsed.

  • Clicking "Claim Rewards" opens a modal showing the reward amount.

  • The claimReward function is called with dynamically estimated gas.

6. Faucet (Testnet Only)

  • Users can mint test STK tokens (up to 100) via the faucet.

  • The mint function is called with dynamic gas estimation.

7. Gas Handling

The frontend uses estimateGas for stake, unstake, claimReward, and faucetTokens transactions, with a 20% buffer to ensure reliability.

📦 What It Tracks (State Variables)

Here’s what the app keeps track of in the background:

  • Your wallet address (currentAccount)

  • Your STK balance (totalStkBalance)

  • How much you staked (stakedAmount)

  • Your RTK rewards (rewardAmount)

  • The selected staking/unstaking amounts

  • Whether the claim modal is open

It also fetches decimals from the token contracts so values show up in human-readable form (not raw Wei).

UI Components

  • MetaMask connect/disconnect buttons.

  • Token balances and staked/reward info displayed as a ticker.

  • Input fields for staking, unstaking, and faucet.

  • Modal (reusable component) for claiming rewards.

  • Toast notifications to show success/error messages.

Adding Smart Contract Details

  • Paste this into Lines 12, 13, and 14 the addresses of the deployed contracts of StakingToken, RewardToken and Staking Dapp.
const stakingDappAddress = '0xAddress_of_Staking_Dapp_Contract';
const stakingTokenAddress = '0xAddress_of_Staking_Token_Contract';
const rewardTokenAddress = '0xAddress_of_Reward_Token_Contract';

Testing Locally

  • From the frontend directory, run the command npm run start

  • Make sure that your MetaMask wallet is correctly installed and switched to Rootstock Testnet as described in our Rootstock Testnet user guide. You'll also need to connect your MetaMask wallet to the local site.

  • Navigate to http://localhost:3000/ in your browser.

  • Enter an amount less than 100 and click on the STK Faucet to get STK tokens

  • Enter an amount greater than 0 and less than or equal to your STK balance and click Stake to stake tokens

  • Enter an amount greater than 0 and less than or equal to your staked balance and click Unstake to unstake tokens

🎉 Congratulations!

You've just interacted with your deployed contract using your dApp's front end! You can build on the codebase by adding new UI components or more functionality to the staking dapp.

Conclusion

In this tutorial, you’ve learned how to

  • Set up a hardhat-based development environment

  • Implement a staking smart contract with erc20 tokens

  • Deploy your contract to Rootstock and interact with a frontend

If you encounter any errors, feel free to join the Rootstock Discord and ask for help in the appropriate channel.

To dive deeper into Rootstock, explore the official documentation.

0
Subscribe to my newsletter

Read articles from Akanimo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Akanimo
Akanimo

I am a Frontend Engineer who is passionate about tech and the web generally. I write about the best programming practices, concepts, and usage of tech products (documentations).