From 30dc10dc012110b8cb8f8ae4ae87cbbf855a6785 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 22:40:00 +0200 Subject: [PATCH] feat(gauge): add staking gauge with reward distribution --- src/StakingGauge.sol | 131 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/StakingGauge.sol diff --git a/src/StakingGauge.sol b/src/StakingGauge.sol new file mode 100644 index 0000000..4eb1d1a --- /dev/null +++ b/src/StakingGauge.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +import {IErc20} from "./interfaces/IErc20.sol"; + +/// @title Staking gauge distributing a reward token pro rata by staked share over time. +contract StakingGauge { + IErc20 public immutable stakingToken; + IErc20 public immutable rewardToken; + address public owner; + + uint256 public rewardRate; + uint256 public periodFinish; + uint256 public rewardsDuration = 7 days; + uint256 public lastUpdateTime; + uint256 public rewardPerTokenStored; + + mapping(address => uint256) public userRewardPerTokenPaid; + mapping(address => uint256) public rewards; + + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + + event Staked(address indexed account, uint256 amount); + event Withdrawn(address indexed account, uint256 amount); + event RewardPaid(address indexed account, uint256 reward); + event RewardAdded(uint256 reward); + event RewardsDurationUpdated(uint256 duration); + + constructor(address staking, address reward, address ownerAddress) { + stakingToken = IErc20(staking); + rewardToken = IErc20(reward); + owner = ownerAddress; + } + + modifier onlyOwner() { + require(msg.sender == owner, "StakingGauge: FORBIDDEN"); + _; + } + + modifier updateReward(address account) { + rewardPerTokenStored = rewardPerToken(); + lastUpdateTime = lastTimeRewardApplicable(); + if (account != address(0)) { + rewards[account] = earned(account); + userRewardPerTokenPaid[account] = rewardPerTokenStored; + } + _; + } + + function _safeTransfer(IErc20 token, address to, uint256 value) private { + (bool success, bytes memory returndata) = + address(token).call(abi.encodeWithSelector(IErc20.transfer.selector, to, value)); + require(success && (returndata.length == 0 || abi.decode(returndata, (bool))), "StakingGauge: TRANSFER_FAILED"); + } + + function _safeTransferFrom(IErc20 token, address from, address to, uint256 value) private { + (bool success, bytes memory returndata) = + address(token).call(abi.encodeWithSelector(IErc20.transferFrom.selector, from, to, value)); + require( + success && (returndata.length == 0 || abi.decode(returndata, (bool))), "StakingGauge: TRANSFER_FROM_FAILED" + ); + } + + function lastTimeRewardApplicable() public view returns (uint256) { + return block.timestamp < periodFinish ? block.timestamp : periodFinish; + } + + function rewardPerToken() public view returns (uint256) { + if (totalSupply == 0) return rewardPerTokenStored; + return rewardPerTokenStored + ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalSupply; + } + + function earned(address account) public view returns (uint256) { + return (balanceOf[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18 + rewards[account]; + } + + function stake(uint256 amount) external updateReward(msg.sender) { + require(amount > 0, "StakingGauge: ZERO_STAKE"); + totalSupply += amount; + balanceOf[msg.sender] += amount; + _safeTransferFrom(stakingToken, msg.sender, address(this), amount); + emit Staked(msg.sender, amount); + } + + function withdraw(uint256 amount) public updateReward(msg.sender) { + require(amount > 0, "StakingGauge: ZERO_WITHDRAW"); + totalSupply -= amount; + balanceOf[msg.sender] -= amount; + _safeTransfer(stakingToken, msg.sender, amount); + emit Withdrawn(msg.sender, amount); + } + + function getReward() public updateReward(msg.sender) { + uint256 reward = rewards[msg.sender]; + if (reward > 0) { + rewards[msg.sender] = 0; + _safeTransfer(rewardToken, msg.sender, reward); + emit RewardPaid(msg.sender, reward); + } + } + + function exit() external { + withdraw(balanceOf[msg.sender]); + getReward(); + } + + function notifyRewardAmount(uint256 reward) external onlyOwner updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + uint256 balance = rewardToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "StakingGauge: REWARD_TOO_HIGH"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } + + function setRewardsDuration(uint256 duration) external onlyOwner { + require(block.timestamp > periodFinish, "StakingGauge: PERIOD_ACTIVE"); + require(duration > 0, "StakingGauge: ZERO_DURATION"); + rewardsDuration = duration; + emit RewardsDurationUpdated(duration); + } +}