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

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
Git v2.44.0
Node.js v20.11.1
npm v10.2.4
Hardhat v2.22.6
Rootstock Testnet Configuration: Configure MetaMask to connect to the Rootstock Testnet. Refer here for more details.
Network Name: Rootstock Testnet
RPC URL: https://rpc.testnet.rootstock.io
Chain ID: 31
Currency Symbol: tRBTC
Block Explorer URL: https://explorer.testnet.rootstock.io/
tRBTC Faucet: To get test tRBTC tokens for transactions, visit the Rootstock Faucet
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 theirSTK
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 asRTK
tokens for staking theirSTK
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);
}
}
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
getStakedAmount(address user): Returns the amount of STK tokens staked by a user.
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.
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.
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.
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 to5 * 10^18
(5 RTK tokens, scaled for 18 decimals) per reward interval. TheREWARD_INTERVAL
is set to 3600 seconds (1 hour), meaning rewards are calculated every hour based on the staked amount. The reward formula is:
- The
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 thescripts
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 thefrontend/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 thefrontend/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 thecomponents
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 thecomponents
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 commandnpm 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 tokensEnter 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.
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).