This content originally appeared on DEV Community and was authored by Saurav Kumar
Turn your tokens into a source of passive income
Learn how staking and yield farming work by building your own reward distribution system on Ethereum!
Overview
In this project, weโll build a Staking Rewards System โ where users deposit tokens and earn periodic rewards in another token.
Itโs just like a digital savings account that pays interest in tokens โ demonstrating one of the core mechanisms in DeFi (Decentralized Finance).
Project Structure
day-27-staking-rewards/
โโ foundry.toml
โโ src/
โ โโ MockERC20.sol
โ โโ StakingRewards.sol
โโ script/
โ โโ Deploy.s.sol
โโ test/
โโ StakingRewards.t.sol
Weโll use Foundry for smart contract testing and deployment โ itโs fast, lightweight, and ideal for local blockchain development.
Step 1 โ Setup
Install Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup
Initialize the project:
forge init day-27-staking-rewards
cd day-27-staking-rewards
Step 2 โ Create a Mock Token
Weโll create a mock ERC20 token for both staking and rewards.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
Step 3 โ Build the StakingRewards Contract
Hereโs the core of our yield farming system.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract StakingRewards is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable stakeToken;
IERC20 public immutable rewardToken;
uint256 public totalSupply;
mapping(address => uint256) public balances;
uint256 public rewardPerTokenStored;
uint256 public lastUpdateTime;
uint256 public rewardRate;
uint256 private constant PRECISION = 1e18;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
event Staked(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event RewardPaid(address indexed user, uint256 reward);
event RewardRateUpdated(uint256 oldRate, uint256 newRate);
constructor(address _stakeToken, address _rewardToken) {
stakeToken = IERC20(_stakeToken);
rewardToken = IERC20(_rewardToken);
lastUpdateTime = block.timestamp;
}
modifier updateReward(address account) {
_updateRewardPerToken();
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
function _updateRewardPerToken() internal {
if (totalSupply == 0) {
lastUpdateTime = block.timestamp;
return;
}
uint256 time = block.timestamp - lastUpdateTime;
uint256 rewardAccrued = time * rewardRate;
rewardPerTokenStored += (rewardAccrued * PRECISION) / totalSupply;
lastUpdateTime = block.timestamp;
}
function notifyRewardAmount(uint256 reward, uint256 duration)
external
onlyOwner
updateReward(address(0))
{
require(duration > 0, "duration>0");
rewardToken.safeTransferFrom(msg.sender, address(this), reward);
uint256 newRate = reward / duration;
emit RewardRateUpdated(rewardRate, newRate);
rewardRate = newRate;
}
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "amount>0");
totalSupply += amount;
balances[msg.sender] += amount;
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
require(amount > 0, "amount>0");
totalSupply -= amount;
balances[msg.sender] -= amount;
stakeToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
function getReward() public nonReentrant updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
rewardToken.safeTransfer(msg.sender, reward);
emit RewardPaid(msg.sender, reward);
}
}
function exit() external {
withdraw(balances[msg.sender]);
getReward();
}
function earned(address account) public view returns (uint256) {
uint256 _balance = balances[account];
uint256 _rewardPerToken = rewardPerTokenStored;
if (totalSupply != 0) {
uint256 time = block.timestamp - lastUpdateTime;
uint256 pending = time * rewardRate;
_rewardPerToken += (pending * PRECISION) / totalSupply;
}
return (_balance * (_rewardPerToken - userRewardPerTokenPaid[account])) / PRECISION + rewards[account];
}
}
Step 4 โ Write Tests (Foundry)
Hereโs a simple test case to verify our staking logic:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/MockERC20.sol";
import "../src/StakingRewards.sol";
contract StakingRewardsTest is Test {
MockERC20 stake;
MockERC20 reward;
StakingRewards staking;
address alice = address(0xA11CE);
function setUp() public {
stake = new MockERC20("Stake Token", "STK", 0);
reward = new MockERC20("Reward Token", "RWD", 0);
staking = new StakingRewards(address(stake), address(reward));
stake.mint(alice, 1000 ether);
reward.mint(address(this), 1000 ether);
reward.approve(address(staking), type(uint256).max);
}
function testStakeAndEarn() public {
staking.notifyRewardAmount(100 ether, 100);
vm.prank(alice);
stake.approve(address(staking), 100 ether);
vm.prank(alice);
staking.stake(100 ether);
vm.warp(block.timestamp + 50);
uint256 earned = staking.earned(alice);
assertApproxEqRel(earned, 50 ether, 1e16);
}
}
Run tests:
forge test
Step 5 โ Deploy Script
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "forge-std/Script.sol";
import "../src/MockERC20.sol";
import "../src/StakingRewards.sol";
contract DeployScript is Script {
function run() public {
vm.startBroadcast();
MockERC20 stake = new MockERC20("Stake Token", "STK", 1_000_000 ether);
MockERC20 reward = new MockERC20("Reward Token", "RWD", 1_000_000 ether);
StakingRewards staking = new StakingRewards(address(stake), address(reward));
vm.stopBroadcast();
}
}
Deploy:
forge script script/Deploy.s.sol --rpc-url <RPC_URL> --private-key <PRIVATE_KEY> --broadcast
Security Tips
Use nonReentrant modifier
Validate inputs before transfers
Keep reward logic owner-only
Test for edge cases like 0 stake or high reward rates
What You Built
- A complete staking & yield farming platform
- Users can stake, earn rewards, and withdraw anytime
- Reward distribution is fair and time-based
- Built with Foundry, OpenZeppelin, and best Solidity practices
Next Challenges
- Add multiple staking pools
- Introduce NFT-based reward boosts
- Build a React or Next.js frontend using Ethers.js
- Automate reward top-ups with Chainlink Keepers
GitHub Repository
GitHub: Day 27 โ Staking & Yield Farming (Solidity)
This content originally appeared on DEV Community and was authored by Saurav Kumar