diff --git a/foundry.toml b/foundry.toml index e2737e9..2782732 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,7 @@ evm_version = "paris" optimizer = true optimizer_runs = 10_000_000 - solc_version = "0.8.23" + solc_version = "0.8.25" verbosity = 3 [profile.ci] diff --git a/src/StakingV2.sol b/src/StakingV2.sol index 842d144..ea6b471 100644 --- a/src/StakingV2.sol +++ b/src/StakingV2.sol @@ -27,7 +27,7 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice the staking token, i.e. SHU /// @dev set in initialize, can't be changed - IERC20 public immutable STAKING_TOKEN; + IERC20 public STAKING_TOKEN; /*////////////////////////////////////////////////////////////// VARIABLES @@ -51,7 +51,7 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice the stake struct /// @dev timestamp is the time the stake was made - struct Deposit { + struct Stake { uint256 amount; uint256 timestamp; uint256 lockPeriod; @@ -62,7 +62,7 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { //////////////////////////////////////////////////////////////*/ /// @notice the keyper stakes mapping - mapping(address keyper => Deposit[]) public deposits; + mapping(address keyper => Stake[]) public stakes; /// TODO when remove keyper also unstake the first stake /// @notice the keypers mapping @@ -80,7 +80,7 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { uint256 indexed amount, uint256 lockPeriod ); - event Unstaked(address user, uint256 amount); + event Unstaked(address user, uint256 amount, uint256 shares); event ClaimRewards(address user, uint256 rewards); event KeyperSet(address keyper, bool isKeyper); @@ -95,10 +95,9 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { } /// @notice Update rewards for a keyper - /// @param caller The keyper address modifier updateRewards() { // Distribute rewards - rewardsDistributor.distributeReward(address(stakingToken)); + rewardsDistributor.distributeReward(address(STAKING_TOKEN)); _; } @@ -110,14 +109,14 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice Initialize the contract /// @param newOwner The owner of the contract, i.e. the DAO contract address - /// @param _stakingToken The address of the staking token, i.e. SHU + /// @param stakingToken The address of the staking token, i.e. SHU /// @param _rewardsDistributor The address of the rewards distributor /// contract /// @param _lockPeriod The lock period in seconds /// @param _minStake The minimum stake amount function initialize( address newOwner, - address _stakingToken, + address stakingToken, address _rewardsDistributor, uint256 _lockPeriod, uint256 _minStake @@ -129,7 +128,7 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { // Transfer ownership to the DAO contract _transferOwnership(newOwner); - STAKING_TOKEN = IERC20(_stakingToken); + STAKING_TOKEN = IERC20(stakingToken); rewardsDistributor = IRewardsDistributor(_rewardsDistributor); lockPeriod = _lockPeriod; minStake = _minStake; @@ -151,10 +150,10 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { address keyper = msg.sender; // Get the keyper stakes - Deposit[] storage stakes = deposits[keyper]; + Stake[] storage keyperStakes = stakes[keyper]; // If the keyper has no stakes, the first stake must be at least the minimum stake - if (stakes.length == 0) { + if (keyperStakes.length == 0) { require( amount >= minStake, "The first stake must be at least the minimum stake" @@ -174,14 +173,14 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /////////////////////////// INTERACTIONS /////////////////////////// // Lock the SHU in the contract - stakingToken.safeTransferFrom(keyper, address(this), amount); + STAKING_TOKEN.safeTransferFrom(keyper, address(this), amount); // Record the new stake - stakes.push(Stake(amount, block.timestamp, lockPeriod)); + keyperStakes.push(Stake(amount, block.timestamp, lockPeriod)); emit Staked(keyper, amount, lockPeriod); - return stakes.length - 1; + return keyperStakes.length - 1; } /// @notice Unstake SHU @@ -210,10 +209,10 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { uint256 amount ) external updateRewards { /////////////////////////// CHECKS /////////////////////////////// - require(stakeIndex < deposits[keyper].length, "Invalid stake index"); + require(stakeIndex < stakes[keyper].length, "Invalid stake index"); // Gets the keyper stake - Stake storage keyperStake = deposits[keyper][stakeIndex]; + Stake storage keyperStake = stakes[keyper][stakeIndex]; uint256 maxWithdrawAmount; @@ -277,16 +276,16 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { // If the stake is empty, remove it if (keyperStake.amount == 0) { // Remove the stake from the keyper's stake array - deposits[keyper][stakeIndex] = deposits[keyper][ - deposits[keyper].length - 1 + stakes[keyper][stakeIndex] = stakes[keyper][ + stakes[keyper].length - 1 ]; - deposits[keyper].pop(); + stakes[keyper].pop(); } /////////////////////////// INTERACTIONS /////////////////////////// // Transfer the SHU to the keyper - stakingToken.safeTransfer(keyper, amount); + STAKING_TOKEN.safeTransfer(keyper, amount); emit Unstaked(keyper, amount, shares); } @@ -334,9 +333,9 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { _burn(keyper, shares); - rewardToken.safeTransfer(keyper, amount); + STAKING_TOKEN.safeTransfer(keyper, amount); - emit ClaimRewards(keyper, address(rewardToken), amount); + emit ClaimRewards(keyper, amount); } /*////////////////////////////////////////////////////////////// @@ -405,6 +404,19 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { (totalLocked[keyper] >= minStake ? totalLocked[keyper] : minStake); } + function maxWithdraw( + address keyper, + uint256 unlockedAmount + ) public view virtual returns (uint256) { + return + convertToAssets(balanceOf(keyper)) - + ( + (totalLocked[keyper] - unlockedAmount) >= minStake + ? totalLocked[keyper] + : minStake + ); + } + /*////////////////////////////////////////////////////////////// TRANSFER LOGIC //////////////////////////////////////////////////////////////*/ @@ -450,6 +462,6 @@ contract StakingV2 is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice Get the amount of SHU staked for all keypers function totalAssets() public view virtual returns (uint256) { - return stakingToken.balanceOf(address(this)); + return STAKING_TOKEN.balanceOf(address(this)); } } diff --git a/test/unit/StakingUnitTestV2.t.sol b/test/unit/StakingUnitTestV2.t.sol index f45795e..3df4251 100644 --- a/test/unit/StakingUnitTestV2.t.sol +++ b/test/unit/StakingUnitTestV2.t.sol @@ -13,28 +13,31 @@ import {MockGovToken} from "../mocks/MockGovToken.sol"; contract StakingUnitTest is Test { Staking public staking; + IRewardsDistributor public rewardsDistributor; MockGovToken public govToken; uint256 constant LOCK_PERIOD = 60 * 24 * 30 * 6; // 6 months uint256 constant MIN_STAKE = 50_000 * 1e18; // 50k - address keyper1 = address(0x1234); - address keyper2 = address(0x5678); - function setUp() public { // Set the block timestamp to an arbitrary value to avoid introducing assumptions into tests // based on a starting timestamp of 0, which is the default. _jumpAhead(1234); govToken = new MockGovToken(); - vm.label(govToken, "govToken"); + vm.label(address(govToken), "govToken"); // deploy rewards distributor - address rewardsDistributionProxy = address( - new TransparentUpgradeableProxy( - address(new RewardsDistributor()), - address(this), - abi.encodeWithSignature("initialize(address)", address(this)) + rewardsDistributor = IRewardsDistributor( + address( + new TransparentUpgradeableProxy( + address(new RewardsDistributor()), + address(this), + abi.encodeWithSignature( + "initialize(address)", + address(this) + ) + ) ) ); @@ -42,28 +45,30 @@ contract StakingUnitTest is Test { address stakingImpl = address(new Staking()); staking = Staking( - new TransparentUpgradeableProxy(stakingImpl, address(this), "") + address( + new TransparentUpgradeableProxy(stakingImpl, address(this), "") + ) ); - vm.label(staking, "staking"); + vm.label(address(staking), "staking"); staking.initialize( address(this), // owner address(govToken), - address(rewardsDistributionProxy), - lockPeriod, - minStake + address(rewardsDistributor), + LOCK_PERIOD, + MIN_STAKE ); - staking = Staking(stakingProxy); + staking = Staking(staking); - IRewardsDistributor(rewardsDistributionProxy).setRewardConfiguration( - stakingProxy, - address(shu), + rewardsDistributor.setRewardConfiguration( + address(staking), + address(govToken), 1e18 ); // fund reward distribution - govToken.transfer(rewardsDistributionProxy, 1_000_000 * 1e18); + govToken.transfer(address(rewardsDistributor), 1_000_000 * 1e18); } function _jumpAhead(uint256 _seconds) public { @@ -74,7 +79,7 @@ contract StakingUnitTest is Test { return uint96(bound(_amount, 0, 100_000_000e18)); } - function _mintGovToken(address _to, uint96 _amount) internal { + function _mintGovToken(address _to, uint256 _amount) internal { vm.assume(_to != address(0)); govToken.mint(_to, _amount); } @@ -95,7 +100,7 @@ contract StakingUnitTest is Test { vm.startPrank(_keyper); govToken.approve(address(staking), _amount); - _depositId = uniStaker.stake(_amount, _delegatee, _beneficiary); + _depositId = staking.stake(_amount); vm.stopPrank(); } @@ -105,20 +110,20 @@ contract StakingUnitTest is Test { } contract Initializer is StakingUnitTest { - function test_Initialize() public { + function test_Initialize() public view { assertEq(staking.owner(), address(this), "Wrong owner"); assertEq( - staking.stakingToken(), + address(staking.STAKING_TOKEN()), address(govToken), "Wrong staking token" ); assertEq( - staking.rewardsDistributor(), - address(rewardsDistributionProxy), + address(staking.rewardsDistributor()), + address(rewardsDistributor), "Wrong rewards distributor" ); - assertEq(staking.lockPeriod(), lockPeriod, "Wrong lock period"); - assertEq(staking.minStake(), minStake, "Wrong min stake"); + assertEq(staking.lockPeriod(), LOCK_PERIOD, "Wrong lock period"); + assertEq(staking.minStake(), MIN_STAKE, "Wrong min stake"); } } @@ -142,20 +147,21 @@ contract Stake is StakingUnitTest { ); } - function testFuz_EmitsAStakeEventWhenStaking( + function testFuzz_EmitsAStakeEventWhenStaking( address _depositor, uint256 _amount - ) { + ) public { _amount = _boundToRealisticStake(_amount); _mintGovToken(_depositor, _amount); + _setKeyper(_depositor, true); vm.assume(_depositor != address(0)); vm.startPrank(_depositor); govToken.approve(address(staking), _amount); vm.expectEmit(); - emit IStaking.Staked(keyper1, _amount, LOCK_PERIOD); + emit Staking.Staked(_depositor, _amount, LOCK_PERIOD); staking.stake(_amount); vm.stopPrank(); @@ -164,10 +170,11 @@ contract Stake is StakingUnitTest { function testFuzz_UpdatesTotalSupplyWhenStaking( address _depositor, uint256 _amount - ) { + ) public { _amount = _boundToRealisticStake(_amount); _mintGovToken(_depositor, _amount); + _setKeyper(_depositor, true); vm.assume(_depositor != address(0)); @@ -181,13 +188,16 @@ contract Stake is StakingUnitTest { address _depositor2, uint256 _amount1, uint256 _amount2 - ) { + ) public { _amount1 = _boundToRealisticStake(_amount1); _amount2 = _boundToRealisticStake(_amount2); _mintGovToken(_depositor1, _amount1); _mintGovToken(_depositor2, _amount2); + _setKeyper(_depositor1, true); + _setKeyper(_depositor2, true); + vm.assume(_depositor1 != address(0)); vm.assume(_depositor2 != address(0)); @@ -200,4 +210,49 @@ contract Stake is StakingUnitTest { "Wrong total supply" ); } + + function testFuzz_UpdateSharesWhenStaking( + address _depositor, + uint256 _amount + ) public { + _amount = _boundToRealisticStake(_amount); + + _mintGovToken(_depositor, _amount); + _setKeyper(_depositor, true); + + vm.assume(_depositor != address(0)); + + uint256 _shares = staking.convertToShares(_amount); + + _stake(_depositor, _amount); + + assertEq(staking.balanceOf(_depositor), _shares, "Wrong balance"); + } + + function testFuzz_UpdateSharesWhenStakingTwice( + address _depositor, + uint256 _amount1, + uint256 _amount2 + ) public { + _amount1 = _boundToRealisticStake(_amount1); + _amount2 = _boundToRealisticStake(_amount2); + + _mintGovToken(_depositor, _amount1 + _amount2); + _setKeyper(_depositor, true); + + vm.assume(_depositor != address(0)); + + uint256 _shares1 = staking.convertToShares(_amount1); + + _stake(_depositor, _amount1); + + uint256 _shares2 = staking.convertToShares(_amount2); + _stake(_depositor, _amount2); + + assertEq( + staking.balanceOf(_depositor), + _shares1 + _shares2, + "Wrong balance" + ); + } }