From e3dda8350057faddba85ad03e148cfa07a3692f4 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 15 May 2025 15:13:57 +0200 Subject: [PATCH 1/8] Implements the NODLStaking contract for NODL token staking management. Includes functions to perform staking, claim rewards, withdraw in case of emergency and allow unstake. Events are added to track actions and validations are implemented to ensure proper staking conditions --- src/NodlStaking.sol | 178 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/NodlStaking.sol diff --git a/src/NodlStaking.sol b/src/NodlStaking.sol new file mode 100644 index 0000000..abf2b68 --- /dev/null +++ b/src/NodlStaking.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {NODL} from "./NODL.sol"; + +contract NODLStaking is Ownable, ReentrancyGuard { + NODL public immutable token; + + uint256 public immutable MIN_STAKE; + uint256 public immutable MAX_TOTAL_STAKE; + uint256 public immutable DURATION; + uint256 public immutable rewardRate; + bool public unstakeAllowed = false; + + uint256 public totalStaked; + + struct StakeInfo { + uint256 amount; + uint256 start; + bool claimed; + } + + mapping(address => StakeInfo) public stakes; + + error ZeroAddress(); + error InvalidRewardRate(); + error MinStakeNotMet(); + error ExceedsMaxTotalStake(); + error AlreadyStaked(); + error NoStakeFound(); + error AlreadyClaimed(); + error TooEarly(); + error NoStake(); + error UnstakeNotAllowed(); + + event Staked(address indexed user, uint256 amount); + event Claimed(address indexed user, uint256 amount, uint256 reward); + event EmergencyWithdrawn(address indexed owner, uint256 amount); + event UnstakeAllowedUpdated(bool allowed); + event Unstaked(address indexed user, uint256 amount); + + /* + @dev Constructor + @param nodlToken The address of the NODL token + @param _rewardRate The reward rate, represented in percentage + @param _minStake The minimum stake, represented in Eth + @param _maxTotalStake The maximum total stake, represented in Eth + @param _duration The duration of the stake, represented in days + */ + constructor(address nodlToken, uint256 _rewardRate, uint256 _minStake, uint256 _maxTotalStake, uint256 _duration) { + if (nodlToken == address(0)) revert ZeroAddress(); + if (_rewardRate <= 0) revert InvalidRewardRate(); + token = NODL(nodlToken); + rewardRate = _rewardRate; + MIN_STAKE = _minStake * 1e18; + MAX_TOTAL_STAKE = _maxTotalStake * 1e18; + DURATION = _duration; + } + + /* + @dev Stake + @param amount The amount of tokens to stake + @notice The stake can only be staked if the amount is greater than the minimum stake + @notice The stake can only be staked if the total staked amount is less than the maximum total stake + @notice The stake can only be staked if the user has not already staked + */ + function stake(uint256 amount) external nonReentrant { + if (amount < MIN_STAKE) revert MinStakeNotMet(); + if (totalStaked + amount > MAX_TOTAL_STAKE) revert ExceedsMaxTotalStake(); + if (stakes[msg.sender].amount != 0) revert AlreadyStaked(); + + token.transferFrom(msg.sender, address(this), amount); + + stakes[msg.sender] = StakeInfo({amount: amount, start: block.timestamp, claimed: false}); + + totalStaked += amount; + + emit Staked(msg.sender, amount); + } + + /* + @dev Claim + @notice The stake can only be claimed if the stake has not been claimed + @notice The stake can only be claimed if the stake has not been unstaked + @notice The stake can only be claimed if the stake has not been claimed + */ + function claim() external nonReentrant { + StakeInfo storage s = stakes[msg.sender]; + if (s.amount == 0) revert NoStakeFound(); + if (s.claimed) revert AlreadyClaimed(); + if (block.timestamp < s.start + DURATION) revert TooEarly(); + + s.claimed = true; + + uint256 reward = (s.amount * rewardRate) / 100; + token.mint(msg.sender, reward); + token.transfer(msg.sender, s.amount); + + emit Claimed(msg.sender, s.amount, reward); + } + + /* + @dev Emergency withdraw + @notice The owner can withdraw the tokens in case of emergency + */ + function emergencyWithdraw() external onlyOwner { + token.transfer(owner(), token.balanceOf(address(this))); + + emit EmergencyWithdrawn(owner(), token.balanceOf(address(this))); + } + + /* + @dev Update the unstake allowed status + @param allowed The new unstake allowed status + */ + function updateUnestakeAllowed(bool allowed) external onlyOwner { + unstakeAllowed = allowed; + + emit UnstakeAllowedUpdated(allowed); + } + + /* + @dev Unstake + @notice The stake can only be unstaked if the unstake is allowed + @notice The stake can only be unstaked if the user has a stake + @notice The stake can only be unstaked if the stake has not been claimed + */ + function unstake() external nonReentrant { + StakeInfo storage s = stakes[msg.sender]; + if (!unstakeAllowed) revert UnstakeNotAllowed(); + if (s.amount == 0) revert NoStake(); + if (s.claimed) revert AlreadyClaimed(); + + uint256 returnAmount = s.amount; + + s.amount = 0; + s.claimed = true; + totalStaked -= s.amount; + + token.transfer(msg.sender, returnAmount); + + emit Unstaked(msg.sender, returnAmount); + } + + /* + @dev Get stake info + @param user The address of the user to get the stake info for + @return amount The amount of the stake + @return start The start time of the stake + @return claimed Whether the stake has been claimed + @return timeLeft The remaining time of the stake + @return potentialReward The potential reward of the stake + */ + function getStakeInfo(address user) + external + view + { + StakeInfo storage s = stakes[user]; + amount = s.amount; + start = s.start; + claimed = s.claimed; + + // remaining time + if (s.amount > 0 && !s.claimed) { + if (block.timestamp < s.start + DURATION) { + timeLeft = s.start + DURATION - block.timestamp; + } + } + + // potential reward + if (s.amount > 0 && !s.claimed) { + potentialReward = (s.amount * rewardRate) / 100; + } + } +} From 2eef95565f9db57e1c2eb8a169a542b5037c88b9 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 21 May 2025 14:36:31 +0200 Subject: [PATCH 2/8] Implements the pause functionality in the NODLStaking contract. Pausable modifier is added to allow pausing and resuming staking operations, as well as new validations and related events. Custom errors are introduced to improve exception handling, and staking, reclaiming and unstaking functions are adjusted to respect the paused state --- src/NodlStaking.sol | 86 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/NodlStaking.sol b/src/NodlStaking.sol index abf2b68..549fb9a 100644 --- a/src/NodlStaking.sol +++ b/src/NodlStaking.sol @@ -4,9 +4,10 @@ pragma solidity 0.8.23; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; import {NODL} from "./NODL.sol"; -contract NODLStaking is Ownable, ReentrancyGuard { +contract NODLStaking is Ownable, ReentrancyGuard, Pausable { NODL public immutable token; uint256 public immutable MIN_STAKE; @@ -27,6 +28,9 @@ contract NODLStaking is Ownable, ReentrancyGuard { error ZeroAddress(); error InvalidRewardRate(); + error InvalidMinStake(); + error InvalidMaxTotalStake(); + error InvalidDuration(); error MinStakeNotMet(); error ExceedsMaxTotalStake(); error AlreadyStaked(); @@ -35,12 +39,18 @@ contract NODLStaking is Ownable, ReentrancyGuard { error TooEarly(); error NoStake(); error UnstakeNotAllowed(); + error InsufficientRewardBalance(); + error InsufficientAllowance(); + error InsufficientTotalStaked(); event Staked(address indexed user, uint256 amount); event Claimed(address indexed user, uint256 amount, uint256 reward); event EmergencyWithdrawn(address indexed owner, uint256 amount); event UnstakeAllowedUpdated(bool allowed); event Unstaked(address indexed user, uint256 amount); + event RewardsFunded(uint256 amount); + event Paused(address account); + event Unpaused(address account); /* @dev Constructor @@ -53,11 +63,15 @@ contract NODLStaking is Ownable, ReentrancyGuard { constructor(address nodlToken, uint256 _rewardRate, uint256 _minStake, uint256 _maxTotalStake, uint256 _duration) { if (nodlToken == address(0)) revert ZeroAddress(); if (_rewardRate <= 0) revert InvalidRewardRate(); + if (_minStake == 0) revert InvalidMinStake(); + if (_maxTotalStake * 1e18 <= _minStake * 1e18) revert InvalidMaxTotalStake(); + if (_duration == 0) revert InvalidDuration(); + token = NODL(nodlToken); rewardRate = _rewardRate; MIN_STAKE = _minStake * 1e18; MAX_TOTAL_STAKE = _maxTotalStake * 1e18; - DURATION = _duration; + DURATION = _duration * 1 days; } /* @@ -67,7 +81,7 @@ contract NODLStaking is Ownable, ReentrancyGuard { @notice The stake can only be staked if the total staked amount is less than the maximum total stake @notice The stake can only be staked if the user has not already staked */ - function stake(uint256 amount) external nonReentrant { + function stake(uint256 amount) external nonReentrant whenNotPaused { if (amount < MIN_STAKE) revert MinStakeNotMet(); if (totalStaked + amount > MAX_TOTAL_STAKE) revert ExceedsMaxTotalStake(); if (stakes[msg.sender].amount != 0) revert AlreadyStaked(); @@ -81,23 +95,40 @@ contract NODLStaking is Ownable, ReentrancyGuard { emit Staked(msg.sender, amount); } + /* + @dev Fund rewards + @param amount The amount of tokens to fund rewards + @notice Only owner can fund rewards + @notice Requires sufficient allowance from owner to contract + */ + function fundRewards(uint256 amount) external onlyOwner whenNotPaused { + uint256 allowance = token.allowance(msg.sender, address(this)); + if (allowance < amount) revert InsufficientAllowance(); + + token.transferFrom(msg.sender, address(this), amount); + emit RewardsFunded(amount); + } + /* @dev Claim @notice The stake can only be claimed if the stake has not been claimed @notice The stake can only be claimed if the stake has not been unstaked @notice The stake can only be claimed if the stake has not been claimed + @notice The contract must have enough balance for both stake and reward */ - function claim() external nonReentrant { + function claim() external nonReentrant whenNotPaused { StakeInfo storage s = stakes[msg.sender]; if (s.amount == 0) revert NoStakeFound(); if (s.claimed) revert AlreadyClaimed(); if (block.timestamp < s.start + DURATION) revert TooEarly(); - s.claimed = true; - uint256 reward = (s.amount * rewardRate) / 100; - token.mint(msg.sender, reward); - token.transfer(msg.sender, s.amount); + uint256 totalToTransfer = s.amount + reward; + uint256 contractBalance = token.balanceOf(address(this)); + if (totalToTransfer > contractBalance) revert InsufficientRewardBalance(); + + s.claimed = true; + token.transfer(msg.sender, totalToTransfer); emit Claimed(msg.sender, s.amount, reward); } @@ -116,9 +147,8 @@ contract NODLStaking is Ownable, ReentrancyGuard { @dev Update the unstake allowed status @param allowed The new unstake allowed status */ - function updateUnestakeAllowed(bool allowed) external onlyOwner { + function updateUnestakeAllowed(bool allowed) external onlyOwner whenNotPaused { unstakeAllowed = allowed; - emit UnstakeAllowedUpdated(allowed); } @@ -128,17 +158,16 @@ contract NODLStaking is Ownable, ReentrancyGuard { @notice The stake can only be unstaked if the user has a stake @notice The stake can only be unstaked if the stake has not been claimed */ - function unstake() external nonReentrant { + function unstake() external nonReentrant whenNotPaused { StakeInfo storage s = stakes[msg.sender]; if (!unstakeAllowed) revert UnstakeNotAllowed(); if (s.amount == 0) revert NoStake(); if (s.claimed) revert AlreadyClaimed(); uint256 returnAmount = s.amount; - + totalStaked -= returnAmount; s.amount = 0; s.claimed = true; - totalStaked -= s.amount; token.transfer(msg.sender, returnAmount); @@ -151,19 +180,28 @@ contract NODLStaking is Ownable, ReentrancyGuard { @return amount The amount of the stake @return start The start time of the stake @return claimed Whether the stake has been claimed - @return timeLeft The remaining time of the stake + @return timeLeft The remaining time of the stake in seconds @return potentialReward The potential reward of the stake */ function getStakeInfo(address user) external view + returns ( + uint256 amount, + uint256 start, + bool claimed, + uint256 timeLeft, + uint256 potentialReward + ) { StakeInfo storage s = stakes[user]; amount = s.amount; start = s.start; claimed = s.claimed; + timeLeft = 0; + potentialReward = 0; - // remaining time + // remaining time in seconds if (s.amount > 0 && !s.claimed) { if (block.timestamp < s.start + DURATION) { timeLeft = s.start + DURATION - block.timestamp; @@ -174,5 +212,23 @@ contract NODLStaking is Ownable, ReentrancyGuard { if (s.amount > 0 && !s.claimed) { potentialReward = (s.amount * rewardRate) / 100; } + + return (amount, start, claimed, timeLeft, potentialReward); + } + + /* + @dev Pause the contract + @notice Only owner can pause the contract + */ + function pause() external onlyOwner { + _pause(); + } + + /* + @dev Unpause the contract + @notice Only owner can unpause the contract + */ + function unpause() external onlyOwner { + _unpause(); } } From 5a9dec1559f285a200d1418291571cd236d727d4 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 21 May 2025 15:38:14 +0200 Subject: [PATCH 3/8] Updates the NODLStaking contract to use AccessControl instead of Ownable, implementing specific roles for reward and emergency management. Pause-related events are removed and functions are adjusted to require roles instead of Ownable. A new test file is added to validate role functionality and contract operations --- src/NodlStaking.sol | 33 +++--- test/NodlStaking.t.sol | 225 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 test/NodlStaking.t.sol diff --git a/src/NodlStaking.sol b/src/NodlStaking.sol index 549fb9a..5ed6014 100644 --- a/src/NodlStaking.sol +++ b/src/NodlStaking.sol @@ -2,12 +2,15 @@ pragma solidity 0.8.23; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; -import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {NODL} from "./NODL.sol"; -contract NODLStaking is Ownable, ReentrancyGuard, Pausable { +contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { + bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant EMERGENCY_MANAGER_ROLE = keccak256("EMERGENCY_MANAGER_ROLE"); + NODL public immutable token; uint256 public immutable MIN_STAKE; @@ -49,8 +52,6 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { event UnstakeAllowedUpdated(bool allowed); event Unstaked(address indexed user, uint256 amount); event RewardsFunded(uint256 amount); - event Paused(address account); - event Unpaused(address account); /* @dev Constructor @@ -72,6 +73,10 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { MIN_STAKE = _minStake * 1e18; MAX_TOTAL_STAKE = _maxTotalStake * 1e18; DURATION = _duration * 1 days; + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(REWARDS_MANAGER_ROLE, msg.sender); + _grantRole(EMERGENCY_MANAGER_ROLE, msg.sender); } /* @@ -101,7 +106,7 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { @notice Only owner can fund rewards @notice Requires sufficient allowance from owner to contract */ - function fundRewards(uint256 amount) external onlyOwner whenNotPaused { + function fundRewards(uint256 amount) external onlyRole(REWARDS_MANAGER_ROLE) whenNotPaused { uint256 allowance = token.allowance(msg.sender, address(this)); if (allowance < amount) revert InsufficientAllowance(); @@ -137,17 +142,17 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { @dev Emergency withdraw @notice The owner can withdraw the tokens in case of emergency */ - function emergencyWithdraw() external onlyOwner { - token.transfer(owner(), token.balanceOf(address(this))); - - emit EmergencyWithdrawn(owner(), token.balanceOf(address(this))); + function emergencyWithdraw() external onlyRole(EMERGENCY_MANAGER_ROLE) { + uint256 balance = token.balanceOf(address(this)); + token.transfer(msg.sender, balance); + emit EmergencyWithdrawn(msg.sender, balance); } /* @dev Update the unstake allowed status @param allowed The new unstake allowed status */ - function updateUnestakeAllowed(bool allowed) external onlyOwner whenNotPaused { + function updateUnestakeAllowed(bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) whenNotPaused { unstakeAllowed = allowed; emit UnstakeAllowedUpdated(allowed); } @@ -220,7 +225,7 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { @dev Pause the contract @notice Only owner can pause the contract */ - function pause() external onlyOwner { + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); } @@ -228,7 +233,7 @@ contract NODLStaking is Ownable, ReentrancyGuard, Pausable { @dev Unpause the contract @notice Only owner can unpause the contract */ - function unpause() external onlyOwner { + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); } } diff --git a/test/NodlStaking.t.sol b/test/NodlStaking.t.sol new file mode 100644 index 0000000..df73f53 --- /dev/null +++ b/test/NodlStaking.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {Test, console2} from "forge-std/Test.sol"; +import {NODL} from "../src/NODL.sol"; +import {NODLStaking} from "../src/NodlStaking.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +contract NodlStakingTest is Test { + NODL public token; + NODLStaking public staking; + + address public admin = address(1); + address public user1 = address(2); + address public user2 = address(3); + + bytes32 public constant FUNDER_ROLE = keccak256("FUNDER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + uint256 public constant REWARD_RATE = 10; // 10% + uint256 public constant MIN_STAKE = 100; // 100 tokens + uint256 public constant MAX_TOTAL_STAKE = 1000; // 1000 tokens + uint256 public constant DURATION = 30; // 30 days + + function setUp() public { + vm.startPrank(admin); + token = new NODL(admin); + staking = new NODLStaking( + address(token), + REWARD_RATE, + MIN_STAKE, + MAX_TOTAL_STAKE, + DURATION + ); + + // Setup roles + staking.grantRole(FUNDER_ROLE, admin); + staking.grantRole(PAUSER_ROLE, admin); + + // Mint tokens to users for testing + token.mint(user1, 1000 ether); + token.mint(user2, 1000 ether); + token.mint(admin, 1000 ether); + vm.stopPrank(); + } + + // Helper function to move time forward + function _skipTime(uint256 days_) internal { + vm.warp(block.timestamp + days_ * 1 days); + } + + // Test roles + function testRoles() public { + assertTrue(staking.hasRole(staking.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(staking.hasRole(FUNDER_ROLE, admin)); + assertTrue(staking.hasRole(PAUSER_ROLE, admin)); + + assertFalse(staking.hasRole(FUNDER_ROLE, user1)); + assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + } + + function testGrantAndRevokeRoles() public { + vm.startPrank(admin); + + // Grant roles + staking.grantRole(FUNDER_ROLE, user1); + staking.grantRole(PAUSER_ROLE, user1); + assertTrue(staking.hasRole(FUNDER_ROLE, user1)); + assertTrue(staking.hasRole(PAUSER_ROLE, user1)); + + // Revoke roles + staking.revokeRole(FUNDER_ROLE, user1); + staking.revokeRole(PAUSER_ROLE, user1); + assertFalse(staking.hasRole(FUNDER_ROLE, user1)); + assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + + vm.stopPrank(); + } + + function testFailNonAdminGrantRole() public { + vm.startPrank(user1); + staking.grantRole(FUNDER_ROLE, user2); + vm.stopPrank(); + } + + // Test fundRewards with roles + function testFundRewards() public { + uint256 amount = 1000 ether; + + vm.startPrank(admin); + token.approve(address(staking), amount); + staking.fundRewards(amount); + vm.stopPrank(); + + assertEq(token.balanceOf(address(staking)), amount); + } + + function testFailNonFunderFundRewards() public { + uint256 amount = 1000 ether; + + vm.startPrank(user1); + token.approve(address(staking), amount); + staking.fundRewards(amount); + vm.stopPrank(); + } + + // Test pause/unpause with roles + function testPauseUnpause() public { + vm.startPrank(admin); + staking.pause(); + assertTrue(staking.paused()); + + staking.unpause(); + assertFalse(staking.paused()); + vm.stopPrank(); + } + + function testFailNonPauserPause() public { + vm.startPrank(user1); + staking.pause(); + vm.stopPrank(); + } + + // Test stake function + function testStake() public { + uint256 stakeAmount = MIN_STAKE * 1e18; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1); + assertEq(amount, stakeAmount); + assertEq(start, block.timestamp); + assertEq(claimed, false); + assertEq(timeLeft, DURATION * 1 days); + assertEq(potentialReward, (stakeAmount * REWARD_RATE) / 100); + } + + function testFailStakeBelowMinimum() public { + uint256 stakeAmount = (MIN_STAKE - 1) * 1e18; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testFailStakeAboveMaximum() public { + uint256 stakeAmount = (MAX_TOTAL_STAKE + 1) * 1e18; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // Test claim function + function testClaim() public { + uint256 stakeAmount = MIN_STAKE * 1e18; + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount * 2); + staking.fundRewards(stakeAmount * 2); + vm.stopPrank(); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(); + uint256 balanceAfter = token.balanceOf(user1); + + uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; + assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); + vm.stopPrank(); + } + + function testFailClaimTooEarly() public { + uint256 stakeAmount = MIN_STAKE * 1e18; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + staking.claim(); + vm.stopPrank(); + } + + // Test emergency withdraw + function testEmergencyWithdraw() public { + uint256 stakeAmount = MIN_STAKE * 1e18; + + // Setup stake and fund rewards + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + vm.startPrank(admin); + token.approve(address(staking), stakeAmount); + staking.fundRewards(stakeAmount); + + uint256 balanceBefore = token.balanceOf(admin); + staking.emergencyWithdraw(); + uint256 balanceAfter = token.balanceOf(admin); + + assertEq(balanceAfter - balanceBefore, stakeAmount * 2); + vm.stopPrank(); + } + + function testFailNonAdminEmergencyWithdraw() public { + vm.startPrank(user1); + staking.emergencyWithdraw(); + vm.stopPrank(); + } +} From c6086237ecf68187a9fffdd78e4f5485d1ff691f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Sun, 25 May 2025 17:43:18 +0200 Subject: [PATCH 4/8] Adds Staking contract for NODL token staking management, implementing functions to perform staking, claim rewards, withdraw in case of emergency and allow unstaking. Events are added to track actions and validations are implemented to ensure proper staking conditions. A new test file is included to validate the functionality of the staking contract --- README.md | 20 + deploy/deploy_staking.dp.ts | 65 +++ src/{NodlStaking.sol => Staking.sol} | 104 +++- test/NodlStaking.t.sol | 225 -------- test/Staking.t.sol | 742 +++++++++++++++++++++++++++ 5 files changed, 903 insertions(+), 253 deletions(-) create mode 100644 deploy/deploy_staking.dp.ts rename src/{NodlStaking.sol => Staking.sol} (64%) delete mode 100644 test/NodlStaking.t.sol create mode 100644 test/Staking.t.sol diff --git a/README.md b/README.md index bb7d610..daf3f60 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,27 @@ Verification on Etherscan is best done via the Solidity Json Input method as it Use all these artifacts on the contract verification page on Etherscan for your given contract (open your contract on Etherscan, select `Contract` and the link starting with `Verify`). When prompted, enter the compiler versions, the license (we use BSD-3 Clause Clear). Then on the next page, enter your normalized JSON input file, and the contract constructor inputs. +## Deploying Staking contract + +```shell +npx hardhat run --network zkSync deploy/deploy_staking.dp.ts +``` + ## Additional resources - [L1 contracts](https://docs.zksync.io/zksync-era/environment/l1-contracts) - [ZK stack addresses](https://docs.zksync.io/zk-stack/zk-chain-addresses) + +## ZkSync CLI useful commands + +# Approve + +```shell +npx zksync-cli contract write --chain "zksync-sepolia" --contract "0xb4B74C2BfeA877672B938E408Bae8894918fE41C" --method "approve(address spender, uint256 amount)" --args "0x2D1941280530027B6bA80Af0e7bD8c2135783368" "1000000000000000000" +``` + +# Stake + +```shell +npx zksync-cli contract write --chain "zksync-sepolia" --contract "0xb974a544128Bc7fAB3447D48cd6ad377D6F62EcF" --method "stake(uint256 amount)" --args "1000000000000000000" +``` \ No newline at end of file diff --git a/deploy/deploy_staking.dp.ts b/deploy/deploy_staking.dp.ts new file mode 100644 index 0000000..8506660 --- /dev/null +++ b/deploy/deploy_staking.dp.ts @@ -0,0 +1,65 @@ +import { Provider, Wallet } from "zksync-ethers"; +import { Deployer } from "@matterlabs/hardhat-zksync"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; +import * as dotenv from "dotenv"; + +import { deployContract } from "./utils"; + +dotenv.config(); +let CONTRACT_ADDRESS = ""; +let SHOULD_DEPLOY = !CONTRACT_ADDRESS; + +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const tokenAddress = process.env.STAKE_TOKEN!; + const adminAddress = process.env.GOV_ADDR!; + const minStakeAmount = process.env.MIN_STAKE!; + const stakingPeriod = process.env.DURATION!; + const rewardRate = process.env.REWARD_RATE!; + const maxTotalStake = process.env.MAX_TOTAL_STAKE!; + const requiredHoldingToken = process.env.REQUIRED_HOLDING_TOKEN!; + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); + const deployer = new Deployer(hre, wallet); + + const constructorArgs = [ + tokenAddress, + (BigInt(requiredHoldingToken) * BigInt(1e18)).toString(), + Number(rewardRate), + (BigInt(minStakeAmount) * BigInt(1e18)).toString(), + (BigInt(maxTotalStake) * BigInt(1e18)).toString(), + Number(stakingPeriod), + adminAddress, + ]; + + if (SHOULD_DEPLOY) { + const staking = await deployContract(deployer, "Staking", constructorArgs); + CONTRACT_ADDRESS = await staking.getAddress(); + console.log(`Staking contract deployed at ${await staking.getAddress()}`); + console.log( + `!!! Do not forget to grant token approval to Staking contract at ${await staking.getAddress()} !!!` + ); + } + + if (CONTRACT_ADDRESS) { + console.log("Starting contract verification..."); + try { + await hre.run("verify:verify", { + address: CONTRACT_ADDRESS, + contract: "src/Staking.sol:Staking", + constructorArguments: constructorArgs, + }); + console.log("Contract verified successfully!"); + } catch (error: any) { + if (error.message.includes("Contract source code already verified")) { + console.log("Contract is already verified!"); + } else { + console.error("Error verifying contract:", error); + throw error; + } + } + } +}; diff --git a/src/NodlStaking.sol b/src/Staking.sol similarity index 64% rename from src/NodlStaking.sol rename to src/Staking.sol index 5ed6014..821b087 100644 --- a/src/NodlStaking.sol +++ b/src/Staking.sol @@ -7,7 +7,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {NODL} from "./NODL.sol"; -contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { +contract Staking is AccessControl, ReentrancyGuard, Pausable { bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); bytes32 public constant EMERGENCY_MANAGER_ROLE = keccak256("EMERGENCY_MANAGER_ROLE"); @@ -15,11 +15,13 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { uint256 public immutable MIN_STAKE; uint256 public immutable MAX_TOTAL_STAKE; + uint256 public MAX_POOL_STAKE = 5_000_000 ether; uint256 public immutable DURATION; - uint256 public immutable rewardRate; + uint256 public immutable REWARD_RATE; + uint256 public immutable REQUIRED_HOLDING_TOKEN; bool public unstakeAllowed = false; - uint256 public totalStaked; + uint256 public totalStakedInPool; struct StakeInfo { uint256 amount; @@ -27,7 +29,8 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { bool claimed; } - mapping(address => StakeInfo) public stakes; + mapping(address => StakeInfo[]) public stakes; + mapping(address => uint256) public totalStakedByUser; error ZeroAddress(); error InvalidRewardRate(); @@ -36,6 +39,7 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { error InvalidDuration(); error MinStakeNotMet(); error ExceedsMaxTotalStake(); + error ExceedsMaxPoolStake(); error AlreadyStaked(); error NoStakeFound(); error AlreadyClaimed(); @@ -45,6 +49,9 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { error InsufficientRewardBalance(); error InsufficientAllowance(); error InsufficientTotalStaked(); + error InsufficientBalance(); + error UnmetRequiredHoldingToken(); + error InvalidMaxPoolStake(); event Staked(address indexed user, uint256 amount); event Claimed(address indexed user, uint256 amount, uint256 reward); @@ -52,31 +59,36 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { event UnstakeAllowedUpdated(bool allowed); event Unstaked(address indexed user, uint256 amount); event RewardsFunded(uint256 amount); + event MaxPoolStakeUpdated(uint256 oldValue, uint256 newValue); /* @dev Constructor @param nodlToken The address of the NODL token + @param _requiredHoldingToken The required holding of token, represented in Wei @param _rewardRate The reward rate, represented in percentage - @param _minStake The minimum stake, represented in Eth - @param _maxTotalStake The maximum total stake, represented in Eth + @param _minStake The minimum stake per user, represented in Wei + @param _maxTotalStake The maximum total stake per user, represented in Wei @param _duration The duration of the stake, represented in days + @param _admin The address of the admin */ - constructor(address nodlToken, uint256 _rewardRate, uint256 _minStake, uint256 _maxTotalStake, uint256 _duration) { + constructor(address nodlToken, uint256 _requiredHoldingToken, uint256 _rewardRate, uint256 _minStake, uint256 _maxTotalStake, uint256 _duration, address _admin) { if (nodlToken == address(0)) revert ZeroAddress(); if (_rewardRate <= 0) revert InvalidRewardRate(); if (_minStake == 0) revert InvalidMinStake(); - if (_maxTotalStake * 1e18 <= _minStake * 1e18) revert InvalidMaxTotalStake(); + if (_maxTotalStake <= _minStake) revert InvalidMaxTotalStake(); if (_duration == 0) revert InvalidDuration(); + if (_admin == address(0)) revert ZeroAddress(); token = NODL(nodlToken); - rewardRate = _rewardRate; - MIN_STAKE = _minStake * 1e18; - MAX_TOTAL_STAKE = _maxTotalStake * 1e18; - DURATION = _duration * 1 days; + REWARD_RATE = _rewardRate; + MIN_STAKE = _minStake; + MAX_TOTAL_STAKE = _maxTotalStake; + DURATION = _duration * 1 seconds; + REQUIRED_HOLDING_TOKEN = _requiredHoldingToken; - _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - _grantRole(REWARDS_MANAGER_ROLE, msg.sender); - _grantRole(EMERGENCY_MANAGER_ROLE, msg.sender); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(REWARDS_MANAGER_ROLE, _admin); + _grantRole(EMERGENCY_MANAGER_ROLE, _admin); } /* @@ -88,14 +100,25 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { */ function stake(uint256 amount) external nonReentrant whenNotPaused { if (amount < MIN_STAKE) revert MinStakeNotMet(); - if (totalStaked + amount > MAX_TOTAL_STAKE) revert ExceedsMaxTotalStake(); - if (stakes[msg.sender].amount != 0) revert AlreadyStaked(); + if (totalStakedInPool + amount > MAX_POOL_STAKE) revert ExceedsMaxPoolStake(); + + // check if the user do not exceed the max total stake per user + uint256 newTotal = totalStakedByUser[msg.sender] + amount; + if (newTotal > MAX_TOTAL_STAKE) revert ExceedsMaxTotalStake(); + + // check if the user has enough holding token + uint256 balance = token.balanceOf(msg.sender); + if (balance < REQUIRED_HOLDING_TOKEN) revert UnmetRequiredHoldingToken(); + + // check if the user has enough balance + if (balance < amount) revert InsufficientBalance(); token.transferFrom(msg.sender, address(this), amount); - stakes[msg.sender] = StakeInfo({amount: amount, start: block.timestamp, claimed: false}); + stakes[msg.sender].push(StakeInfo({amount: amount, start: block.timestamp, claimed: false})); - totalStaked += amount; + totalStakedInPool += amount; + totalStakedByUser[msg.sender] = newTotal; emit Staked(msg.sender, amount); } @@ -121,18 +144,20 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { @notice The stake can only be claimed if the stake has not been claimed @notice The contract must have enough balance for both stake and reward */ - function claim() external nonReentrant whenNotPaused { - StakeInfo storage s = stakes[msg.sender]; + function claim(uint256 index) external nonReentrant whenNotPaused { + StakeInfo storage s = stakes[msg.sender][index]; if (s.amount == 0) revert NoStakeFound(); if (s.claimed) revert AlreadyClaimed(); if (block.timestamp < s.start + DURATION) revert TooEarly(); - uint256 reward = (s.amount * rewardRate) / 100; + uint256 reward = (s.amount * REWARD_RATE) / 100; uint256 totalToTransfer = s.amount + reward; uint256 contractBalance = token.balanceOf(address(this)); if (totalToTransfer > contractBalance) revert InsufficientRewardBalance(); s.claimed = true; + totalStakedInPool -= s.amount; + totalStakedByUser[msg.sender] -= s.amount; token.transfer(msg.sender, totalToTransfer); emit Claimed(msg.sender, s.amount, reward); @@ -144,6 +169,7 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { */ function emergencyWithdraw() external onlyRole(EMERGENCY_MANAGER_ROLE) { uint256 balance = token.balanceOf(address(this)); + totalStakedInPool = 0; token.transfer(msg.sender, balance); emit EmergencyWithdrawn(msg.sender, balance); } @@ -163,14 +189,15 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { @notice The stake can only be unstaked if the user has a stake @notice The stake can only be unstaked if the stake has not been claimed */ - function unstake() external nonReentrant whenNotPaused { - StakeInfo storage s = stakes[msg.sender]; + function unstake(uint256 index) external nonReentrant whenNotPaused { + StakeInfo storage s = stakes[msg.sender][index]; if (!unstakeAllowed) revert UnstakeNotAllowed(); if (s.amount == 0) revert NoStake(); if (s.claimed) revert AlreadyClaimed(); uint256 returnAmount = s.amount; - totalStaked -= returnAmount; + totalStakedInPool -= returnAmount; + totalStakedByUser[msg.sender] -= returnAmount; s.amount = 0; s.claimed = true; @@ -188,7 +215,7 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { @return timeLeft The remaining time of the stake in seconds @return potentialReward The potential reward of the stake */ - function getStakeInfo(address user) + function getStakeInfo(address user, uint256 index) external view returns ( @@ -199,7 +226,11 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { uint256 potentialReward ) { - StakeInfo storage s = stakes[user]; + if (index >= stakes[user].length) { + return (0, 0, false, 0, 0); + } + + StakeInfo storage s = stakes[user][index]; amount = s.amount; start = s.start; claimed = s.claimed; @@ -215,7 +246,7 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { // potential reward if (s.amount > 0 && !s.claimed) { - potentialReward = (s.amount * rewardRate) / 100; + potentialReward = (s.amount * REWARD_RATE) / 100; } return (amount, start, claimed, timeLeft, potentialReward); @@ -236,4 +267,21 @@ contract NODLStaking is AccessControl, ReentrancyGuard, Pausable { function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); } + + /* + @dev Update the maximum pool stake + @param newMaxPoolStake The new maximum pool stake value + @notice Only admin can update the maximum pool stake + @notice The new value must be greater than 0 + @notice The new value must be greater than or equal to the current total staked + */ + function updateMaxPoolStake(uint256 newMaxPoolStake) external onlyRole(DEFAULT_ADMIN_ROLE) whenNotPaused { + if (newMaxPoolStake <= 0) revert InvalidMaxPoolStake(); + if (newMaxPoolStake < totalStakedInPool) revert InvalidMaxPoolStake(); + + uint256 oldValue = MAX_POOL_STAKE; + MAX_POOL_STAKE = newMaxPoolStake; + + emit MaxPoolStakeUpdated(oldValue, newMaxPoolStake); + } } diff --git a/test/NodlStaking.t.sol b/test/NodlStaking.t.sol deleted file mode 100644 index df73f53..0000000 --- a/test/NodlStaking.t.sol +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear -pragma solidity 0.8.23; - -import {Test, console2} from "forge-std/Test.sol"; -import {NODL} from "../src/NODL.sol"; -import {NODLStaking} from "../src/NodlStaking.sol"; -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; - -contract NodlStakingTest is Test { - NODL public token; - NODLStaking public staking; - - address public admin = address(1); - address public user1 = address(2); - address public user2 = address(3); - - bytes32 public constant FUNDER_ROLE = keccak256("FUNDER_ROLE"); - bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); - - uint256 public constant REWARD_RATE = 10; // 10% - uint256 public constant MIN_STAKE = 100; // 100 tokens - uint256 public constant MAX_TOTAL_STAKE = 1000; // 1000 tokens - uint256 public constant DURATION = 30; // 30 days - - function setUp() public { - vm.startPrank(admin); - token = new NODL(admin); - staking = new NODLStaking( - address(token), - REWARD_RATE, - MIN_STAKE, - MAX_TOTAL_STAKE, - DURATION - ); - - // Setup roles - staking.grantRole(FUNDER_ROLE, admin); - staking.grantRole(PAUSER_ROLE, admin); - - // Mint tokens to users for testing - token.mint(user1, 1000 ether); - token.mint(user2, 1000 ether); - token.mint(admin, 1000 ether); - vm.stopPrank(); - } - - // Helper function to move time forward - function _skipTime(uint256 days_) internal { - vm.warp(block.timestamp + days_ * 1 days); - } - - // Test roles - function testRoles() public { - assertTrue(staking.hasRole(staking.DEFAULT_ADMIN_ROLE(), admin)); - assertTrue(staking.hasRole(FUNDER_ROLE, admin)); - assertTrue(staking.hasRole(PAUSER_ROLE, admin)); - - assertFalse(staking.hasRole(FUNDER_ROLE, user1)); - assertFalse(staking.hasRole(PAUSER_ROLE, user1)); - } - - function testGrantAndRevokeRoles() public { - vm.startPrank(admin); - - // Grant roles - staking.grantRole(FUNDER_ROLE, user1); - staking.grantRole(PAUSER_ROLE, user1); - assertTrue(staking.hasRole(FUNDER_ROLE, user1)); - assertTrue(staking.hasRole(PAUSER_ROLE, user1)); - - // Revoke roles - staking.revokeRole(FUNDER_ROLE, user1); - staking.revokeRole(PAUSER_ROLE, user1); - assertFalse(staking.hasRole(FUNDER_ROLE, user1)); - assertFalse(staking.hasRole(PAUSER_ROLE, user1)); - - vm.stopPrank(); - } - - function testFailNonAdminGrantRole() public { - vm.startPrank(user1); - staking.grantRole(FUNDER_ROLE, user2); - vm.stopPrank(); - } - - // Test fundRewards with roles - function testFundRewards() public { - uint256 amount = 1000 ether; - - vm.startPrank(admin); - token.approve(address(staking), amount); - staking.fundRewards(amount); - vm.stopPrank(); - - assertEq(token.balanceOf(address(staking)), amount); - } - - function testFailNonFunderFundRewards() public { - uint256 amount = 1000 ether; - - vm.startPrank(user1); - token.approve(address(staking), amount); - staking.fundRewards(amount); - vm.stopPrank(); - } - - // Test pause/unpause with roles - function testPauseUnpause() public { - vm.startPrank(admin); - staking.pause(); - assertTrue(staking.paused()); - - staking.unpause(); - assertFalse(staking.paused()); - vm.stopPrank(); - } - - function testFailNonPauserPause() public { - vm.startPrank(user1); - staking.pause(); - vm.stopPrank(); - } - - // Test stake function - function testStake() public { - uint256 stakeAmount = MIN_STAKE * 1e18; - - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - vm.stopPrank(); - - (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1); - assertEq(amount, stakeAmount); - assertEq(start, block.timestamp); - assertEq(claimed, false); - assertEq(timeLeft, DURATION * 1 days); - assertEq(potentialReward, (stakeAmount * REWARD_RATE) / 100); - } - - function testFailStakeBelowMinimum() public { - uint256 stakeAmount = (MIN_STAKE - 1) * 1e18; - - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - vm.stopPrank(); - } - - function testFailStakeAboveMaximum() public { - uint256 stakeAmount = (MAX_TOTAL_STAKE + 1) * 1e18; - - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - vm.stopPrank(); - } - - // Test claim function - function testClaim() public { - uint256 stakeAmount = MIN_STAKE * 1e18; - - // Setup stake - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount * 2); - staking.fundRewards(stakeAmount * 2); - vm.stopPrank(); - - // Move time forward and claim - _skipTime(DURATION + 1); - - vm.startPrank(user1); - uint256 balanceBefore = token.balanceOf(user1); - staking.claim(); - uint256 balanceAfter = token.balanceOf(user1); - - uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; - assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); - vm.stopPrank(); - } - - function testFailClaimTooEarly() public { - uint256 stakeAmount = MIN_STAKE * 1e18; - - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - staking.claim(); - vm.stopPrank(); - } - - // Test emergency withdraw - function testEmergencyWithdraw() public { - uint256 stakeAmount = MIN_STAKE * 1e18; - - // Setup stake and fund rewards - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - vm.stopPrank(); - - vm.startPrank(admin); - token.approve(address(staking), stakeAmount); - staking.fundRewards(stakeAmount); - - uint256 balanceBefore = token.balanceOf(admin); - staking.emergencyWithdraw(); - uint256 balanceAfter = token.balanceOf(admin); - - assertEq(balanceAfter - balanceBefore, stakeAmount * 2); - vm.stopPrank(); - } - - function testFailNonAdminEmergencyWithdraw() public { - vm.startPrank(user1); - staking.emergencyWithdraw(); - vm.stopPrank(); - } -} diff --git a/test/Staking.t.sol b/test/Staking.t.sol new file mode 100644 index 0000000..764fb36 --- /dev/null +++ b/test/Staking.t.sol @@ -0,0 +1,742 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {Test, console2} from "forge-std/Test.sol"; +import {NODL} from "../src/NODL.sol"; +import {Staking} from "../src/Staking.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +contract StakingTest is Test { + NODL public token; + Staking public staking; + + address public admin = address(1); + address public user1 = address(2); + address public user2 = address(3); + address public user3 = address(4); + + bytes32 public constant FUNDER_ROLE = keccak256("FUNDER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + uint256 public constant REWARD_RATE = 10; // 10% + uint256 public constant MIN_STAKE = 100 * 1e18; // 100 tokens + uint256 public constant MAX_TOTAL_STAKE = 1000 * 1e18; // 1000 tokens + uint256 public constant DURATION = 30 days; // 1/2 hour + uint256 public constant REQUIRED_HOLDING_TOKEN = 200 * 1e18; // 200 tokens + + function setUp() public { + vm.startPrank(admin); + token = new NODL(admin); + staking = new Staking( + address(token), + REQUIRED_HOLDING_TOKEN, + REWARD_RATE, + MIN_STAKE, + MAX_TOTAL_STAKE, + DURATION, + admin + ); + + // Setup roles + staking.grantRole(FUNDER_ROLE, admin); + staking.grantRole(PAUSER_ROLE, admin); + + // Mint tokens to users for testing + token.mint(user1, 1000 ether); + token.mint(user2, 1000 ether); + token.mint(admin, 1000 ether); + token.mint(user3, 100 ether); + vm.stopPrank(); + } + + // Helper function to move time forward + function _skipTime(uint256 days_) internal { + vm.warp(block.timestamp + days_ * 1 days); + } + + // Test roles + function testRoles() public { + assertTrue(staking.hasRole(staking.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(staking.hasRole(FUNDER_ROLE, admin)); + assertTrue(staking.hasRole(PAUSER_ROLE, admin)); + + assertFalse(staking.hasRole(FUNDER_ROLE, user1)); + assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + } + + function testGrantAndRevokeRoles() public { + vm.startPrank(admin); + + // Grant roles + staking.grantRole(FUNDER_ROLE, user1); + staking.grantRole(PAUSER_ROLE, user1); + assertTrue(staking.hasRole(FUNDER_ROLE, user1)); + assertTrue(staking.hasRole(PAUSER_ROLE, user1)); + + // Revoke roles + staking.revokeRole(FUNDER_ROLE, user1); + staking.revokeRole(PAUSER_ROLE, user1); + assertFalse(staking.hasRole(FUNDER_ROLE, user1)); + assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + + vm.stopPrank(); + } + + function testFailNonAdminGrantRole() public { + vm.startPrank(user1); + staking.grantRole(FUNDER_ROLE, user2); + vm.stopPrank(); + } + + // Test fundRewards with roles + function testFundRewards() public { + uint256 amount = 1000 ether; + + vm.startPrank(admin); + token.approve(address(staking), amount); + staking.fundRewards(amount); + vm.stopPrank(); + + assertEq(token.balanceOf(address(staking)), amount); + } + + function testFailNonFunderFundRewards() public { + uint256 amount = 1000 ether; + + vm.startPrank(user1); + token.approve(address(staking), amount); + staking.fundRewards(amount); + vm.stopPrank(); + } + + // Test pause/unpause with roles + function testPauseUnpause() public { + vm.startPrank(admin); + staking.pause(); + assertTrue(staking.paused()); + + staking.unpause(); + assertFalse(staking.paused()); + vm.stopPrank(); + } + + function testFailNonPauserPause() public { + vm.startPrank(user1); + staking.pause(); + vm.stopPrank(); + } + + // Test stake function + function testStake() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1, 0); + assertEq(amount, stakeAmount); + assertEq(start, block.timestamp); + assertEq(claimed, false); + assertEq(timeLeft, DURATION); + assertEq(potentialReward, (stakeAmount * REWARD_RATE) / 100); + } + + function testFailStakeBelowMinimum() public { + uint256 stakeAmount = (MIN_STAKE - (1 ether)); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testFailStakeAboveMaximum() public { + uint256 stakeAmount = (MAX_TOTAL_STAKE + (1 ether)); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testFailStakeInsufficientBalance() public { + uint256 stakeAmount = MIN_STAKE + (1000 ether); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testFailStakeUnmetRequiredHoldingToken() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user3); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // Test multiple stakes + function testMultipleStakes() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + (uint256 amount1, , , , ) = staking.getStakeInfo(user1, 0); + assertEq(amount1, stakeAmount); + + // Second stake + staking.stake(stakeAmount); + (uint256 amount2, , , , ) = staking.getStakeInfo(user1, 1); + assertEq(amount2, stakeAmount); + + vm.stopPrank(); + } + + function testClaim() public { + uint256 stakeAmount = MIN_STAKE; + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount * 2); + staking.fundRewards(stakeAmount * 2); + vm.stopPrank(); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(0); // Added index parameter + uint256 balanceAfter = token.balanceOf(user1); + + uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; + assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); + vm.stopPrank(); + } + + function testFailClaimTooEarly() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + staking.claim(0); // Added index parameter + vm.stopPrank(); + } + + function testUnstake() public { + uint256 stakeAmount = MIN_STAKE; + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnestakeAllowed(true); + vm.stopPrank(); + + // Unstake + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.unstake(0); // Added index parameter + uint256 balanceAfter = token.balanceOf(user1); + + assertEq(balanceAfter - balanceBefore, stakeAmount); + vm.stopPrank(); + } + + function testFailUnstakeNotAllowed() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + staking.unstake(0); // Added index parameter + vm.stopPrank(); + } + + function testFailUnstakeNonExistentStake() public { + vm.startPrank(user1); + staking.unstake(0); // Added index parameter + vm.stopPrank(); + } + + // Test emergency withdraw + function testEmergencyWithdraw() public { + uint256 stakeAmount = MIN_STAKE; + + // Setup stake and fund rewards + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + vm.startPrank(admin); + token.approve(address(staking), stakeAmount); + staking.fundRewards(stakeAmount); + + uint256 balanceBefore = token.balanceOf(admin); + staking.emergencyWithdraw(); + uint256 balanceAfter = token.balanceOf(admin); + + assertEq(balanceAfter - balanceBefore, stakeAmount * 2); + vm.stopPrank(); + } + + function testFailNonAdminEmergencyWithdraw() public { + vm.startPrank(user1); + staking.emergencyWithdraw(); + vm.stopPrank(); + } + + // Test updateMaxPoolStake function + function testUpdateMaxPoolStake() public { + uint256 newMaxPoolStake = 10_000_000 ether; + + vm.startPrank(admin); + uint256 oldValue = staking.MAX_POOL_STAKE(); + staking.updateMaxPoolStake(newMaxPoolStake); + assertEq(staking.MAX_POOL_STAKE(), newMaxPoolStake); + vm.stopPrank(); + } + + function testFailNonAdminUpdateMaxPoolStake() public { + uint256 newMaxPoolStake = 10_000_000 ether; + + vm.startPrank(user1); + staking.updateMaxPoolStake(newMaxPoolStake); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeToZero() public { + vm.startPrank(admin); + staking.updateMaxPoolStake(0); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeBelowCurrentStake() public { + uint256 stakeAmount = MIN_STAKE; + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + // Try to update max pool stake below current stake + vm.startPrank(admin); + staking.updateMaxPoolStake(stakeAmount - 1); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeWhenPaused() public { + vm.startPrank(admin); + staking.pause(); + staking.updateMaxPoolStake(10_000_000 ether); + vm.stopPrank(); + } + + // Límites de Usuario + function testStakeAtMaxTotalStake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + assertEq(staking.totalStakedByUser(user1), stakeAmount); + vm.stopPrank(); + } + + function testFailStakeAboveMaxTotalStake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE + 1 ether; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testStakeAfterPartialUnstake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnestakeAllowed(true); + vm.stopPrank(); + + // Partial unstake + vm.startPrank(user1); + staking.unstake(0); + assertEq(staking.totalStakedByUser(user1), 0); + + // New stake after unstake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + vm.stopPrank(); + } + + // Estado del Contrato + function testTotalStakedInPool() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + assertEq(staking.totalStakedInPool(), stakeAmount); + + // Second user stakes + vm.stopPrank(); + vm.startPrank(user2); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + assertEq(staking.totalStakedInPool(), stakeAmount * 2); + vm.stopPrank(); + } + + function testTotalStakedByUser() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + + // Second stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount * 2); + vm.stopPrank(); + } + + function testGetStakeInfoForNonExistentStake() public { + (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1, 0); + assertEq(amount, 0); + assertEq(start, 0); + assertEq(claimed, false); + assertEq(timeLeft, 0); + assertEq(potentialReward, 0); + } + + // Recompensas + function testRewardCalculation() public { + uint256 stakeAmount = 1000 ether; + uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), expectedReward); + staking.fundRewards(expectedReward); + vm.stopPrank(); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(0); + uint256 balanceAfter = token.balanceOf(user1); + + assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); + vm.stopPrank(); + } + + function testFailClaimInsufficientRewards() public { + uint256 stakeAmount = 1000 ether; + uint256 rewardAmount = (stakeAmount * REWARD_RATE) / 100; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund less rewards than needed + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), rewardAmount - 1); + staking.fundRewards(rewardAmount - 1); + vm.stopPrank(); + + // Move time forward and try to claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + staking.claim(0); + vm.stopPrank(); + } + + function testMultipleRewardsFundings() public { + uint256 stakeAmount = 1000 ether; + uint256 rewardAmount = (stakeAmount * REWARD_RATE) / 100; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards multiple times + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), rewardAmount * 3); + staking.fundRewards(rewardAmount); + staking.fundRewards(rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(0); + uint256 balanceAfter = token.balanceOf(user1); + + assertEq(balanceAfter - balanceBefore, stakeAmount + rewardAmount); + vm.stopPrank(); + } + + // Validaciones de Tiempo + function testClaimExactlyAtDuration() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount); + staking.fundRewards(stakeAmount); + vm.stopPrank(); + + // Move time forward exactly to duration + _skipTime(DURATION); + + vm.startPrank(user1); + staking.claim(0); + vm.stopPrank(); + } + + function testClaimAfterDuration() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount); + staking.fundRewards(stakeAmount); + vm.stopPrank(); + + // Move time forward past duration + _skipTime(DURATION + 1 days); + + vm.startPrank(user1); + staking.claim(0); + vm.stopPrank(); + } + + function testFailClaimBeforeDuration() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Try to claim before duration + _skipTime(DURATION - 1); + + staking.claim(0); + vm.stopPrank(); + } + + // Múltiples Operaciones + function testMultipleStakesAndClaims() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + + // Second stake + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount * 2); + staking.fundRewards(stakeAmount * 2); + vm.stopPrank(); + + // Move time forward + _skipTime(DURATION + 1); + + // Claim both stakes + vm.startPrank(user1); + staking.claim(0); + staking.claim(1); + vm.stopPrank(); + } + + function testMultipleStakesAndUnstakes() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + + // Second stake + staking.stake(stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnestakeAllowed(true); + vm.stopPrank(); + + // Unstake both + vm.startPrank(user1); + staking.unstake(0); + staking.unstake(1); + vm.stopPrank(); + } + + function testStakeAfterClaim() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + + // Fund rewards + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), stakeAmount); + staking.fundRewards(stakeAmount); + vm.stopPrank(); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + staking.claim(0); + + // New stake after claim + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testStakeAfterUnstake() public { + uint256 stakeAmount = MIN_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnestakeAllowed(true); + vm.stopPrank(); + + // Unstake + vm.startPrank(user1); + staking.unstake(0); + + // New stake after unstake + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // Límites del Pool + function testFailStakeExceedsMaxPoolStake() public { + uint256 stakeAmount = staking.MAX_POOL_STAKE() + 1 ether; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testStakeAtMaxPoolStake() public { + // First update MAX_POOL_STAKE to respect MAX_TOTAL_STAKE + vm.startPrank(admin); + staking.updateMaxPoolStake(MAX_TOTAL_STAKE); + vm.stopPrank(); + + uint256 stakeAmount = MAX_TOTAL_STAKE; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + assertEq(staking.totalStakedInPool(), stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + vm.stopPrank(); + } + + // Test pool limit with multiple users + function testMultipleUsersAtMaxPoolStake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE; + uint256 numUsers = 5; // 5 users making maximum stake + + // Mint tokens to multiple users + vm.startPrank(admin); + for(uint i = 0; i < numUsers; i++) { + address user = address(uint160(uint(keccak256(abi.encodePacked(i))))); + token.mint(user, stakeAmount); + } + vm.stopPrank(); + + // Each user makes a stake + for(uint i = 0; i < numUsers; i++) { + address user = address(uint160(uint(keccak256(abi.encodePacked(i))))); + vm.startPrank(user); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + assertEq(staking.totalStakedInPool(), stakeAmount * numUsers); + } +} From 4300aa83a1d942b4f2315d3c0a2a2c0dde79d2aa Mon Sep 17 00:00:00 2001 From: douglasacost Date: Sun, 25 May 2025 18:31:15 +0200 Subject: [PATCH 5/8] Update readme --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index daf3f60..c8257fe 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,40 @@ N_LEVELS_URI_2=example.com \ forge script script/DeployMigrationNFT.s.sol --zksync --rpc-url https://sepolia.era.zksync.dev --zk-optimizer -i 1 --broadcast ``` +## Deploying Staking contract +Allow users with at least 50,000 NODL (Dolphin level) to participate in a staking contract with the following characteristics: + +### Functional Requirements +- Restricted access: Only users holding 50,000 NODL or more can stake. + +- Staking cap: The contract accepts a maximum total of 5 million NODL per staker. Additionally, the contract has a global staking cap MAX_POOL_STAKE. + +- Limited duration: Staking lasts the DURATION in seconds passed as a parameter. + +- Guaranteed yield: Users receive a fixed reward, predefined in the contract, at the end of the staking period. + +- Return: Once the staking period ends, both the staked tokens and the yield are returned to the user via a claim function. + +### Deployment + +```shell +GOV_ADDR=0x2D1941280530027B6bA80Af0e7bD8c2135783368 \ +STAKE_TOKEN=0xb4B74C2BfeA877672B938E408Bae8894918fE41C \ +MIN_STAKE=50000 \ +MAX_TOTAL_STAKE=5000000 \ +DURATION=3600 \ +REWARD_RATE=12 \ +REQUIRED_HOLDING_TOKEN=50000 \ +npx hardhat deploy-zksync --script deploy_staking.dp.ts --network zkSyncSepoliaTestnet +``` +- GOV_ADDR: Address of the governance contract. +- STAKE_TOKEN: Address of the token to stake. +- MIN_STAKE: Minimum amount of tokens a user can stake. +- MAX_TOTAL_STAKE: Maximum amount of tokens a user can stake. +- DURATION: Duration of the staking period in seconds. +- REWARD_RATE: Reward rate of the staking contract. +- REQUIRED_HOLDING_TOKEN: Minimum amount of tokens a user must hold to participate in the staking contract. + ## Scripts ### Checking on bridging proposals From e2d0fe21fbdc4a74eb9491071471fa9c8d7c6dcf Mon Sep 17 00:00:00 2001 From: douglasacost Date: Sun, 25 May 2025 23:57:10 +0200 Subject: [PATCH 6/8] Clean up spellcheck --- .cspell.json | 8 +++++++- src/Staking.sol | 2 +- test/Staking.t.sol | 20 ++++++++++---------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.cspell.json b/.cspell.json index 4fb6588..684f4fa 100644 --- a/.cspell.json +++ b/.cspell.json @@ -54,6 +54,12 @@ "Tokendecimals", "zyfi", "addrsSlot", - "NODLNS" + "NODLNS", + "unstake", + "unstakes", + "Unstaked", + "funder", + "Fundings", + "Reentrancy" ] } diff --git a/src/Staking.sol b/src/Staking.sol index 821b087..2bac102 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -178,7 +178,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { @dev Update the unstake allowed status @param allowed The new unstake allowed status */ - function updateUnestakeAllowed(bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) whenNotPaused { + function updateUnstakeAllowed(bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) whenNotPaused { unstakeAllowed = allowed; emit UnstakeAllowedUpdated(allowed); } diff --git a/test/Staking.t.sol b/test/Staking.t.sol index 764fb36..47fc006 100644 --- a/test/Staking.t.sol +++ b/test/Staking.t.sol @@ -248,7 +248,7 @@ contract StakingTest is Test { // Enable unstake vm.stopPrank(); vm.startPrank(admin); - staking.updateUnestakeAllowed(true); + staking.updateUnstakeAllowed(true); vm.stopPrank(); // Unstake @@ -352,7 +352,7 @@ contract StakingTest is Test { vm.stopPrank(); } - // Límites de Usuario + // User limits function testStakeAtMaxTotalStake() public { uint256 stakeAmount = MAX_TOTAL_STAKE; @@ -386,7 +386,7 @@ contract StakingTest is Test { // Enable unstake vm.stopPrank(); vm.startPrank(admin); - staking.updateUnestakeAllowed(true); + staking.updateUnstakeAllowed(true); vm.stopPrank(); // Partial unstake @@ -400,7 +400,7 @@ contract StakingTest is Test { vm.stopPrank(); } - // Estado del Contrato + // Contract state function testTotalStakedInPool() public { uint256 stakeAmount = MIN_STAKE; @@ -443,7 +443,7 @@ contract StakingTest is Test { assertEq(potentialReward, 0); } - // Recompensas + // Rewards function testRewardCalculation() public { uint256 stakeAmount = 1000 ether; uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; @@ -523,7 +523,7 @@ contract StakingTest is Test { vm.stopPrank(); } - // Validaciones de Tiempo + // Time validations function testClaimExactlyAtDuration() public { uint256 stakeAmount = MIN_STAKE; @@ -582,7 +582,7 @@ contract StakingTest is Test { vm.stopPrank(); } - // Múltiples Operaciones + // Multiple operations function testMultipleStakesAndClaims() public { uint256 stakeAmount = MIN_STAKE; @@ -627,7 +627,7 @@ contract StakingTest is Test { // Enable unstake vm.stopPrank(); vm.startPrank(admin); - staking.updateUnestakeAllowed(true); + staking.updateUnstakeAllowed(true); vm.stopPrank(); // Unstake both @@ -676,7 +676,7 @@ contract StakingTest is Test { // Enable unstake vm.stopPrank(); vm.startPrank(admin); - staking.updateUnestakeAllowed(true); + staking.updateUnstakeAllowed(true); vm.stopPrank(); // Unstake @@ -688,7 +688,7 @@ contract StakingTest is Test { vm.stopPrank(); } - // Límites del Pool + // Pool limits function testFailStakeExceedsMaxPoolStake() public { uint256 stakeAmount = staking.MAX_POOL_STAKE() + 1 ether; From a6984306a4c30a3bf599b53bf1f00e11d5bf32a8 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 17 Jun 2025 20:18:28 +0200 Subject: [PATCH 7/8] =?UTF-8?q?Implements=20improvements=20to=20the=20Stak?= =?UTF-8?q?ing=20contract,=20including=20the=20addition=20of=20a=20PRECISI?= =?UTF-8?q?ON=20constant,=20a=20new=20=E2=80=98unstaked=E2=80=99=20state?= =?UTF-8?q?=20in=20the=20StakeInfo=20structure,=20and=20the=20private=20?= =?UTF-8?q?=5FcalculateReward=20function=20to=20calculate=20rewards=20with?= =?UTF-8?q?=20high=20precision.=20Validations=20in=20the=20staking=20and?= =?UTF-8?q?=20claim=20functions=20are=20adjusted=20to=20correctly=20handle?= =?UTF-8?q?=20the=20new=20states,=20and=20related=20custom=20events=20and?= =?UTF-8?q?=20errors=20are=20updated.=20Additionally,=20ensures=20that=20t?= =?UTF-8?q?he=20available=20reward=20balance=20is=20properly=20managed=20d?= =?UTF-8?q?uring=20staking=20and=20claiming=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Staking.sol | 70 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 2bac102..1a5893a 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -10,7 +10,8 @@ import {NODL} from "./NODL.sol"; contract Staking is AccessControl, ReentrancyGuard, Pausable { bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); bytes32 public constant EMERGENCY_MANAGER_ROLE = keccak256("EMERGENCY_MANAGER_ROLE"); - + uint256 private constant PRECISION = 1e18; + NODL public immutable token; uint256 public immutable MIN_STAKE; @@ -22,11 +23,13 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { bool public unstakeAllowed = false; uint256 public totalStakedInPool; + uint256 public availableRewards; struct StakeInfo { uint256 amount; uint256 start; bool claimed; + bool unstaked; } mapping(address => StakeInfo[]) public stakes; @@ -52,6 +55,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { error InsufficientBalance(); error UnmetRequiredHoldingToken(); error InvalidMaxPoolStake(); + error AlreadyUnstaked(); event Staked(address indexed user, uint256 amount); event Claimed(address indexed user, uint256 amount, uint256 reward); @@ -91,6 +95,15 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { _grantRole(EMERGENCY_MANAGER_ROLE, _admin); } + /* + @dev Calculate reward with high precision + @param amount The staked amount + @return reward The calculated reward + */ + function _calculateReward(uint256 amount) private view returns (uint256) { + return (amount * REWARD_RATE * PRECISION) / (100 * PRECISION); + } + /* @dev Stake @param amount The amount of tokens to stake @@ -102,6 +115,13 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { if (amount < MIN_STAKE) revert MinStakeNotMet(); if (totalStakedInPool + amount > MAX_POOL_STAKE) revert ExceedsMaxPoolStake(); + // check if the contract has enough balance to give rewards + uint256 reward = _calculateReward(amount); + + if (availableRewards < reward) { + revert InsufficientRewardBalance(); + } + // check if the user do not exceed the max total stake per user uint256 newTotal = totalStakedByUser[msg.sender] + amount; if (newTotal > MAX_TOTAL_STAKE) revert ExceedsMaxTotalStake(); @@ -115,7 +135,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { token.transferFrom(msg.sender, address(this), amount); - stakes[msg.sender].push(StakeInfo({amount: amount, start: block.timestamp, claimed: false})); + stakes[msg.sender].push(StakeInfo({amount: amount, start: block.timestamp, claimed: false, unstaked: false})); totalStakedInPool += amount; totalStakedByUser[msg.sender] = newTotal; @@ -134,6 +154,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { if (allowance < amount) revert InsufficientAllowance(); token.transferFrom(msg.sender, address(this), amount); + availableRewards += amount; emit RewardsFunded(amount); } @@ -145,19 +166,23 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { @notice The contract must have enough balance for both stake and reward */ function claim(uint256 index) external nonReentrant whenNotPaused { - StakeInfo storage s = stakes[msg.sender][index]; + StakeInfo[] storage userStakes = stakes[msg.sender]; + if (index >= userStakes.length) revert NoStake(); + + StakeInfo storage s = userStakes[index]; if (s.amount == 0) revert NoStakeFound(); if (s.claimed) revert AlreadyClaimed(); + if (s.unstaked) revert AlreadyUnstaked(); if (block.timestamp < s.start + DURATION) revert TooEarly(); - uint256 reward = (s.amount * REWARD_RATE) / 100; + uint256 reward = _calculateReward(s.amount); uint256 totalToTransfer = s.amount + reward; - uint256 contractBalance = token.balanceOf(address(this)); - if (totalToTransfer > contractBalance) revert InsufficientRewardBalance(); + if (totalToTransfer > availableRewards) revert InsufficientRewardBalance(); s.claimed = true; totalStakedInPool -= s.amount; totalStakedByUser[msg.sender] -= s.amount; + availableRewards -= reward; token.transfer(msg.sender, totalToTransfer); emit Claimed(msg.sender, s.amount, reward); @@ -169,7 +194,8 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { */ function emergencyWithdraw() external onlyRole(EMERGENCY_MANAGER_ROLE) { uint256 balance = token.balanceOf(address(this)); - totalStakedInPool = 0; + availableRewards = 0; + token.transfer(msg.sender, balance); emit EmergencyWithdrawn(msg.sender, balance); } @@ -190,7 +216,11 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { @notice The stake can only be unstaked if the stake has not been claimed */ function unstake(uint256 index) external nonReentrant whenNotPaused { - StakeInfo storage s = stakes[msg.sender][index]; + StakeInfo[] storage userStakes = stakes[msg.sender]; + // check if the user has stake at the index + if (index >= userStakes.length) revert NoStake(); + + StakeInfo storage s = userStakes[index]; if (!unstakeAllowed) revert UnstakeNotAllowed(); if (s.amount == 0) revert NoStake(); if (s.claimed) revert AlreadyClaimed(); @@ -199,7 +229,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { totalStakedInPool -= returnAmount; totalStakedByUser[msg.sender] -= returnAmount; s.amount = 0; - s.claimed = true; + s.unstaked = true; token.transfer(msg.sender, returnAmount); @@ -212,6 +242,7 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { @return amount The amount of the stake @return start The start time of the stake @return claimed Whether the stake has been claimed + @return unstaked Whether the stake has been unstaked @return timeLeft The remaining time of the stake in seconds @return potentialReward The potential reward of the stake */ @@ -222,34 +253,33 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { uint256 amount, uint256 start, bool claimed, + bool unstaked, uint256 timeLeft, uint256 potentialReward ) { - if (index >= stakes[user].length) { - return (0, 0, false, 0, 0); - } - - StakeInfo storage s = stakes[user][index]; + StakeInfo[] memory userStakes = stakes[user]; + if (index >= userStakes.length) revert NoStake(); + + StakeInfo memory s = userStakes[index]; amount = s.amount; start = s.start; claimed = s.claimed; - timeLeft = 0; - potentialReward = 0; + unstaked = s.unstaked; // remaining time in seconds - if (s.amount > 0 && !s.claimed) { + if (s.amount > 0 && !s.claimed && !s.unstaked) { if (block.timestamp < s.start + DURATION) { timeLeft = s.start + DURATION - block.timestamp; } } // potential reward - if (s.amount > 0 && !s.claimed) { - potentialReward = (s.amount * REWARD_RATE) / 100; + if (s.amount > 0 && !s.claimed && !s.unstaked) { + potentialReward = _calculateReward(s.amount); } - return (amount, start, claimed, timeLeft, potentialReward); + return (amount, start, claimed, unstaked, timeLeft, potentialReward); } /* From e523bc648b1b10f621fc38a95a64fbe0199dc2b9 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 17 Jun 2025 21:07:02 +0200 Subject: [PATCH 8/8] Update the Staking contract to correct the logic for claiming and managing rewards. Redundant comments are removed and validations are adjusted to ensure that available rewards are correctly verified before claiming. New reward management and emergency roles are introduced in testing, and test coverage is improved to verify the behavior of staking and claiming functions --- src/Staking.sol | 14 +- test/Staking.t.sol | 759 ++++++++++++++++++++++++++++++--------------- 2 files changed, 523 insertions(+), 250 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 1a5893a..823d2b8 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -162,7 +162,6 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { @dev Claim @notice The stake can only be claimed if the stake has not been claimed @notice The stake can only be claimed if the stake has not been unstaked - @notice The stake can only be claimed if the stake has not been claimed @notice The contract must have enough balance for both stake and reward */ function claim(uint256 index) external nonReentrant whenNotPaused { @@ -176,16 +175,17 @@ contract Staking is AccessControl, ReentrancyGuard, Pausable { if (block.timestamp < s.start + DURATION) revert TooEarly(); uint256 reward = _calculateReward(s.amount); - uint256 totalToTransfer = s.amount + reward; - if (totalToTransfer > availableRewards) revert InsufficientRewardBalance(); + if (availableRewards < reward) revert InsufficientRewardBalance(); + uint256 amountToTransfer = s.amount; + s.amount = 0; s.claimed = true; - totalStakedInPool -= s.amount; - totalStakedByUser[msg.sender] -= s.amount; + totalStakedInPool -= amountToTransfer; + totalStakedByUser[msg.sender] -= amountToTransfer; availableRewards -= reward; - token.transfer(msg.sender, totalToTransfer); + token.transfer(msg.sender, amountToTransfer + reward); - emit Claimed(msg.sender, s.amount, reward); + emit Claimed(msg.sender, amountToTransfer, reward); } /* diff --git a/test/Staking.t.sol b/test/Staking.t.sol index 47fc006..f618c6b 100644 --- a/test/Staking.t.sol +++ b/test/Staking.t.sol @@ -15,13 +15,13 @@ contract StakingTest is Test { address public user2 = address(3); address public user3 = address(4); - bytes32 public constant FUNDER_ROLE = keccak256("FUNDER_ROLE"); - bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant EMERGENCY_MANAGER_ROLE = keccak256("EMERGENCY_MANAGER_ROLE"); uint256 public constant REWARD_RATE = 10; // 10% uint256 public constant MIN_STAKE = 100 * 1e18; // 100 tokens uint256 public constant MAX_TOTAL_STAKE = 1000 * 1e18; // 1000 tokens - uint256 public constant DURATION = 30 days; // 1/2 hour + uint256 public constant DURATION = 30 days; // 30 days uint256 public constant REQUIRED_HOLDING_TOKEN = 200 * 1e18; // 200 tokens function setUp() public { @@ -37,14 +37,10 @@ contract StakingTest is Test { admin ); - // Setup roles - staking.grantRole(FUNDER_ROLE, admin); - staking.grantRole(PAUSER_ROLE, admin); - // Mint tokens to users for testing token.mint(user1, 1000 ether); token.mint(user2, 1000 ether); - token.mint(admin, 1000 ether); + token.mint(admin, 1000000 ether); // Increased admin balance significantly token.mint(user3, 100 ether); vm.stopPrank(); } @@ -54,37 +50,43 @@ contract StakingTest is Test { vm.warp(block.timestamp + days_ * 1 days); } + // Helper function to calculate reward like the contract + function _calculateReward(uint256 amount) internal view returns (uint256) { + uint256 PRECISION = 1e18; + return (amount * REWARD_RATE * PRECISION) / (100 * PRECISION); + } + // Test roles function testRoles() public { assertTrue(staking.hasRole(staking.DEFAULT_ADMIN_ROLE(), admin)); - assertTrue(staking.hasRole(FUNDER_ROLE, admin)); - assertTrue(staking.hasRole(PAUSER_ROLE, admin)); + assertTrue(staking.hasRole(REWARDS_MANAGER_ROLE, admin)); + assertTrue(staking.hasRole(EMERGENCY_MANAGER_ROLE, admin)); - assertFalse(staking.hasRole(FUNDER_ROLE, user1)); - assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + assertFalse(staking.hasRole(REWARDS_MANAGER_ROLE, user1)); + assertFalse(staking.hasRole(EMERGENCY_MANAGER_ROLE, user1)); } function testGrantAndRevokeRoles() public { vm.startPrank(admin); // Grant roles - staking.grantRole(FUNDER_ROLE, user1); - staking.grantRole(PAUSER_ROLE, user1); - assertTrue(staking.hasRole(FUNDER_ROLE, user1)); - assertTrue(staking.hasRole(PAUSER_ROLE, user1)); + staking.grantRole(REWARDS_MANAGER_ROLE, user1); + staking.grantRole(EMERGENCY_MANAGER_ROLE, user1); + assertTrue(staking.hasRole(REWARDS_MANAGER_ROLE, user1)); + assertTrue(staking.hasRole(EMERGENCY_MANAGER_ROLE, user1)); // Revoke roles - staking.revokeRole(FUNDER_ROLE, user1); - staking.revokeRole(PAUSER_ROLE, user1); - assertFalse(staking.hasRole(FUNDER_ROLE, user1)); - assertFalse(staking.hasRole(PAUSER_ROLE, user1)); + staking.revokeRole(REWARDS_MANAGER_ROLE, user1); + staking.revokeRole(EMERGENCY_MANAGER_ROLE, user1); + assertFalse(staking.hasRole(REWARDS_MANAGER_ROLE, user1)); + assertFalse(staking.hasRole(EMERGENCY_MANAGER_ROLE, user1)); vm.stopPrank(); } function testFailNonAdminGrantRole() public { vm.startPrank(user1); - staking.grantRole(FUNDER_ROLE, user2); + staking.grantRole(REWARDS_MANAGER_ROLE, user2); vm.stopPrank(); } @@ -97,10 +99,10 @@ contract StakingTest is Test { staking.fundRewards(amount); vm.stopPrank(); - assertEq(token.balanceOf(address(staking)), amount); + assertEq(staking.availableRewards(), amount); } - function testFailNonFunderFundRewards() public { + function testFailNonRewardsManagerFundRewards() public { uint256 amount = 1000 ether; vm.startPrank(user1); @@ -120,7 +122,7 @@ contract StakingTest is Test { vm.stopPrank(); } - function testFailNonPauserPause() public { + function testFailNonAdminPause() public { vm.startPrank(user1); staking.pause(); vm.stopPrank(); @@ -130,17 +132,24 @@ contract StakingTest is Test { function testStake() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - vm.stopPrank(); - (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1, 0); + (uint256 amount, uint256 start, bool claimed, bool unstaked, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1, 0); assertEq(amount, stakeAmount); assertEq(start, block.timestamp); assertEq(claimed, false); + assertEq(unstaked, false); assertEq(timeLeft, DURATION); - assertEq(potentialReward, (stakeAmount * REWARD_RATE) / 100); + assertEq(potentialReward, _calculateReward(stakeAmount)); } function testFailStakeBelowMinimum() public { @@ -179,21 +188,61 @@ contract StakingTest is Test { vm.stopPrank(); } + function testStakeInsufficientRewardsReverts() public { + uint256 stakeAmount = MIN_STAKE; + + // Ensure no rewards are funded + assertEq(staking.availableRewards(), 0); + + // Calculate expected reward + uint256 expectedReward = _calculateReward(stakeAmount); + assertGt(expectedReward, 0, "Expected reward should be greater than 0"); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.InsufficientRewardBalance.selector); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // Additional test to verify the logic + function testStakeFailsWithoutRewards() public { + uint256 stakeAmount = MIN_STAKE; + + // Verify no rewards are available + assertEq(staking.availableRewards(), 0); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + + // This should fail because no rewards are available + vm.expectRevert(); + staking.stake(stakeAmount); + vm.stopPrank(); + } + // Test multiple stakes function testMultipleStakes() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount * 2); // First stake staking.stake(stakeAmount); - (uint256 amount1, , , , ) = staking.getStakeInfo(user1, 0); + (uint256 amount1, , , , , ) = staking.getStakeInfo(user1, 0); assertEq(amount1, stakeAmount); // Second stake staking.stake(stakeAmount); - (uint256 amount2, , , , ) = staking.getStakeInfo(user1, 1); + (uint256 amount2, , , , , ) = staking.getStakeInfo(user1, 1); assertEq(amount2, stakeAmount); vm.stopPrank(); @@ -202,27 +251,26 @@ contract StakingTest is Test { function testClaim() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + // Setup stake vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount * 2); - staking.fundRewards(stakeAmount * 2); - vm.stopPrank(); - // Move time forward and claim _skipTime(DURATION + 1); - vm.startPrank(user1); uint256 balanceBefore = token.balanceOf(user1); - staking.claim(0); // Added index parameter + staking.claim(0); uint256 balanceAfter = token.balanceOf(user1); - uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; + uint256 expectedReward = _calculateReward(stakeAmount); assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); vm.stopPrank(); } @@ -230,158 +278,63 @@ contract StakingTest is Test { function testFailClaimTooEarly() public { uint256 stakeAmount = MIN_STAKE; - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - staking.claim(0); // Added index parameter - vm.stopPrank(); - } - - function testUnstake() public { - uint256 stakeAmount = MIN_STAKE; - - // Setup stake - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - - // Enable unstake - vm.stopPrank(); + // Fund rewards first vm.startPrank(admin); - staking.updateUnstakeAllowed(true); - vm.stopPrank(); - - // Unstake - vm.startPrank(user1); - uint256 balanceBefore = token.balanceOf(user1); - staking.unstake(0); // Added index parameter - uint256 balanceAfter = token.balanceOf(user1); - - assertEq(balanceAfter - balanceBefore, stakeAmount); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); vm.stopPrank(); - } - - function testFailUnstakeNotAllowed() public { - uint256 stakeAmount = MIN_STAKE; vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - staking.unstake(0); // Added index parameter - vm.stopPrank(); - } - - function testFailUnstakeNonExistentStake() public { - vm.startPrank(user1); - staking.unstake(0); // Added index parameter + staking.claim(0); vm.stopPrank(); } - // Test emergency withdraw - function testEmergencyWithdraw() public { + function testFailClaimAlreadyUnstaked() public { uint256 stakeAmount = MIN_STAKE; - // Setup stake and fund rewards - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - vm.stopPrank(); - - vm.startPrank(admin); - token.approve(address(staking), stakeAmount); - staking.fundRewards(stakeAmount); - - uint256 balanceBefore = token.balanceOf(admin); - staking.emergencyWithdraw(); - uint256 balanceAfter = token.balanceOf(admin); - - assertEq(balanceAfter - balanceBefore, stakeAmount * 2); - vm.stopPrank(); - } - - function testFailNonAdminEmergencyWithdraw() public { - vm.startPrank(user1); - staking.emergencyWithdraw(); - vm.stopPrank(); - } - - // Test updateMaxPoolStake function - function testUpdateMaxPoolStake() public { - uint256 newMaxPoolStake = 10_000_000 ether; - - vm.startPrank(admin); - uint256 oldValue = staking.MAX_POOL_STAKE(); - staking.updateMaxPoolStake(newMaxPoolStake); - assertEq(staking.MAX_POOL_STAKE(), newMaxPoolStake); - vm.stopPrank(); - } - - function testFailNonAdminUpdateMaxPoolStake() public { - uint256 newMaxPoolStake = 10_000_000 ether; - - vm.startPrank(user1); - staking.updateMaxPoolStake(newMaxPoolStake); - vm.stopPrank(); - } - - function testFailUpdateMaxPoolStakeToZero() public { + // Fund rewards first vm.startPrank(admin); - staking.updateMaxPoolStake(0); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); vm.stopPrank(); - } - - function testFailUpdateMaxPoolStakeBelowCurrentStake() public { - uint256 stakeAmount = MIN_STAKE; // Setup stake vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - vm.stopPrank(); - // Try to update max pool stake below current stake - vm.startPrank(admin); - staking.updateMaxPoolStake(stakeAmount - 1); + // Enable unstake and unstake vm.stopPrank(); - } - - function testFailUpdateMaxPoolStakeWhenPaused() public { vm.startPrank(admin); - staking.pause(); - staking.updateMaxPoolStake(10_000_000 ether); + staking.updateUnstakeAllowed(true); vm.stopPrank(); - } - - // User limits - function testStakeAtMaxTotalStake() public { - uint256 stakeAmount = MAX_TOTAL_STAKE; vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); + staking.unstake(0); - assertEq(staking.totalStakedByUser(user1), stakeAmount); + // Try to claim unstaked stake + staking.claim(0); vm.stopPrank(); } - function testFailStakeAboveMaxTotalStake() public { - uint256 stakeAmount = MAX_TOTAL_STAKE + 1 ether; + function testUnstake() public { + uint256 stakeAmount = MIN_STAKE; - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); vm.stopPrank(); - } - - function testStakeAfterPartialUnstake() public { - uint256 stakeAmount = MAX_TOTAL_STAKE; + // Setup stake vm.startPrank(user1); - token.approve(address(staking), stakeAmount * 2); - - // First stake + token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - assertEq(staking.totalStakedByUser(user1), stakeAmount); // Enable unstake vm.stopPrank(); @@ -389,80 +342,69 @@ contract StakingTest is Test { staking.updateUnstakeAllowed(true); vm.stopPrank(); - // Partial unstake + // Unstake vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); staking.unstake(0); - assertEq(staking.totalStakedByUser(user1), 0); + uint256 balanceAfter = token.balanceOf(user1); - // New stake after unstake - staking.stake(stakeAmount); - assertEq(staking.totalStakedByUser(user1), stakeAmount); + assertEq(balanceAfter - balanceBefore, stakeAmount); + + // Verify stake is marked as unstaked + (uint256 amount, , bool claimed, bool unstaked, , ) = staking.getStakeInfo(user1, 0); + assertEq(amount, 0); + assertEq(claimed, false); + assertEq(unstaked, true); vm.stopPrank(); } - // Contract state - function testTotalStakedInPool() public { + function testFailUnstakeNotAllowed() public { uint256 stakeAmount = MIN_STAKE; - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); - assertEq(staking.totalStakedInPool(), stakeAmount); - - // Second user stakes + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); vm.stopPrank(); - vm.startPrank(user2); + + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - assertEq(staking.totalStakedInPool(), stakeAmount * 2); + staking.unstake(0); vm.stopPrank(); } - function testTotalStakedByUser() public { - uint256 stakeAmount = MIN_STAKE; - + function testFailUnstakeNonExistentStake() public { vm.startPrank(user1); - token.approve(address(staking), stakeAmount * 2); - - // First stake - staking.stake(stakeAmount); - assertEq(staking.totalStakedByUser(user1), stakeAmount); - - // Second stake - staking.stake(stakeAmount); - assertEq(staking.totalStakedByUser(user1), stakeAmount * 2); + staking.unstake(0); vm.stopPrank(); } function testGetStakeInfoForNonExistentStake() public { - (uint256 amount, uint256 start, bool claimed, uint256 timeLeft, uint256 potentialReward) = staking.getStakeInfo(user1, 0); - assertEq(amount, 0); - assertEq(start, 0); - assertEq(claimed, false); - assertEq(timeLeft, 0); - assertEq(potentialReward, 0); + // This test should expect a revert since getStakeInfo now reverts for non-existent stakes + vm.expectRevert(Staking.NoStake.selector); + staking.getStakeInfo(user1, 0); } // Rewards function testRewardCalculation() public { uint256 stakeAmount = 1000 ether; - uint256 expectedReward = (stakeAmount * REWARD_RATE) / 100; - - vm.startPrank(user1); - token.approve(address(staking), stakeAmount); - staking.stake(stakeAmount); + uint256 expectedReward = _calculateReward(stakeAmount); - // Fund rewards - vm.stopPrank(); + // Fund rewards first vm.startPrank(admin); token.approve(address(staking), expectedReward); staking.fundRewards(expectedReward); vm.stopPrank(); + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + // Move time forward and claim _skipTime(DURATION + 1); - vm.startPrank(user1); uint256 balanceBefore = token.balanceOf(user1); staking.claim(0); uint256 balanceAfter = token.balanceOf(user1); @@ -473,30 +415,35 @@ contract StakingTest is Test { function testFailClaimInsufficientRewards() public { uint256 stakeAmount = 1000 ether; - uint256 rewardAmount = (stakeAmount * REWARD_RATE) / 100; + uint256 rewardAmount = _calculateReward(stakeAmount); + + // Fund rewards for stake but not enough for claim + vm.startPrank(admin); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - // Fund less rewards than needed - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), rewardAmount - 1); - staking.fundRewards(rewardAmount - 1); - vm.stopPrank(); - // Move time forward and try to claim _skipTime(DURATION + 1); - vm.startPrank(user1); + vm.expectRevert(Staking.InsufficientRewardBalance.selector); staking.claim(0); vm.stopPrank(); } function testMultipleRewardsFundings() public { uint256 stakeAmount = 1000 ether; - uint256 rewardAmount = (stakeAmount * REWARD_RATE) / 100; + uint256 rewardAmount = _calculateReward(stakeAmount); + + // Fund rewards first + vm.startPrank(admin); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); vm.startPrank(user1); token.approve(address(staking), stakeAmount); @@ -505,8 +452,7 @@ contract StakingTest is Test { // Fund rewards multiple times vm.stopPrank(); vm.startPrank(admin); - token.approve(address(staking), rewardAmount * 3); - staking.fundRewards(rewardAmount); + token.approve(address(staking), rewardAmount * 2); staking.fundRewards(rewardAmount); staking.fundRewards(rewardAmount); vm.stopPrank(); @@ -527,21 +473,20 @@ contract StakingTest is Test { function testClaimExactlyAtDuration() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount); - staking.fundRewards(stakeAmount); - vm.stopPrank(); - // Move time forward exactly to duration _skipTime(DURATION); - vm.startPrank(user1); staking.claim(0); vm.stopPrank(); } @@ -549,21 +494,20 @@ contract StakingTest is Test { function testClaimAfterDuration() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount); - staking.fundRewards(stakeAmount); - vm.stopPrank(); - // Move time forward past duration _skipTime(DURATION + 1 days); - vm.startPrank(user1); staking.claim(0); vm.stopPrank(); } @@ -571,6 +515,13 @@ contract StakingTest is Test { function testFailClaimBeforeDuration() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); @@ -578,6 +529,7 @@ contract StakingTest is Test { // Try to claim before duration _skipTime(DURATION - 1); + vm.expectRevert(Staking.TooEarly.selector); staking.claim(0); vm.stopPrank(); } @@ -586,6 +538,13 @@ contract StakingTest is Test { function testMultipleStakesAndClaims() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount * 2); @@ -595,18 +554,10 @@ contract StakingTest is Test { // Second stake staking.stake(stakeAmount); - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount * 2); - staking.fundRewards(stakeAmount * 2); - vm.stopPrank(); - // Move time forward _skipTime(DURATION + 1); // Claim both stakes - vm.startPrank(user1); staking.claim(0); staking.claim(1); vm.stopPrank(); @@ -615,6 +566,13 @@ contract StakingTest is Test { function testMultipleStakesAndUnstakes() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount * 2); @@ -640,23 +598,22 @@ contract StakingTest is Test { function testStakeAfterClaim() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount * 2); // First stake staking.stake(stakeAmount); - // Fund rewards - vm.stopPrank(); - vm.startPrank(admin); - token.approve(address(staking), stakeAmount); - staking.fundRewards(stakeAmount); - vm.stopPrank(); - // Move time forward and claim _skipTime(DURATION + 1); - vm.startPrank(user1); staking.claim(0); // New stake after claim @@ -667,6 +624,13 @@ contract StakingTest is Test { function testStakeAfterUnstake() public { uint256 stakeAmount = MIN_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount * 2); @@ -706,6 +670,13 @@ contract StakingTest is Test { uint256 stakeAmount = MAX_TOTAL_STAKE; + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + vm.startPrank(user1); token.approve(address(staking), stakeAmount); staking.stake(stakeAmount); @@ -728,6 +699,13 @@ contract StakingTest is Test { } vm.stopPrank(); + // Fund rewards for all users + vm.startPrank(admin); + uint256 totalRewardAmount = _calculateReward(stakeAmount * numUsers); + token.approve(address(staking), totalRewardAmount); + staking.fundRewards(totalRewardAmount); + vm.stopPrank(); + // Each user makes a stake for(uint i = 0; i < numUsers; i++) { address user = address(uint160(uint(keccak256(abi.encodePacked(i))))); @@ -739,4 +717,299 @@ contract StakingTest is Test { assertEq(staking.totalStakedInPool(), stakeAmount * numUsers); } + + // Test precision in reward calculation + function testPrecisionInRewardCalculation() public { + uint256 stakeAmount = 999 ether; // Just below MAX_TOTAL_STAKE (1000 ether) + uint256 expectedReward = _calculateReward(stakeAmount); + + // Fund rewards first + vm.startPrank(admin); + token.approve(address(staking), expectedReward); + staking.fundRewards(expectedReward); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Check potential reward calculation + (uint256 amount, , , , , uint256 potentialReward) = staking.getStakeInfo(user1, 0); + assertEq(amount, stakeAmount); + assertEq(potentialReward, expectedReward); + vm.stopPrank(); + } + + // Test unstake state tracking + function testUnstakeStateTracking() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = (stakeAmount * REWARD_RATE) / 100; + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Check initial state + (uint256 amount1, , bool claimed1, bool unstaked1, , ) = staking.getStakeInfo(user1, 0); + assertEq(amount1, stakeAmount); + assertEq(claimed1, false); + assertEq(unstaked1, false); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + // Unstake + vm.startPrank(user1); + staking.unstake(0); + + // Check unstaked state + (uint256 amount2, , bool claimed2, bool unstaked2, , ) = staking.getStakeInfo(user1, 0); + assertEq(amount2, 0); + assertEq(claimed2, false); + assertEq(unstaked2, true); + vm.stopPrank(); + } + + // Test claim state tracking + function testClaimStateTracking() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + // Check initial state + (uint256 amount1, , bool claimed1, bool unstaked1, , ) = staking.getStakeInfo(user1, 0); + assertEq(amount1, stakeAmount); + assertEq(claimed1, false); + assertEq(unstaked1, false); + + // Move time forward and claim + _skipTime(DURATION + 1); + + vm.startPrank(user1); + staking.claim(0); + + // Check claimed state + (uint256 amount2, , bool claimed2, bool unstaked2, , ) = staking.getStakeInfo(user1, 0); + assertEq(amount2, 0); + assertEq(claimed2, true); + assertEq(unstaked2, false); + vm.stopPrank(); + } + + // Test emergency withdraw + function testEmergencyWithdraw() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + vm.startPrank(admin); + uint256 balanceBefore = token.balanceOf(admin); + staking.emergencyWithdraw(); + uint256 balanceAfter = token.balanceOf(admin); + + assertEq(balanceAfter - balanceBefore, stakeAmount + rewardAmount); + assertEq(staking.availableRewards(), 0); + vm.stopPrank(); + } + + function testFailNonEmergencyManagerEmergencyWithdraw() public { + vm.startPrank(user1); + staking.emergencyWithdraw(); + vm.stopPrank(); + } + + // Test updateMaxPoolStake function + function testUpdateMaxPoolStake() public { + uint256 newMaxPoolStake = 10_000_000 ether; + + vm.startPrank(admin); + uint256 oldValue = staking.MAX_POOL_STAKE(); + staking.updateMaxPoolStake(newMaxPoolStake); + assertEq(staking.MAX_POOL_STAKE(), newMaxPoolStake); + vm.stopPrank(); + } + + function testFailNonAdminUpdateMaxPoolStake() public { + uint256 newMaxPoolStake = 10_000_000 ether; + + vm.startPrank(user1); + staking.updateMaxPoolStake(newMaxPoolStake); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeToZero() public { + vm.startPrank(admin); + staking.updateMaxPoolStake(0); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeBelowCurrentStake() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // Setup stake + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + // Try to update max pool stake below current stake + vm.startPrank(admin); + staking.updateMaxPoolStake(stakeAmount - 1); + vm.stopPrank(); + } + + function testFailUpdateMaxPoolStakeWhenPaused() public { + vm.startPrank(admin); + staking.pause(); + staking.updateMaxPoolStake(10_000_000 ether); + vm.stopPrank(); + } + + // User limits + function testStakeAtMaxTotalStake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + assertEq(staking.totalStakedByUser(user1), stakeAmount); + vm.stopPrank(); + } + + function testFailStakeAboveMaxTotalStake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE + 1 ether; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function testStakeAfterPartialUnstake() public { + uint256 stakeAmount = MAX_TOTAL_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + // Partial unstake + vm.startPrank(user1); + staking.unstake(0); + assertEq(staking.totalStakedByUser(user1), 0); + + // New stake after unstake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + vm.stopPrank(); + } + + // Contract state + function testTotalStakedInPool() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + assertEq(staking.totalStakedInPool(), stakeAmount); + + // Second user stakes + vm.stopPrank(); + vm.startPrank(user2); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + assertEq(staking.totalStakedInPool(), stakeAmount * 2); + vm.stopPrank(); + } + + function testTotalStakedByUser() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards first + vm.startPrank(admin); + uint256 rewardAmount = _calculateReward(stakeAmount * 2); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount * 2); + + // First stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount); + + // Second stake + staking.stake(stakeAmount); + assertEq(staking.totalStakedByUser(user1), stakeAmount * 2); + vm.stopPrank(); + } }