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/README.md b/README.md index bb7d610..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 @@ -180,7 +214,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/Staking.sol b/src/Staking.sol new file mode 100644 index 0000000..823d2b8 --- /dev/null +++ b/src/Staking.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.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 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; + uint256 public immutable MAX_TOTAL_STAKE; + uint256 public MAX_POOL_STAKE = 5_000_000 ether; + uint256 public immutable DURATION; + uint256 public immutable REWARD_RATE; + uint256 public immutable REQUIRED_HOLDING_TOKEN; + 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; + mapping(address => uint256) public totalStakedByUser; + + error ZeroAddress(); + error InvalidRewardRate(); + error InvalidMinStake(); + error InvalidMaxTotalStake(); + error InvalidDuration(); + error MinStakeNotMet(); + error ExceedsMaxTotalStake(); + error ExceedsMaxPoolStake(); + error AlreadyStaked(); + error NoStakeFound(); + error AlreadyClaimed(); + error TooEarly(); + error NoStake(); + error UnstakeNotAllowed(); + error InsufficientRewardBalance(); + error InsufficientAllowance(); + error InsufficientTotalStaked(); + error InsufficientBalance(); + error UnmetRequiredHoldingToken(); + error InvalidMaxPoolStake(); + error AlreadyUnstaked(); + + 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 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 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 _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 <= _minStake) revert InvalidMaxTotalStake(); + if (_duration == 0) revert InvalidDuration(); + if (_admin == address(0)) revert ZeroAddress(); + + token = NODL(nodlToken); + REWARD_RATE = _rewardRate; + MIN_STAKE = _minStake; + MAX_TOTAL_STAKE = _maxTotalStake; + DURATION = _duration * 1 seconds; + REQUIRED_HOLDING_TOKEN = _requiredHoldingToken; + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(REWARDS_MANAGER_ROLE, _admin); + _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 + @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 whenNotPaused { + 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(); + + // 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].push(StakeInfo({amount: amount, start: block.timestamp, claimed: false, unstaked: false})); + + totalStakedInPool += amount; + totalStakedByUser[msg.sender] = newTotal; + + 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 onlyRole(REWARDS_MANAGER_ROLE) whenNotPaused { + uint256 allowance = token.allowance(msg.sender, address(this)); + if (allowance < amount) revert InsufficientAllowance(); + + token.transferFrom(msg.sender, address(this), amount); + availableRewards += 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 contract must have enough balance for both stake and reward + */ + function claim(uint256 index) external nonReentrant whenNotPaused { + 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 = _calculateReward(s.amount); + if (availableRewards < reward) revert InsufficientRewardBalance(); + + uint256 amountToTransfer = s.amount; + s.amount = 0; + s.claimed = true; + totalStakedInPool -= amountToTransfer; + totalStakedByUser[msg.sender] -= amountToTransfer; + availableRewards -= reward; + token.transfer(msg.sender, amountToTransfer + reward); + + emit Claimed(msg.sender, amountToTransfer, reward); + } + + /* + @dev Emergency withdraw + @notice The owner can withdraw the tokens in case of emergency + */ + function emergencyWithdraw() external onlyRole(EMERGENCY_MANAGER_ROLE) { + uint256 balance = token.balanceOf(address(this)); + availableRewards = 0; + + token.transfer(msg.sender, balance); + emit EmergencyWithdrawn(msg.sender, balance); + } + + /* + @dev Update the unstake allowed status + @param allowed The new unstake allowed status + */ + function updateUnstakeAllowed(bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) whenNotPaused { + 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(uint256 index) external nonReentrant whenNotPaused { + 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(); + + uint256 returnAmount = s.amount; + totalStakedInPool -= returnAmount; + totalStakedByUser[msg.sender] -= returnAmount; + s.amount = 0; + s.unstaked = true; + + 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 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 + */ + function getStakeInfo(address user, uint256 index) + external + view + returns ( + uint256 amount, + uint256 start, + bool claimed, + bool unstaked, + uint256 timeLeft, + uint256 potentialReward + ) + { + 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; + unstaked = s.unstaked; + + // remaining time in seconds + 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 && !s.unstaked) { + potentialReward = _calculateReward(s.amount); + } + + return (amount, start, claimed, unstaked, timeLeft, potentialReward); + } + + /* + @dev Pause the contract + @notice Only owner can pause the contract + */ + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); + } + + /* + @dev Unpause the contract + @notice Only owner can unpause the contract + */ + 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/Staking.t.sol b/test/Staking.t.sol new file mode 100644 index 0000000..f618c6b --- /dev/null +++ b/test/Staking.t.sol @@ -0,0 +1,1015 @@ +// 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 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; // 30 days + 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 + ); + + // Mint tokens to users for testing + token.mint(user1, 1000 ether); + token.mint(user2, 1000 ether); + token.mint(admin, 1000000 ether); // Increased admin balance significantly + 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); + } + + // 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(REWARDS_MANAGER_ROLE, admin)); + assertTrue(staking.hasRole(EMERGENCY_MANAGER_ROLE, admin)); + + assertFalse(staking.hasRole(REWARDS_MANAGER_ROLE, user1)); + assertFalse(staking.hasRole(EMERGENCY_MANAGER_ROLE, user1)); + } + + function testGrantAndRevokeRoles() public { + vm.startPrank(admin); + + // Grant roles + 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(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(REWARDS_MANAGER_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(staking.availableRewards(), amount); + } + + function testFailNonRewardsManagerFundRewards() 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 testFailNonAdminPause() public { + vm.startPrank(user1); + staking.pause(); + vm.stopPrank(); + } + + // Test stake function + 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); + + (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, _calculateReward(stakeAmount)); + } + + 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(); + } + + 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); + 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; + + // 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); + + // Move time forward and claim + _skipTime(DURATION + 1); + + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(0); + uint256 balanceAfter = token.balanceOf(user1); + + uint256 expectedReward = _calculateReward(stakeAmount); + assertEq(balanceAfter - balanceBefore, stakeAmount + expectedReward); + vm.stopPrank(); + } + + function testFailClaimTooEarly() 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); + staking.claim(0); + vm.stopPrank(); + } + + function testFailClaimAlreadyUnstaked() 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); + + // Enable unstake and unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + vm.startPrank(user1); + staking.unstake(0); + + // Try to claim unstaked stake + staking.claim(0); + vm.stopPrank(); + } + + function testUnstake() 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); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + // Unstake + vm.startPrank(user1); + uint256 balanceBefore = token.balanceOf(user1); + staking.unstake(0); + uint256 balanceAfter = token.balanceOf(user1); + + 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(); + } + + function testFailUnstakeNotAllowed() 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); + staking.unstake(0); + vm.stopPrank(); + } + + function testFailUnstakeNonExistentStake() public { + vm.startPrank(user1); + staking.unstake(0); + vm.stopPrank(); + } + + function testGetStakeInfoForNonExistentStake() public { + // 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 = _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); + + // Move time forward and claim + _skipTime(DURATION + 1); + + 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 = _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); + + // Move time forward and try to claim + _skipTime(DURATION + 1); + + vm.expectRevert(Staking.InsufficientRewardBalance.selector); + staking.claim(0); + vm.stopPrank(); + } + + function testMultipleRewardsFundings() public { + uint256 stakeAmount = 1000 ether; + 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); + staking.stake(stakeAmount); + + // Fund rewards multiple times + vm.stopPrank(); + vm.startPrank(admin); + token.approve(address(staking), rewardAmount * 2); + 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(); + } + + // Time validations + 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); + + // Move time forward exactly to duration + _skipTime(DURATION); + + staking.claim(0); + vm.stopPrank(); + } + + 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); + + // Move time forward past duration + _skipTime(DURATION + 1 days); + + staking.claim(0); + vm.stopPrank(); + } + + 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); + + // Try to claim before duration + _skipTime(DURATION - 1); + + vm.expectRevert(Staking.TooEarly.selector); + staking.claim(0); + vm.stopPrank(); + } + + // Multiple operations + 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); + + // First stake + staking.stake(stakeAmount); + + // Second stake + staking.stake(stakeAmount); + + // Move time forward + _skipTime(DURATION + 1); + + // Claim both stakes + staking.claim(0); + staking.claim(1); + vm.stopPrank(); + } + + 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); + + // First stake + staking.stake(stakeAmount); + + // Second stake + staking.stake(stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + // Unstake both + vm.startPrank(user1); + staking.unstake(0); + staking.unstake(1); + vm.stopPrank(); + } + + 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); + + // Move time forward and claim + _skipTime(DURATION + 1); + + staking.claim(0); + + // New stake after claim + staking.stake(stakeAmount); + vm.stopPrank(); + } + + 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); + + // First stake + staking.stake(stakeAmount); + + // Enable unstake + vm.stopPrank(); + vm.startPrank(admin); + staking.updateUnstakeAllowed(true); + vm.stopPrank(); + + // Unstake + vm.startPrank(user1); + staking.unstake(0); + + // New stake after unstake + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // Pool limits + 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; + + // 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.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(); + + // 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))))); + vm.startPrank(user); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + 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(); + } +}