diff --git a/.cspell.json b/.cspell.json index 1ae4dc18..2598ce48 100644 --- a/.cspell.json +++ b/.cspell.json @@ -162,6 +162,11 @@ "girlnext", "Checksummed", "checksummed", - "BMNFT" + "BMNFT", + "unstake", + "unstakes", + "Unstaked", + "funder", + "Fundings" ] } diff --git a/README.md b/README.md index 0c4198b8..0e7540da 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,46 @@ 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 +DEPLOYER_PRIVATE_KEY=0x... \ +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 +``` +- DEPLOYER_PRIVATE_KEY: Private key of the deploying account. +- GOV_ADDR: Address of the governance contract (receives admin, rewards-manager, and emergency-manager roles). +- STAKE_TOKEN: Address of the token to stake. +- MIN_STAKE: Minimum amount of tokens a user can stake (whole tokens, scaled by 1e18 in the script). +- MAX_TOTAL_STAKE: Maximum amount of tokens a single user can stake (whole tokens, scaled by 1e18). +- DURATION: Duration of the staking period, in seconds. +- REWARD_RATE: Reward rate as a whole-number percent of the staked amount, paid at the end of the period. +- REQUIRED_HOLDING_TOKEN: Minimum token balance a user must hold to participate (whole tokens, scaled by 1e18). + +### Trust assumptions + +The admin account (GOV_ADDR) holds the default-admin, rewards-manager, and emergency-manager roles. It can pause the contract (which blocks `claim`/`unstake`), toggle `unstakeAllowed` (which defaults to false, so before the period ends users can only exit once the admin enables it), and — while paused — call `emergencyWithdraw` to sweep the entire contract balance, including staked principal. Deployments intended for untrusted users should split these roles and/or place them behind a timelock or multisig. + ## Scripts ### Checking on bridging proposals @@ -242,3 +282,17 @@ Use all these artifacts on the contract verification page on Etherscan for your - [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/hardhat-deploy/deploy_staking.dp.ts b/hardhat-deploy/deploy_staking.dp.ts new file mode 100644 index 00000000..d79d9df6 --- /dev/null +++ b/hardhat-deploy/deploy_staking.dp.ts @@ -0,0 +1,75 @@ +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(); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const tokenAddress = requireEnv("STAKE_TOKEN"); + const adminAddress = requireEnv("GOV_ADDR"); + const minStakeAmount = requireEnv("MIN_STAKE"); + const stakingPeriod = requireEnv("DURATION"); + const rewardRate = requireEnv("REWARD_RATE"); + const maxTotalStake = requireEnv("MAX_TOTAL_STAKE"); + const requiredHoldingToken = requireEnv("REQUIRED_HOLDING_TOKEN"); + + // REWARD_RATE is an integer percent and DURATION is an integer number of seconds + if (!/^\d+$/.test(rewardRate)) { + throw new Error(`REWARD_RATE must be a whole-number percent, got "${rewardRate}"`); + } + if (!/^\d+$/.test(stakingPeriod)) { + throw new Error(`DURATION must be a whole number of seconds, got "${stakingPeriod}"`); + } + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(requireEnv("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, + ]; + + const staking = await deployContract(deployer, "Staking", constructorArgs); + const contractAddress = await staking.getAddress(); + console.log(`Staking contract deployed at ${contractAddress}`); + console.log( + `!!! Do not forget to grant token approval to Staking contract at ${contractAddress} !!!` + ); + + console.log("Starting contract verification..."); + try { + await hre.run("verify:verify", { + address: contractAddress, + 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 00000000..b67392da --- /dev/null +++ b/src/Staking.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.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"; + +contract Staking is AccessControl, ReentrancyGuard, Pausable { + using SafeERC20 for IERC20; + + bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant EMERGENCY_MANAGER_ROLE = keccak256("EMERGENCY_MANAGER_ROLE"); + + IERC20 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 AlreadyClaimed(); + error TooEarly(); + error NoStake(); + error UnstakeNotAllowed(); + error InsufficientRewardBalance(); + 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 seconds + @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 = IERC20(nodlToken); + REWARD_RATE = _rewardRate; + MIN_STAKE = _minStake; + MAX_TOTAL_STAKE = _maxTotalStake; + DURATION = _duration; + REQUIRED_HOLDING_TOKEN = _requiredHoldingToken; + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(REWARDS_MANAGER_ROLE, _admin); + _grantRole(EMERGENCY_MANAGER_ROLE, _admin); + } + + /* + @dev Calculate reward + @param amount The staked amount + @return reward The calculated reward + */ + function _calculateReward(uint256 amount) private view returns (uint256) { + return (amount * REWARD_RATE) / 100; + } + + /* + @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's future reward is reserved from availableRewards so every accepted stake is claimable + */ + function stake(uint256 amount) external nonReentrant whenNotPaused { + if (amount < MIN_STAKE) revert MinStakeNotMet(); + if (totalStakedInPool + amount > MAX_POOL_STAKE) revert ExceedsMaxPoolStake(); + + // reserve the reward so concurrent stakes cannot oversubscribe the pool + uint256 reward = _calculateReward(amount); + + if (availableRewards < reward) { + revert InsufficientRewardBalance(); + } + availableRewards -= reward; + + // 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.safeTransferFrom(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 { + token.safeTransferFrom(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 reward was reserved at stake time, so a matured stake is always claimable + */ + 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.claimed) revert AlreadyClaimed(); + if (s.unstaked) revert AlreadyUnstaked(); + if (block.timestamp < s.start + DURATION) revert TooEarly(); + + uint256 reward = _calculateReward(s.amount); + + uint256 amountToTransfer = s.amount; + s.amount = 0; + s.claimed = true; + totalStakedInPool -= amountToTransfer; + totalStakedByUser[msg.sender] -= amountToTransfer; + token.safeTransfer(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) whenPaused { + uint256 balance = token.balanceOf(address(this)); + availableRewards = 0; + + token.safeTransfer(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.claimed) revert AlreadyClaimed(); + if (s.unstaked) revert AlreadyUnstaked(); + + uint256 returnAmount = s.amount; + totalStakedInPool -= returnAmount; + totalStakedByUser[msg.sender] -= returnAmount; + s.amount = 0; + s.unstaked = true; + + // the reward reserved at stake time is forfeited back to the pool + availableRewards += _calculateReward(returnAmount); + + token.safeTransfer(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 + ) + { + if (index >= stakes[user].length) revert NoStake(); + + StakeInfo storage s = stakes[user][index]; + amount = s.amount; + start = s.start; + claimed = s.claimed; + unstaked = s.unstaked; + + if (s.amount > 0 && !s.claimed && !s.unstaked) { + if (block.timestamp < s.start + DURATION) { + timeLeft = s.start + DURATION - block.timestamp; + } + potentialReward = _calculateReward(s.amount); + } + + return (amount, start, claimed, unstaked, timeLeft, potentialReward); + } + + /* + @dev Number of stakes recorded for a user (including claimed/unstaked entries) + @param user The address to query + @return The length of the user's stakes array + */ + function stakesCount(address user) external view returns (uint256) { + return stakes[user].length; + } + + /* + @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 00000000..db5395a3 --- /dev/null +++ b/test/Staking.t.sol @@ -0,0 +1,1099 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Test, console2} from "forge-std/Test.sol"; +import {NODL} from "../src/NODL.sol"; +import {Staking} from "../src/Staking.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.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 by a number of seconds + // (DURATION is stored in seconds, so callers pass second-denominated values) + function _skipTime(uint256 secs) internal { + vm.warp(block.timestamp + secs); + } + + // 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 test_RevertWhen_NonAdminGrantsRole() public { + vm.startPrank(user1); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user1, bytes32(0)) + ); + 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 test_RevertWhen_NonRewardsManagerFundsRewards() public { + uint256 amount = 1000 ether; + + vm.startPrank(user1); + token.approve(address(staking), amount); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user1, REWARDS_MANAGER_ROLE) + ); + 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 test_RevertWhen_NonAdminPauses() public { + vm.startPrank(user1); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user1, bytes32(0)) + ); + 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 test_RevertWhen_StakeBelowMinimum() public { + uint256 stakeAmount = (MIN_STAKE - (1 ether)); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.MinStakeNotMet.selector); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function test_RevertWhen_StakeAboveMaximum() public { + uint256 stakeAmount = (MAX_TOTAL_STAKE + (1 ether)); + + // Fund rewards so the per-user maximum check is actually reached + vm.startPrank(admin); + token.approve(address(staking), _calculateReward(stakeAmount)); + staking.fundRewards(_calculateReward(stakeAmount)); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.ExceedsMaxTotalStake.selector); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function test_RevertWhen_StakeInsufficientBalance() public { + // user needs >= REQUIRED_HOLDING_TOKEN balance but less than the stake + // amount (which must stay within MAX_TOTAL_STAKE) to reach the balance check + uint256 stakeAmount = 500 ether; + vm.prank(admin); + token.mint(user3, 200 ether); // user3 now holds 300 ether + + vm.startPrank(admin); + token.approve(address(staking), _calculateReward(stakeAmount)); + staking.fundRewards(_calculateReward(stakeAmount)); + vm.stopPrank(); + + vm.startPrank(user3); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.InsufficientBalance.selector); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + function test_RevertWhen_StakeUnmetRequiredHoldingToken() public { + uint256 stakeAmount = MIN_STAKE; + + // Fund rewards so the holding requirement check is actually reached + vm.startPrank(admin); + token.approve(address(staking), _calculateReward(stakeAmount)); + staking.fundRewards(_calculateReward(stakeAmount)); + vm.stopPrank(); + + vm.startPrank(user3); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.UnmetRequiredHoldingToken.selector); + 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 test_RevertWhen_ClaimTooEarly() 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.expectRevert(Staking.TooEarly.selector); + staking.claim(0); + vm.stopPrank(); + } + + function test_RevertWhen_ClaimAlreadyUnstaked() 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 an unstaked stake + vm.expectRevert(Staking.AlreadyUnstaked.selector); + 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 test_RevertWhen_UnstakeNotAllowed() 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.expectRevert(Staking.UnstakeNotAllowed.selector); + staking.unstake(0); + vm.stopPrank(); + } + + function test_RevertWhen_UnstakeNonExistentStake() public { + vm.startPrank(user1); + vm.expectRevert(Staking.NoStake.selector); + 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(); + } + + // Rewards are reserved at stake time, so once the pool is fully committed a + // further stake reverts rather than letting a later claim fail (oversubscription). + function test_RevertWhen_StakeExhaustsReservedRewards() public { + uint256 stakeAmount = MIN_STAKE; + uint256 rewardAmount = _calculateReward(stakeAmount); + + // Fund rewards for exactly one stake + vm.startPrank(admin); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + // First staker reserves the entire reward pool + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + vm.stopPrank(); + + assertEq(staking.availableRewards(), 0); + + // Second staker cannot be admitted: their reward is not funded + vm.startPrank(user2); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.InsufficientRewardBalance.selector); + staking.stake(stakeAmount); + vm.stopPrank(); + } + + // A matured stake stays claimable even after availableRewards reads 0, + // because the reward was reserved when the stake was created. + function test_ClaimSucceedsAfterRewardsFullyReserved() public { + uint256 stakeAmount = MIN_STAKE; + uint256 rewardAmount = _calculateReward(stakeAmount); + + vm.startPrank(admin); + token.approve(address(staking), rewardAmount); + staking.fundRewards(rewardAmount); + vm.stopPrank(); + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + staking.stake(stakeAmount); + + assertEq(staking.availableRewards(), 0); + + _skipTime(DURATION + 1); + + uint256 balanceBefore = token.balanceOf(user1); + staking.claim(0); + assertEq(token.balanceOf(user1) - balanceBefore, stakeAmount + rewardAmount); + 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 test_RevertWhen_ClaimBeforeDuration() 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 one second before the duration elapses + _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 test_RevertWhen_StakeExceedsMaxPoolStake() public { + uint256 stakeAmount = staking.MAX_POOL_STAKE() + 1 ether; + + vm.startPrank(user1); + token.approve(address(staking), stakeAmount); + vm.expectRevert(Staking.ExceedsMaxPoolStake.selector); + 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); + // emergencyWithdraw requires the contract to be paused first + staking.pause(); + uint256 balanceBefore = token.balanceOf(admin); + staking.emergencyWithdraw(); + uint256 balanceAfter = token.balanceOf(admin); + + assertEq(balanceAfter - balanceBefore, stakeAmount + rewardAmount); + assertEq(staking.availableRewards(), 0); + vm.stopPrank(); + } + + function test_RevertWhen_NonEmergencyManagerEmergencyWithdraws() public { + // Pause first so the failure is attributable to the role check, not the pause guard + vm.prank(admin); + staking.pause(); + + vm.startPrank(user1); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user1, EMERGENCY_MANAGER_ROLE) + ); + staking.emergencyWithdraw(); + vm.stopPrank(); + } + + function test_RevertWhen_EmergencyWithdrawNotPaused() public { + vm.prank(admin); + vm.expectRevert(Pausable.ExpectedPause.selector); + staking.emergencyWithdraw(); + } + + // 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 test_RevertWhen_NonAdminUpdatesMaxPoolStake() public { + uint256 newMaxPoolStake = 10_000_000 ether; + + vm.startPrank(user1); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user1, bytes32(0)) + ); + staking.updateMaxPoolStake(newMaxPoolStake); + vm.stopPrank(); + } + + function test_RevertWhen_UpdateMaxPoolStakeToZero() public { + vm.startPrank(admin); + vm.expectRevert(Staking.InvalidMaxPoolStake.selector); + staking.updateMaxPoolStake(0); + vm.stopPrank(); + } + + function test_RevertWhen_UpdateMaxPoolStakeBelowCurrentStake() 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); + vm.expectRevert(Staking.InvalidMaxPoolStake.selector); + staking.updateMaxPoolStake(stakeAmount - 1); + vm.stopPrank(); + } + + function test_RevertWhen_UpdateMaxPoolStakeWhenPaused() public { + vm.startPrank(admin); + staking.pause(); + vm.expectRevert(Pausable.EnforcedPause.selector); + 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 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(); + } +}