diff --git a/README.md b/README.md index f683466..70d233a 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The architecture consists of two contracts: through the Staking contract, keypers trust that the DAO will not set the minimum stake amount to an unreasonable value. -## Protocol Invariants [TBD] +## Protocol Invariants 1. On unstake, `keyperStake.timestamp + lockPeriod <= block.timestamp` if global `lockPeriod` is greater or equal to the stake lock period, otherwise `keyperStake.timestamp + keyperStake.lockPeriod <= block.timestamp`. 2. If `some(keyperStakes(keyper).length()) > 0` then `nextStakeId` != 0; diff --git a/docs/delegate-architecture.md b/docs/delegate-architecture.md index 0239b20..4640483 100644 --- a/docs/delegate-architecture.md +++ b/docs/delegate-architecture.md @@ -6,11 +6,10 @@ inherits from OpenZeppelin's ERC20VotesUpgradeable and OwnableUpgradeable. - The contract overrides the `transfer` and - `transferFrom` functions to prevent the sSHU token from being transferred. All + `transferFrom` functions to prevent the dSHU token from being transferred. All the other inherited functions follow the OpenZeppelin implementation. - To avoid rounding errors, the contract uses the FixedPointMathLib from Solmate library. -- The contract uses SafeTransferLib from solmate to interact with the SHU token. - The choosen mechanism for the rewards distribution is a ERC4626 vault implementation. ## Variables @@ -108,7 +107,9 @@ Set the new staking contract address. Get a list of stake ids belonging to a user. -### `maxWithdraw(address user)` +## Security Considerations -Calculates the maximum amount of assets that a keyper can withdraw, which -represents the rewards accumulated and not claimed yet. This funciton will revert if the user has no shares. +- The contract doesn't use the Ownable2Step pattern due to the 24KB contract + size limit. +- The contract doesn't use safe transfer as the only token that can be + transferred is the SHU token, which is a trusted token. diff --git a/docs/rewards-distributor.md b/docs/rewards-distributor.md index 31bac2d..a7200bd 100644 --- a/docs/rewards-distributor.md +++ b/docs/rewards-distributor.md @@ -29,19 +29,41 @@ struct RewardConfiguration { ### `setRewardConfiguration(address receiver, uint256 emissionRate)` -Add, update or stop distributing rewards to a receiver. The emission rate is +Add, or update the distributing rewards to a receiver. The emission rate is the number of reward tokens distributed per second. This function can only be called by the Owner (DAO). If the emission rate for the specified receiver is not 0, the function will update the `emissionRate`. If the owner wants to stop -distributing rewards, they should set the emission rate to 0. +distributing rewards, they should call `removeRewardConfiguration`. + +### `removeRewardConfiguration(address receiver)` + +Remove the reward configuration for a specific receiver. This function can only +be called by the Owner. + +### `setRewardToken(address rewardToken)` + +This function can only be called by the Owner. +This function will first withdraw all the rewards from the previous reward +token and send them to the owner. Then it will set the new reward token +address. + +### `withdrawFunds(address token, address to, uint256 amount)` + +This function is useful in case someone transfer tokens to the contract by +mistake. The owner can withdraw any ERC20 token from the contract. ## Permissionless Functions -### `distributionRewards()` +### `collectRewards()` + +Distribute all the rewards to the receiver contract (msg.sender) accumulated until from the +`lastUpdate` timestamp to the current timestamp. + +### `collectRewardsTo(address receiver)` -Distribute all the rewards to the receiver contract accumulated until from the -`lastUpdate` timestamp to the current timestamp. If the msg.sender is not one of -the receivers, the function will revert. +Distribute all the rewards to the specified receiver contract accumulated until +from the `lastUpdate` timestamp to the current timestamp. If the receiver is +not a valid receiver, the function will revert. ## View Functions diff --git a/docs/staking-architecture.md b/docs/staking-architecture.md index a6e5f33..6066eef 100644 --- a/docs/staking-architecture.md +++ b/docs/staking-architecture.md @@ -10,7 +10,6 @@ the other inherited functions follow the OpenZeppelin implementation. - To avoid rounding errors, the contract uses the FixedPointMathLib from Solmate library. -- The contract uses SafeTransferLib from solmate to interact with the SHU token. - The choosen mechanism for the rewards distribution is a ERC4626 vault implementation. ## Variables @@ -147,18 +146,11 @@ Set the new minimum amount of SHU tokens that must be staked by keypers. Get a list of stake ids belonging to a keyper. -### `maxWithdraw(address keyper)` - -Calculates the maximum amount of assets that a keyper can withdraw, which -represents the rewards accumulated and not claimed yet. This doesn't include -unlocked stakes. - -- if the keyper has no shares, the function will revert. -- if the keyper sSHU balance is less or equal than the minimum stake or the total locked amount, the function will return 0. - ## Security Considerations - The contract doesn't use the Ownable2Step pattern due to the 24KB contract size limit. - If the Owner address gets compromised, the attacker can increase the minimum stake to a very high value, preventing keypers from unstaking their SHU tokens. +- The contract doesn't use safe transfer as the only token that can be + transferred is the SHU token, which is a trusted token. diff --git a/foundry.toml b/foundry.toml index 83fc66d..a344c89 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,6 +5,7 @@ via_ir = true solc_version = "0.8.26" verbosity = 3 + gas_reports = ["Staking", "DelegateStaking", "RewardsDistributor"] [profile.ci] fuzz = { runs = 5000 } diff --git a/script/Constants.sol b/script/Constants.sol index 6765024..e718637 100644 --- a/script/Constants.sol +++ b/script/Constants.sol @@ -5,3 +5,4 @@ address constant CONTRACT_OWNER = 0x36bD3044ab68f600f6d3e081056F34f2a58432c4; // uint256 constant MIN_STAKE = 50_000e18; uint256 constant REWARD_RATE = 0.1333333333e18; uint256 constant LOCK_PERIOD = 182 days; +uint256 constant INITIAL_MINT = 10_000e18; diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 6117d2c..cac1e0d 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -3,14 +3,20 @@ pragma solidity 0.8.26; import "@forge-std/Script.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {RewardsDistributor} from "src/RewardsDistributor.sol"; +import {DelegateStaking} from "src/DelegateStaking.sol"; import {Staking} from "src/Staking.sol"; import "./Constants.sol"; contract Deploy is Script { function run() public - returns (Staking stakingProxy, RewardsDistributor rewardsDistributor) + returns ( + Staking stakingProxy, + RewardsDistributor rewardsDistributor, + DelegateStaking delegateProxy + ) { vm.startBroadcast(); @@ -29,6 +35,11 @@ contract Deploy is Script { ) ); + IERC20Metadata(STAKING_TOKEN).approve( + address(stakingProxy), + INITIAL_MINT + ); + stakingProxy.initialize( CONTRACT_OWNER, STAKING_TOKEN, @@ -37,6 +48,30 @@ contract Deploy is Script { MIN_STAKE ); + DelegateStaking delegate = new DelegateStaking(); + delegateProxy = DelegateStaking( + address( + new TransparentUpgradeableProxy( + address(delegate), + address(CONTRACT_OWNER), + "" + ) + ) + ); + + IERC20Metadata(STAKING_TOKEN).approve( + address(delegateProxy), + INITIAL_MINT + ); + + delegateProxy.initialize( + CONTRACT_OWNER, + STAKING_TOKEN, + address(rewardsDistributor), + address(stakingProxy), + LOCK_PERIOD + ); + vm.stopBroadcast(); } } diff --git a/script/testnet/Constants.sol b/script/testnet/Constants.sol index f1f5c06..d1b9224 100644 --- a/script/testnet/Constants.sol +++ b/script/testnet/Constants.sol @@ -5,9 +5,11 @@ uint256 constant MIN_STAKE = 50_000e18; uint256 constant REWARD_RATE = 0.1333333333e18; uint256 constant LOCK_PERIOD = 182 days; -address constant STAKING_CONTRACT_IMPL = 0x966aea71f391D044017143ab1D7e5DEd9a950e7e; -address constant STAKING_CONTRACT_PROXY = 0xe53a0850fDd90af0be3d4fDE02bD36C5EdFfc437; -address constant MOCKED_SHU = 0xF2215e7eDfc4782D85BAfA06114f22A0654cA8aC; -address constant REWARDS_DISTRIBUTOR = 0x8aA01CcdEec887f0a6AF127b094702F283d244DE; -address constant DELEGATE_CONTRACT_IMPL = 0x82957f2a4270BCb3A544133c5A41F76ac4862CC3; -address constant DELEGATE_CONTRACT_PROXY = 0x46707609373E016D6F72fAA4c13cbFC9BF3AFF7c; +address constant STAKING_CONTRACT_IMPL = 0xFaD109819176Ded391B663ceB621D24EF5E921d6; +address constant STAKING_CONTRACT_PROXY = 0x04c34f9c83A108153153a63CF2012761350B6667; +address constant STAKING_TOKEN = 0xF2215e7eDfc4782D85BAfA06114f22A0654cA8aC; +address constant REWARDS_DISTRIBUTOR = 0x2061c38E4F168294CcD989ecf427F44a77d9cC34; +address constant DELEGATE_CONTRACT_IMPL = 0x266ea1Ea3d1482cCd17dFb17E102dD8Ff2B26882; +address constant DELEGATE_CONTRACT_PROXY = 0x7F51584f23B61e4d3E4D1C8A4D5f8C39Acb53251; + +uint256 constant INITIAL_MINT = 10_000e18; diff --git a/script/testnet/DeployDelegateTestnet.s.sol b/script/testnet/DeployDelegateTestnet.s.sol deleted file mode 100644 index c17c9bf..0000000 --- a/script/testnet/DeployDelegateTestnet.s.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.26; - -import "@forge-std/Script.sol"; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {DelegateStaking} from "src/DelegateStaking.sol"; -import "./Constants.sol"; - -// forge script script/testnet/DeployDelegateTestnet.s.sol --rpc-url testnet -vvvvv --slow --always-use-create-2-factory --account test --etherscan-api-key testnet --verify --chain 11155111 -contract DeployTestnet is Script { - function run() public returns (DelegateStaking delegateProxy) { - vm.startBroadcast(); - - DelegateStaking delegate = new DelegateStaking(); - delegateProxy = DelegateStaking( - address( - new TransparentUpgradeableProxy( - address(delegate), - address(CONTRACT_OWNER), - "" - ) - ) - ); - - delegateProxy.initialize( - CONTRACT_OWNER, - MOCKED_SHU, - REWARDS_DISTRIBUTOR, - STAKING_CONTRACT_PROXY, - LOCK_PERIOD - ); - - vm.stopBroadcast(); - } -} diff --git a/script/testnet/DeployTestnet.s.sol b/script/testnet/DeployTestnet.s.sol index ece1158..7b45de2 100644 --- a/script/testnet/DeployTestnet.s.sol +++ b/script/testnet/DeployTestnet.s.sol @@ -2,25 +2,30 @@ pragma solidity 0.8.26; import "@forge-std/Script.sol"; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RewardsDistributor} from "src/RewardsDistributor.sol"; import {Staking} from "src/Staking.sol"; +import {DelegateStaking} from "src/DelegateStaking.sol"; import {MockGovToken} from "test/mocks/MockGovToken.sol"; import "./Constants.sol"; -// forge script script/testnet/DeployTestnet.s.sol --rpc-url testnet -vvvvv --slow --always-use-create-2-factory --account test --etherscan-api-key testnet --verify --chain 11155111 +// forge script script/testnet/DeployTestnet.s.sol --rpc-url testnet -vvvvv --slow --always-use-create-2-factory --account test --etherscan-api-key testnet --verify --chain 11155111 --code-size-limit 40000 --broadcast contract DeployTestnet is Script { function run() public - returns (Staking stakingProxy, RewardsDistributor rewardsDistributor) + returns ( + Staking stakingProxy, + RewardsDistributor rewardsDistributor, + DelegateStaking delegateProxy + ) { vm.startBroadcast(); - MockGovToken govToken = new MockGovToken(); - rewardsDistributor = new RewardsDistributor( CONTRACT_OWNER, - address(govToken) + address(STAKING_TOKEN) ); Staking stake = new Staking(); @@ -34,14 +39,43 @@ contract DeployTestnet is Script { ) ); + IERC20Metadata(STAKING_TOKEN).approve( + address(stakingProxy), + INITIAL_MINT + ); + stakingProxy.initialize( CONTRACT_OWNER, - address(govToken), + address(STAKING_TOKEN), address(rewardsDistributor), LOCK_PERIOD, MIN_STAKE ); + DelegateStaking delegate = new DelegateStaking(); + delegateProxy = DelegateStaking( + address( + new TransparentUpgradeableProxy( + address(delegate), + address(CONTRACT_OWNER), + "" + ) + ) + ); + + IERC20Metadata(STAKING_TOKEN).approve( + address(delegateProxy), + INITIAL_MINT + ); + + delegateProxy.initialize( + CONTRACT_OWNER, + STAKING_TOKEN, + address(rewardsDistributor), + address(stakingProxy), + LOCK_PERIOD + ); + vm.stopBroadcast(); } } diff --git a/src/BaseStaking.sol b/src/BaseStaking.sol index a83cc01..2d0748a 100644 --- a/src/BaseStaking.sol +++ b/src/BaseStaking.sol @@ -2,22 +2,18 @@ pragma solidity 0.8.26; import {OwnableUpgradeable} from "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; -import {ERC20VotesUpgradeable} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; -import {EnumerableSetLib} from "@solady/utils/EnumerableSetLib.sol"; +import {ERC20VotesUpgradeable as ERC20Votes} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; -import {IERC20} from "./interfaces/IERC20.sol"; -import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; +import {EnumerableSetLib} from "./libraries/EnumerableSetLib.sol"; import {FixedPointMathLib} from "./libraries/FixedPointMathLib.sol"; import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol"; -abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { +abstract contract BaseStaking is OwnableUpgradeable, ERC20Votes { /*////////////////////////////////////////////////////////////// - LIBRARIES + LIBRARIES //////////////////////////////////////////////////////////////*/ using EnumerableSetLib for EnumerableSetLib.Uint256Set; - using SafeTransferLib for IERC20; - using FixedPointMathLib for uint256; /*////////////////////////////////////////////////////////////// @@ -26,7 +22,7 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { /// @notice the staking token, i.e. SHU /// @dev set in initialize, can't be changed - IERC20 public stakingToken; + ERC20Votes public stakingToken; /// @notice the rewards distributor contract /// @dev only owner can change @@ -40,15 +36,14 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { uint256 internal nextStakeId; /*////////////////////////////////////////////////////////////// - MAPPINGS + MAPPINGS //////////////////////////////////////////////////////////////*/ /// @notice how many SHU a user has locked - mapping(address user => uint256 totalLocked) public totalLocked; + mapping(address => uint256) public totalLocked; // @notice stake ids belonging to a user - mapping(address user => EnumerableSetLib.Uint256Set stakeIds) - internal userStakes; + mapping(address => EnumerableSetLib.Uint256Set) internal userStakes; /*////////////////////////////////////////////////////////////// EVENTS @@ -57,9 +52,6 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { /// @notice Emitted when a keyper claims rewards event RewardsClaimed(address indexed user, uint256 rewards); - /// @notice Emitted when the lock period is changed - event NewLockPeriod(uint256 indexed lockPeriod); - /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -78,8 +70,8 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { /// @notice Thrown when the argument is the zero address error AddressZero(); - /// @notice Thrown when the amount of shares is 0 - error SharesMustBeGreaterThanZero(); + /// @notice Thrown when a user has no shares + error UserHasNoShares(); /*////////////////////////////////////////////////////////////// MODIFIERS @@ -104,31 +96,48 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { /// maximum withdrawable amount. The maximum withdrawable amount /// is the total amount of assets the user has minus the /// total locked amount - /// - If the claim results in a balance less than the total locked - /// amount, the claim will be rejected /// - The keyper can claim the rewards at any time as longs there is /// a reward to claim /// @param amount The amount of rewards to claim function claimRewards( uint256 amount ) external updateRewards returns (uint256 rewards) { - address user = msg.sender; + uint256 assets = convertToAssets(balanceOf(msg.sender)); + uint256 locked = totalLocked[msg.sender]; - // Prevents the keyper from claiming more than they should - uint256 maxWithdrawAmount = maxWithdraw(user); + uint256 maxWithdrawAmount; + unchecked { + // need the first branch as convertToAssets rounds down + maxWithdrawAmount = locked >= assets ? 0 : assets - locked; + } + // Prevents the keyper from claiming more than they should rewards = _calculateWithdrawAmount(amount, maxWithdrawAmount); - require(rewards > 0, NoRewardsToClaim()); // Calculates the amount of shares to burn - uint256 shares = _previewWithdraw(rewards); + _burn(msg.sender, _previewWithdraw(rewards)); + stakingToken.transfer(msg.sender, rewards); - _burn(user, shares); + emit RewardsClaimed(msg.sender, rewards); + } - stakingToken.safeTransfer(user, rewards); + /*////////////////////////////////////////////////////////////// + TRANSFER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @notice Transfer is disabled + function transfer(address, uint256) public pure override returns (bool) { + revert TransferDisabled(); + } - emit RewardsClaimed(user, rewards); + /// @notice Transfer is disabled + function transferFrom( + address, + address, + uint256 + ) public pure override returns (bool) { + revert TransferDisabled(); } /*////////////////////////////////////////////////////////////// @@ -149,26 +158,7 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { /// @param _lockPeriod The lock period in seconds function setLockPeriod(uint256 _lockPeriod) external onlyOwner { lockPeriod = _lockPeriod; - - emit NewLockPeriod(_lockPeriod); - } - - /*////////////////////////////////////////////////////////////// - TRANSFER LOGIC - //////////////////////////////////////////////////////////////*/ - - /// @notice Transfer is disabled - function transfer(address, uint256) public pure override returns (bool) { - revert TransferDisabled(); - } - - /// @notice Transfer is disabled - function transferFrom( - address, - address, - uint256 - ) public pure override returns (bool) { - revert TransferDisabled(); + // no events for this function due to 24kb contract size limit } /*////////////////////////////////////////////////////////////// @@ -180,9 +170,8 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { function convertToShares( uint256 assets ) public view virtual returns (uint256) { - // sum + 1 on both sides to prevent donation attack - // this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0 - return assets.mulDivDown(totalSupply() + 1, _totalAssets() + 1); + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + return supply == 0 ? assets : assets.mulDivDown(supply, _totalAssets()); } /// @notice Get the total amount of assets the shares are worth @@ -190,9 +179,8 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { function convertToAssets( uint256 shares ) public view virtual returns (uint256) { - // sum + 1 on both sides to prevent donation attack - // this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0 - return shares.mulDivDown(_totalAssets() + 1, totalSupply() + 1); + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + return supply == 0 ? shares : shares.mulDivDown(_totalAssets(), supply); } /// @notice Get the stake ids belonging to a user @@ -202,38 +190,23 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { return userStakes[user].values(); } - /// @notice Get the total amount of assets that a keyper can withdraw - /// @dev must be implemented by the child contract - function maxWithdraw(address user) public view virtual returns (uint256); - /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ /// @notice Deposit SHU into the contract - /// @param user The user address /// @param amount The amount of SHU to deposit - function _deposit(address user, uint256 amount) internal { - // Calculate the amount of shares to mint - uint256 shares = convertToShares(amount); - - // A first deposit donation attack may result in shares being 0 if the - // contract has very high assets balance but a very low total supply. - // Although this attack is not profitable for the attacker, as they will - // spend more tokens than they will receive, it can still be used to perform a DDOS attack - // against a specific user. The targeted user can still withdraw their SHU, - // but this is only guaranteed if someone mints to increase the total supply of shares, - // because previewWithdraw rounds up and their shares will be less than the burn amount. - require(shares > 0, SharesMustBeGreaterThanZero()); - + function _deposit(uint256 amount) internal { // Update the total locked amount - totalLocked[user] += amount; + unchecked { + totalLocked[msg.sender] += amount; + } // Mint the shares - _mint(user, shares); + _mint(msg.sender, convertToShares(amount)); // Lock the SHU in the contract - stakingToken.safeTransferFrom(msg.sender, address(this), amount); + stakingToken.transferFrom(msg.sender, address(this), amount); } /// @notice Withdraw SHU from the contract @@ -245,27 +218,23 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { ) internal returns (uint256 shares) { shares = _previewWithdraw(amount); - // Decrease the amount from the total locked - totalLocked[user] -= amount; + unchecked { + // Decrease the amount from the total locked + totalLocked[user] -= amount; + } // Burn the shares _burn(user, shares); // Transfer the SHU to the keyper - stakingToken.safeTransfer(user, amount); - } - - /// @notice Get the amount of SHU staked for all keypers - function _totalAssets() internal view returns (uint256) { - return stakingToken.balanceOf(address(this)); + stakingToken.transfer(user, amount); } /// @notice Get the amount of shares that will be burned /// @param assets The amount of assets function _previewWithdraw(uint256 assets) internal view returns (uint256) { - // sum + 1 on both sides to prevent donation attack - // this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0 - return assets.mulDivUp(totalSupply() + 1, _totalAssets() + 1); + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + return supply == 0 ? assets : assets.mulDivUp(supply, _totalAssets()); } /// @notice Calculates the amount to withdraw @@ -283,4 +252,21 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable { amount = _amount; } } + + /// @notice Get the amount of SHU staked for all keypers + function _totalAssets() internal view returns (uint256) { + return stakingToken.balanceOf(address(this)); + } + + /// @notice Initialize the contract minting dead shares to avoid inflation attack + function __BaseStaking_init() internal { + // mint dead shares to avoid inflation attack + uint256 amount = 10_000e18; + + // Mint the shares to the vault + _mint(address(this), convertToShares(amount)); + + // Transfer the SHU to the vault + stakingToken.transferFrom(msg.sender, address(this), amount); + } } diff --git a/src/DelegateStaking.sol b/src/DelegateStaking.sol index b9e9767..fb9fbb9 100644 --- a/src/DelegateStaking.sol +++ b/src/DelegateStaking.sol @@ -1,14 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import {EnumerableSetLib} from "@solady/utils/EnumerableSetLib.sol"; -import {OwnableUpgradeable} from "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; -import {ERC20VotesUpgradeable} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import {ERC20VotesUpgradeable as ERC20Votes} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; import {BaseStaking} from "./BaseStaking.sol"; -import {IERC20} from "./interfaces/IERC20.sol"; -import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; -import {FixedPointMathLib} from "./libraries/FixedPointMathLib.sol"; +import {EnumerableSetLib} from "./libraries/EnumerableSetLib.sol"; import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol"; interface IStaking { @@ -38,12 +34,10 @@ interface IStaking { */ contract DelegateStaking is BaseStaking { /*////////////////////////////////////////////////////////////// - LIBRARIES + LIBRARIES //////////////////////////////////////////////////////////////*/ using EnumerableSetLib for EnumerableSetLib.Uint256Set; - using SafeTransferLib for IERC20; - /*////////////////////////////////////////////////////////////// VARIABLES //////////////////////////////////////////////////////////////*/ @@ -66,14 +60,14 @@ contract DelegateStaking is BaseStaking { } /*////////////////////////////////////////////////////////////// - MAPPINGS + MAPPINGS //////////////////////////////////////////////////////////////*/ /// @notice stores the metadata associated with a given stake - mapping(uint256 id => Stake _stake) public stakes; + mapping(uint256 => Stake) public stakes; /// @notice stores the amount delegated to a keyper - mapping(address keyper => uint256 totalDelegated) public totalDelegated; + mapping(address => uint256) public totalDelegated; /*////////////////////////////////////////////////////////////// EVENTS @@ -97,9 +91,6 @@ contract DelegateStaking is BaseStaking { ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice Thrown when a user has no shares - error UserHasNoShares(); - /// @notice Trown when amount is zero error ZeroAmount(); @@ -129,18 +120,18 @@ contract DelegateStaking is BaseStaking { address _rewardsDistributor, address _stakingContract, uint256 _lockPeriod - ) public initializer { + ) external initializer { __ERC20_init("Delegated Staking SHU", "dSHU"); - // Transfer ownership to the DAO contract - _transferOwnership(_owner); - - stakingToken = IERC20(_stakingToken); - rewardsDistributor = IRewardsDistributor(_rewardsDistributor); staking = IStaking(_stakingContract); + stakingToken = ERC20Votes(_stakingToken); + rewardsDistributor = IRewardsDistributor(_rewardsDistributor); lockPeriod = _lockPeriod; nextStakeId = 1; + _transferOwnership(_owner); + + __BaseStaking_init(); } /// @notice Stake SHU @@ -158,12 +149,10 @@ contract DelegateStaking is BaseStaking { require(staking.keypers(keyper), AddressIsNotAKeyper()); - address user = msg.sender; - stakeId = nextStakeId++; // Add the stake id to the user stakes - userStakes[user].add(stakeId); + userStakes[msg.sender].add(stakeId); // Add the stake to the stakes mapping stakes[stakeId].keyper = keyper; @@ -172,11 +161,13 @@ contract DelegateStaking is BaseStaking { stakes[stakeId].lockPeriod = lockPeriod; // Increase the keyper total delegated amount - totalDelegated[keyper] += amount; + unchecked { + totalDelegated[keyper] += amount; + } - _deposit(user, amount); + _deposit(amount); - emit Staked(user, keyper, amount, lockPeriod); + emit Staked(msg.sender, keyper, amount, lockPeriod); } /// @notice Unstake SHU @@ -198,8 +189,10 @@ contract DelegateStaking is BaseStaking { uint256 stakeId, uint256 _amount ) external updateRewards returns (uint256 amount) { - address user = msg.sender; - require(userStakes[user].contains(stakeId), StakeDoesNotBelongToUser()); + require( + userStakes[msg.sender].contains(stakeId), + StakeDoesNotBelongToUser() + ); Stake memory userStake = stakes[stakeId]; require(userStake.amount > 0, StakeDoesNotExist()); @@ -214,16 +207,18 @@ contract DelegateStaking is BaseStaking { ? lockPeriod : userStake.lockPeriod; - require( - block.timestamp > userStake.timestamp + lock, - StakeIsStillLocked() - ); + unchecked { + require( + block.timestamp > userStake.timestamp + lock, + StakeIsStillLocked() + ); - // Decrease the amount from the stake - stakes[stakeId].amount -= amount; + // Decrease the amount from the stake + stakes[stakeId].amount -= amount; - // Decrease the total delegated amount - totalDelegated[userStake.keyper] -= amount; + // Decrease the total delegated amount + totalDelegated[userStake.keyper] -= amount; + } // If the stake is empty, remove it if (stakes[stakeId].amount == 0) { @@ -231,12 +226,12 @@ contract DelegateStaking is BaseStaking { delete stakes[stakeId]; // Remove the stake from the user stakes - userStakes[user].remove(stakeId); + userStakes[msg.sender].remove(stakeId); } - uint256 shares = _withdraw(user, amount); + uint256 shares = _withdraw(msg.sender, amount); - emit Unstaked(user, amount, shares); + emit Unstaked(msg.sender, amount, shares); } /*////////////////////////////////////////////////////////////// @@ -255,27 +250,4 @@ contract DelegateStaking is BaseStaking { emit NewStakingContract(_stakingContract); } - - /*////////////////////////////////////////////////////////////// - OVERRIDE - //////////////////////////////////////////////////////////////*/ - - /// @notice Get the maximum amount of assets that a keyper can withdraw - //// - if the user has no shares, the function will revert - /// - if the user dSHU balance is less or equal than the total - /// locked amount, the function will return 0 - /// @param user The user address - /// @return amount The maximum amount of assets that a user can withdraw - function maxWithdraw( - address user - ) public view override returns (uint256 amount) { - uint256 shares = balanceOf(user); - require(shares > 0, UserHasNoShares()); - - uint256 assets = convertToAssets(shares); - uint256 locked = totalLocked[user]; - - // need the first branch as convertToAssets rounds down - amount = locked >= assets ? 0 : assets - locked; - } } diff --git a/src/RewardsDistributor.sol b/src/RewardsDistributor.sol index d66db60..3f8785a 100644 --- a/src/RewardsDistributor.sol +++ b/src/RewardsDistributor.sol @@ -3,15 +3,14 @@ pragma solidity 0.8.26; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol"; -import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import {IERC20} from "./interfaces/IERC20.sol"; +/** + * @title Shutter Rewards Distributor Contract + * + * This contract lets the owner distribute rewards to the Staking and DelegateStaking contracts. + */ contract RewardsDistributor is Ownable, IRewardsDistributor { - /*////////////////////////////////////////////////////////////// - LIBRARIES - //////////////////////////////////////////////////////////////*/ - using SafeTransferLib for IERC20; - /*////////////////////////////////////////////////////////////// VARIABLES //////////////////////////////////////////////////////////////*/ @@ -30,7 +29,7 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { } /*////////////////////////////////////////////////////////////// - MAPPINGS + MAPPINGS //////////////////////////////////////////////////////////////*/ mapping(address receiver => RewardConfiguration configuration) @@ -76,22 +75,18 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { /// @notice Distribute rewards to receiver /// Caller must be the receiver function collectRewards() external override returns (uint256 rewards) { - address receiver = msg.sender; - RewardConfiguration storage rewardConfiguration = rewardConfigurations[ - receiver + msg.sender ]; // difference in time since last update uint256 timeDelta = block.timestamp - rewardConfiguration.lastUpdate; - uint256 funds = rewardToken.balanceOf(address(this)); - rewards = rewardConfiguration.emissionRate * timeDelta; // the contract must have enough funds to distribute // we don't want to revert in case its zero to not block the staking contract - if (rewards == 0 || funds < rewards) { + if (rewards == 0 || rewardToken.balanceOf(address(this)) < rewards) { return 0; } @@ -99,16 +94,16 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { rewardConfiguration.lastUpdate = block.timestamp; // transfer the reward - rewardToken.safeTransfer(receiver, rewards); + rewardToken.transfer(msg.sender, rewards); - emit RewardCollected(receiver, rewards); + emit RewardCollected(msg.sender, rewards); } /// @notice Send rewards to receiver /// @param receiver The receiver of the rewards function collectRewardsTo( address receiver - ) external override returns (uint256 rewards) { + ) public override returns (uint256 rewards) { RewardConfiguration storage rewardConfiguration = rewardConfigurations[ receiver ]; @@ -120,18 +115,20 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { require(timeDelta > 0, TimeDeltaZero()); - uint256 funds = rewardToken.balanceOf(address(this)); - rewards = rewardConfiguration.emissionRate * timeDelta; // the contract must have enough funds to distribute - require(funds >= rewards, NotEnoughFunds()); + // and the rewards must be greater than zero + require( + rewards > 0 && rewardToken.balanceOf(address(this)) >= rewards, + NotEnoughFunds() + ); // update the last update timestamp rewardConfiguration.lastUpdate = block.timestamp; // transfer the reward - rewardToken.safeTransfer(receiver, rewards); + rewardToken.transfer(receiver, rewards); emit RewardCollected(receiver, rewards); } @@ -151,7 +148,11 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { // only update last update if it's the first time if (rewardConfigurations[receiver].lastUpdate == 0) { rewardConfigurations[receiver].lastUpdate = block.timestamp; + } else { + // claim the rewards before updating the emission rate + collectRewardsTo(receiver); } + rewardConfigurations[receiver].emissionRate = emissionRate; emit RewardConfigurationSet(receiver, emissionRate); @@ -159,33 +160,45 @@ contract RewardsDistributor is Ownable, IRewardsDistributor { /// @notice Remove a reward configuration /// @param receiver The receiver of the rewards - function removeRewardConfiguration(address receiver) external onlyOwner { - delete rewardConfigurations[receiver]; + function removeRewardConfiguration( + address receiver + ) external override onlyOwner { + rewardConfigurations[receiver].lastUpdate = 0; + rewardConfigurations[receiver].emissionRate = 0; emit RewardConfigurationSet(receiver, 0); } - /// @notice Withdraw funds from the contract - /// @param to The address to withdraw to - /// @param amount The amount to withdraw - function withdrawFunds( - address to, - uint256 amount - ) public override onlyOwner { - rewardToken.safeTransfer(to, amount); - } - /// @notice Set the reward token /// @param _rewardToken The reward token - function setRewardToken(address _rewardToken) external onlyOwner { + function setRewardToken(address _rewardToken) external override onlyOwner { require(_rewardToken != address(0), ZeroAddress()); // withdraw remaining old reward token - withdrawFunds(msg.sender, rewardToken.balanceOf(address(this))); + withdrawFunds( + address(rewardToken), + msg.sender, + rewardToken.balanceOf(address(this)) + ); // set the new reward token rewardToken = IERC20(_rewardToken); emit RewardTokenSet(_rewardToken); } + + /// @notice Withdraw funds from the contract + /// @param to The address to withdraw to + /// @param amount The amount to withdraw + function withdrawFunds( + address token, + address to, + uint256 amount + ) public override onlyOwner { + require(to != address(0), ZeroAddress()); + + // we don't want to use safeTransfer here as not all ERC20 tokens + // are compatible it + IERC20(token).transfer(to, amount); + } } diff --git a/src/Staking.sol b/src/Staking.sol index 3bf30e4..636b1af 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -1,13 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import {ERC20VotesUpgradeable} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; -import {EnumerableSetLib} from "@solady/utils/EnumerableSetLib.sol"; +import {ERC20VotesUpgradeable as ERC20Votes} from "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; import {BaseStaking} from "./BaseStaking.sol"; -import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; -import {FixedPointMathLib} from "./libraries/FixedPointMathLib.sol"; -import {IERC20} from "./interfaces/IERC20.sol"; +import {EnumerableSetLib} from "./libraries/EnumerableSetLib.sol"; import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol"; /** @@ -42,8 +39,6 @@ contract Staking is BaseStaking { //////////////////////////////////////////////////////////////*/ using EnumerableSetLib for EnumerableSetLib.Uint256Set; - using SafeTransferLib for IERC20; - /*////////////////////////////////////////////////////////////// VARIABLES //////////////////////////////////////////////////////////////*/ @@ -69,10 +64,14 @@ contract Staking is BaseStaking { //////////////////////////////////////////////////////////////*/ /// @notice stores the metadata associated with a given stake - mapping(uint256 id => Stake _stake) public stakes; + mapping(uint256 => Stake) public stakes; /// @notice keypers mapping - mapping(address keyper => bool isKeyper) public keypers; + mapping(address => bool) public keypers; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ /// @notice Emitted when a keyper stakes SHU event Staked(address indexed user, uint256 amount, uint256 lockPeriod); @@ -83,14 +82,9 @@ contract Staking is BaseStaking { /// @notice Emitted when a keyper is added or removed event KeyperSet(address indexed keyper, bool isKeyper); - /// @notice Emitted when the minimum stake is changed - event NewMinStake(uint256 indexed minStake); - /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice Thrown when a user has no shares - error UserHasNoShares(); /// @notice Thrown when a non-keyper attempts a call for which only keypers are allowed error OnlyKeyper(); @@ -125,26 +119,26 @@ contract Staking is BaseStaking { address _rewardsDistributor, uint256 _lockPeriod, uint256 _minStake - ) public initializer { + ) external initializer { __ERC20_init("Staked SHU", "sSHU"); - // Transfer ownership to the DAO contract - _transferOwnership(_owner); - - stakingToken = IERC20(_stakingToken); + minStake = _minStake; + stakingToken = ERC20Votes(_stakingToken); rewardsDistributor = IRewardsDistributor(_rewardsDistributor); lockPeriod = _lockPeriod; - minStake = _minStake; nextStakeId = 1; + _transferOwnership(_owner); + + __BaseStaking_init(); } /// @notice Stake SHU - /// - first stake must be at least the minimum stake + /// - first stake must be at least the minimum stake amount /// - amount will be locked in the contract for the lock period /// - keyper must approve the contract to spend the SHU before staking /// - this function will mint sSHU to the keyper - //// - sSHU is non-transferable + /// - sSHU is non-transferable /// - only keypers can stake /// @param amount The amount of SHU to stake /// @return stakeId The index of the stake @@ -155,10 +149,8 @@ contract Staking is BaseStaking { require(amount > 0, ZeroAmount()); - address user = msg.sender; - // Get the keyper stakes - EnumerableSetLib.Uint256Set storage stakesIds = userStakes[user]; + EnumerableSetLib.Uint256Set storage stakesIds = userStakes[msg.sender]; // If the keyper has no stakes, the first stake must be at least the minimum stake if (stakesIds.length() == 0) { @@ -168,21 +160,21 @@ contract Staking is BaseStaking { stakeId = nextStakeId++; // Add the stake id to the user stakes - userStakes[user].add(stakeId); + userStakes[msg.sender].add(stakeId); // Add the stake to the stakes mapping stakes[stakeId].amount = amount; stakes[stakeId].timestamp = block.timestamp; stakes[stakeId].lockPeriod = lockPeriod; - _deposit(user, amount); + _deposit(amount); - emit Staked(user, amount, lockPeriod); + emit Staked(msg.sender, amount, lockPeriod); } /// @notice Unstake SHU /// - stakeId must be a valid id beloging to the keyper - /// - If address keyper is a keyper only the keyper can unstake + /// - If address is a keyper only them can unstake /// - if keyper address is not a keyper, anyone can unstake /// - Unstake can't never result in a keyper SHU staked < minStake /// if the keyper is still a keyper @@ -190,13 +182,11 @@ contract Staking is BaseStaking { /// block.timestamp must be greater than the stake timestamp + /// lock period /// - if the stake lock period is greater than the global lock - /// period, the block.timestamp must be greater than the stake timestamp + - /// lock period + /// period, the block.timestamp must be greater than the stake timestamp + lock period /// - if address is not a keyper, lock period is ignored /// - if amount is zero, the contract will transfer the stakeId /// total amount - /// - if amount is specified, it must be less than the stakeId amount - /// - amount must be specified in SHU, not sSHU + /// - amount must be specified in assets, not shares /// @param keyper The keyper address /// @param stakeId The stake index /// @param _amount The amount @@ -223,34 +213,33 @@ contract Staking is BaseStaking { // Only the keyper can unstake require(msg.sender == keyper, OnlyKeyper()); - // If the lock period is less than the global lock period, the stake - // must be locked for the lock period - // If the global lock period is greater than the stake lock period, - // the stake must be locked for the stake lock period + // If the stake lock period is greater than the global lock period, + // the stake must be locked for the global lock period + // If the stake lock period is less than the global lock period, the stake + // must be locked for the stake lock period uint256 lock = keyperStake.lockPeriod > lockPeriod ? lockPeriod : keyperStake.lockPeriod; - require( - block.timestamp > keyperStake.timestamp + lock, - StakeIsStillLocked() - ); - - // The unstake can't never result in a keyper SHU staked < minStake - require( - _maxWithdraw(keyper, keyperStake.amount) >= amount, - WithdrawAmountTooHigh() - ); - } else { - // doesn't include the min stake and locked staked as the keyper is not a keyper anymore - require( - convertToAssets(balanceOf(keyper)) >= amount, - WithdrawAmountTooHigh() - ); + unchecked { + require( + block.timestamp > keyperStake.timestamp + lock, + StakeIsStillLocked() + ); + } + + // convert to assets rounds down so sometimes keyperStake.amount + // will not be enough and a dust amount must be left in the stake + uint256 maxWithdrawAvailable = convertToAssets(balanceOf(keyper)) - + minStake; + + require(amount <= maxWithdrawAvailable, WithdrawAmountTooHigh()); } // Decrease the amount from the stake - stakes[stakeId].amount -= amount; + unchecked { + stakes[stakeId].amount -= amount; + } // If the stake is empty, remove it if (stakes[stakeId].amount == 0) { @@ -269,11 +258,12 @@ contract Staking is BaseStaking { /*////////////////////////////////////////////////////////////// RESTRICTED FUNCTIONS //////////////////////////////////////////////////////////////*/ + /// @notice Set the minimum stake amount /// @param _minStake The minimum stake amount function setMinStake(uint256 _minStake) external onlyOwner { minStake = _minStake; - emit NewMinStake(_minStake); + // no events for this function due to 24kb contract size limit } /// @notice Set a keyper @@ -285,48 +275,4 @@ contract Staking is BaseStaking { keypers[keyper] = isKeyper; emit KeyperSet(keyper, isKeyper); } - - /*////////////////////////////////////////////////////////////// - OVERRIDE - //////////////////////////////////////////////////////////////*/ - - /// @notice Get the maximum amount of assets that a keyper can withdraw - //// - if the keyper has no shares, the function will revert - /// - if the keyper sSHU balance is less or equal than the minimum stake or the total - /// locked amount, the function will return 0 - /// @param keyper The keyper address - /// @return amount The maximum amount of assets that a keyper can withdraw - function maxWithdraw( - address keyper - ) public view override returns (uint256) { - return _maxWithdraw(keyper, 0); - } - - /*////////////////////////////////////////////////////////////// - INTERNAL FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /// @notice Get the maximum amount of assets that a keyper can withdraw - /// after unlocking a certain amount - /// - if the keyper has no shares, the function will revert - /// - if the keyper sSHU balance is less or equal than the minimum - /// stake or the total locked amount, the function will return 0 - /// @param keyper The keyper address - /// @param unlockedAmount The amount of unlocked assets - /// @return amount The maximum amount of assets that a keyper can withdraw after unlocking a certain amount - function _maxWithdraw( - address keyper, - uint256 unlockedAmount - ) internal view virtual returns (uint256 amount) { - uint256 shares = balanceOf(keyper); - require(shares > 0, UserHasNoShares()); - - uint256 assets = convertToAssets(shares); - - uint256 locked = totalLocked[keyper] - unlockedAmount; - uint256 compare = locked >= minStake ? locked : minStake; - - // need the first branch as convertToAssets rounds down - amount = compare >= assets ? 0 : assets - compare; - } } diff --git a/src/interfaces/IRewardsDistributor.sol b/src/interfaces/IRewardsDistributor.sol index 07217fd..c795bda 100644 --- a/src/interfaces/IRewardsDistributor.sol +++ b/src/interfaces/IRewardsDistributor.sol @@ -6,7 +6,7 @@ interface IRewardsDistributor { function collectRewardsTo(address receiver) external returns (uint256); - function withdrawFunds(address to, uint256 amount) external; + function withdrawFunds(address token, address to, uint256 amount) external; function setRewardConfiguration( address receiver, diff --git a/src/libraries/EnumerableSetLib.sol b/src/libraries/EnumerableSetLib.sol new file mode 100644 index 0000000..69bdb6f --- /dev/null +++ b/src/libraries/EnumerableSetLib.sol @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice Library for managing enumerable sets in storage. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/utils/EnumerableSetLib.sol) +/// +/// @dev Note: +/// In many applications, the number of elements in an enumerable set is small. +/// This enumerable set implementation avoids storing the length and indices +/// for up to 3 elements. Once the length exceeds 3 for the first time, the length +/// and indices will be initialized. The amortized cost of adding elements is O(1). +/// +/// The AddressSet implementation packs the length with the 0th entry. +library EnumerableSetLib { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CUSTOM ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev The index must be less than the length. + error IndexOutOfBounds(); + + /// @dev The value cannot be the zero sentinel. + error ValueIsZeroSentinel(); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev A sentinel value to denote the zero value in storage. + /// No elements can be equal to this value. + /// `uint72(bytes9(keccak256(bytes("_ZERO_SENTINEL"))))`. + uint256 private constant _ZERO_SENTINEL = 0xfbb67fda52d4bfb8bf; + + /// @dev The storage layout is given by: + /// ``` + /// mstore(0x04, _ENUMERABLE_ADDRESS_SET_SLOT_SEED) + /// mstore(0x00, set.slot) + /// let rootSlot := keccak256(0x00, 0x24) + /// mstore(0x20, rootSlot) + /// mstore(0x00, shr(96, shl(96, value))) + + /// let positionSlot := keccak256(0x00, 0x40) + /// let valueSlot := add(rootSlot, sload(positionSlot)) + /// let valueInStorage := shr(96, sload(valueSlot)) + /// let lazyLength := shr(160, shl(160, sload(rootSlot))) + /// ``` + uint256 private constant _ENUMERABLE_ADDRESS_SET_SLOT_SEED = 0x978aab92; + + /// @dev The storage layout is given by: + /// ``` + /// mstore(0x04, _ENUMERABLE_WORD_SET_SLOT_SEED) + /// mstore(0x00, set.slot) + /// let rootSlot := keccak256(0x00, 0x24) + /// mstore(0x20, rootSlot) + /// mstore(0x00, value) + /// let positionSlot := keccak256(0x00, 0x40) + /// let valueSlot := add(rootSlot, sload(positionSlot)) + /// let valueInStorage := sload(valueSlot) + /// let lazyLength := sload(not(rootSlot)) + /// ``` + uint256 private constant _ENUMERABLE_WORD_SET_SLOT_SEED = 0x18fb5864; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* STRUCTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev An enumerable bytes32 set in storage. + struct Bytes32Set { + uint256 _spacer; + } + + /// @dev An enumerable uint256 set in storage. + struct Uint256Set { + uint256 _spacer; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* GETTERS / SETTERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Returns the number of elements in the set. + function length( + Bytes32Set storage set + ) internal view returns (uint256 result) { + bytes32 rootSlot = _rootSlot(set); + /// @solidity memory-safe-assembly + assembly { + let n := sload(not(rootSlot)) + result := shr(1, n) + for { + + } iszero(n) { + + } { + result := 0 + if iszero(sload(add(rootSlot, result))) { + break + } + result := 1 + if iszero(sload(add(rootSlot, result))) { + break + } + result := 2 + if iszero(sload(add(rootSlot, result))) { + break + } + result := 3 + break + } + } + } + + /// @dev Returns the number of elements in the set. + function length( + Uint256Set storage set + ) internal view returns (uint256 result) { + result = length(_toBytes32Set(set)); + } + + /// @dev Adds `value` to the set. Returns whether `value` was not in the set. + function add( + Bytes32Set storage set, + bytes32 value + ) internal returns (bool result) { + bytes32 rootSlot = _rootSlot(set); + /// @solidity memory-safe-assembly + assembly { + if eq(value, _ZERO_SENTINEL) { + mstore(0x00, 0xf5a267f1) // `ValueIsZeroSentinel()`. + revert(0x1c, 0x04) + } + if iszero(value) { + value := _ZERO_SENTINEL + } + for { + let n := sload(not(rootSlot)) + } 1 { + + } { + mstore(0x20, rootSlot) + if iszero(n) { + let v0 := sload(rootSlot) + if iszero(v0) { + sstore(rootSlot, value) + result := 1 + break + } + if eq(v0, value) { + break + } + let v1 := sload(add(rootSlot, 1)) + if iszero(v1) { + sstore(add(rootSlot, 1), value) + result := 1 + break + } + if eq(v1, value) { + break + } + let v2 := sload(add(rootSlot, 2)) + if iszero(v2) { + sstore(add(rootSlot, 2), value) + result := 1 + break + } + if eq(v2, value) { + break + } + mstore(0x00, v0) + sstore(keccak256(0x00, 0x40), 1) + mstore(0x00, v1) + sstore(keccak256(0x00, 0x40), 2) + mstore(0x00, v2) + sstore(keccak256(0x00, 0x40), 3) + n := 7 + } + mstore(0x00, value) + let p := keccak256(0x00, 0x40) + if iszero(sload(p)) { + n := shr(1, n) + sstore(add(rootSlot, n), value) + sstore(p, add(1, n)) + sstore(not(rootSlot), or(1, shl(1, add(1, n)))) + result := 1 + break + } + break + } + } + } + + /// @dev Adds `value` to the set. Returns whether `value` was not in the set. + function add( + Uint256Set storage set, + uint256 value + ) internal returns (bool result) { + result = add(_toBytes32Set(set), bytes32(value)); + } + + /// @dev Returns whether `value` is in the set. + function contains( + Bytes32Set storage set, + bytes32 value + ) internal view returns (bool result) { + bytes32 rootSlot = _rootSlot(set); + /// @solidity memory-safe-assembly + assembly { + if eq(value, _ZERO_SENTINEL) { + mstore(0x00, 0xf5a267f1) // `ValueIsZeroSentinel()`. + revert(0x1c, 0x04) + } + if iszero(value) { + value := _ZERO_SENTINEL + } + for { + + } 1 { + + } { + if iszero(sload(not(rootSlot))) { + result := 1 + if eq(sload(rootSlot), value) { + break + } + if eq(sload(add(rootSlot, 1)), value) { + break + } + if eq(sload(add(rootSlot, 2)), value) { + break + } + result := 0 + break + } + mstore(0x20, rootSlot) + mstore(0x00, value) + result := iszero(iszero(sload(keccak256(0x00, 0x40)))) + break + } + } + } + + /// @dev Returns whether `value` is in the set. + function contains( + Uint256Set storage set, + uint256 value + ) internal view returns (bool result) { + result = contains(_toBytes32Set(set), bytes32(value)); + } + + /// @dev Removes `value` from the set. Returns whether `value` was in the set. + function remove( + Bytes32Set storage set, + bytes32 value + ) internal returns (bool result) { + bytes32 rootSlot = _rootSlot(set); + /// @solidity memory-safe-assembly + assembly { + if eq(value, _ZERO_SENTINEL) { + mstore(0x00, 0xf5a267f1) // `ValueIsZeroSentinel()`. + revert(0x1c, 0x04) + } + if iszero(value) { + value := _ZERO_SENTINEL + } + for { + let n := sload(not(rootSlot)) + } 1 { + + } { + if iszero(n) { + result := 1 + if eq(sload(rootSlot), value) { + sstore(rootSlot, sload(add(rootSlot, 1))) + sstore(add(rootSlot, 1), sload(add(rootSlot, 2))) + sstore(add(rootSlot, 2), 0) + break + } + if eq(sload(add(rootSlot, 1)), value) { + sstore(add(rootSlot, 1), sload(add(rootSlot, 2))) + sstore(add(rootSlot, 2), 0) + break + } + if eq(sload(add(rootSlot, 2)), value) { + sstore(add(rootSlot, 2), 0) + break + } + result := 0 + break + } + mstore(0x20, rootSlot) + mstore(0x00, value) + let p := keccak256(0x00, 0x40) + let position := sload(p) + if iszero(position) { + break + } + n := sub(shr(1, n), 1) + if iszero(eq(sub(position, 1), n)) { + let lastValue := sload(add(rootSlot, n)) + sstore(add(rootSlot, sub(position, 1)), lastValue) + sstore(add(rootSlot, n), 0) + mstore(0x00, lastValue) + sstore(keccak256(0x00, 0x40), position) + } + sstore(not(rootSlot), or(shl(1, n), 1)) + sstore(p, 0) + result := 1 + break + } + } + } + + /// @dev Removes `value` from the set. Returns whether `value` was in the set. + function remove( + Uint256Set storage set, + uint256 value + ) internal returns (bool result) { + result = remove(_toBytes32Set(set), bytes32(value)); + } + + /// @dev Returns all of the values in the set. + /// Note: This can consume more gas than the block gas limit for large sets. + function values( + Bytes32Set storage set + ) internal view returns (bytes32[] memory result) { + bytes32 rootSlot = _rootSlot(set); + /// @solidity memory-safe-assembly + assembly { + let zs := _ZERO_SENTINEL + let n := sload(not(rootSlot)) + result := mload(0x40) + let o := add(0x20, result) + for { + + } 1 { + + } { + if iszero(n) { + let v := sload(rootSlot) + if v { + n := 1 + mstore(o, mul(v, iszero(eq(v, zs)))) + v := sload(add(rootSlot, n)) + if v { + n := 2 + mstore(add(o, 0x20), mul(v, iszero(eq(v, zs)))) + v := sload(add(rootSlot, n)) + if v { + n := 3 + mstore(add(o, 0x40), mul(v, iszero(eq(v, zs)))) + } + } + } + break + } + n := shr(1, n) + for { + let i := 0 + } lt(i, n) { + i := add(i, 1) + } { + let v := sload(add(rootSlot, i)) + mstore(add(o, shl(5, i)), mul(v, iszero(eq(v, zs)))) + } + break + } + mstore(result, n) + mstore(0x40, add(o, shl(5, n))) + } + } + + /// @dev Returns all of the values in the set. + /// Note: This can consume more gas than the block gas limit for large sets. + function values( + Uint256Set storage set + ) internal view returns (uint256[] memory result) { + result = _toUints(values(_toBytes32Set(set))); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* PRIVATE HELPERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Returns the root slot. + function _rootSlot(Bytes32Set storage s) private pure returns (bytes32 r) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x04, _ENUMERABLE_WORD_SET_SLOT_SEED) + mstore(0x00, s.slot) + r := keccak256(0x00, 0x24) + } + } + + /// @dev Casts to a Bytes32Set. + function _toBytes32Set( + Uint256Set storage s + ) private pure returns (Bytes32Set storage c) { + /// @solidity memory-safe-assembly + assembly { + c.slot := s.slot + } + } + + /// @dev Casts to a uint256 array. + function _toUints( + bytes32[] memory a + ) private pure returns (uint256[] memory c) { + /// @solidity memory-safe-assembly + assembly { + c := a + } + } +} diff --git a/src/libraries/SafeTransferLib.sol b/src/libraries/SafeTransferLib.sol deleted file mode 100644 index 3cc64e0..0000000 --- a/src/libraries/SafeTransferLib.sol +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.26; - -import {IERC20} from "../interfaces/IERC20.sol"; - -/// @notice Safe ETH and ERC20 transfer library that gracefully handles missing return values. -/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/SafeTransferLib.sol) -/// @dev Use with caution! Some functions in this library knowingly create dirty bits at the destination of the free memory pointer. -/// @dev Note that none of the functions in this library check that a token has code at all! That responsibility is delegated to the caller. -library SafeTransferLib { - /*////////////////////////////////////////////////////////////// - ERC20 OPERATIONS - //////////////////////////////////////////////////////////////*/ - - function safeTransferFrom( - IERC20 token, - address from, - address to, - uint256 amount - ) internal { - bool success; - - /// @solidity memory-safe-assembly - assembly { - // Get a pointer to some free memory. - let freeMemoryPointer := mload(0x40) - - // Write the abi-encoded calldata into memory, beginning with the function selector. - mstore( - freeMemoryPointer, - 0x23b872dd00000000000000000000000000000000000000000000000000000000 - ) - mstore( - add(freeMemoryPointer, 4), - and(from, 0xffffffffffffffffffffffffffffffffffffffff) - ) // Append and mask the "from" argument. - mstore( - add(freeMemoryPointer, 36), - and(to, 0xffffffffffffffffffffffffffffffffffffffff) - ) // Append and mask the "to" argument. - mstore(add(freeMemoryPointer, 68), amount) // Append the "amount" argument. Masking not required as it's a full 32 byte type. - - success := and( - // Set success to whether the call reverted, if not we check it either - // returned exactly 1 (can't just be non-zero data), or had no return data. - or( - and(eq(mload(0), 1), gt(returndatasize(), 31)), - iszero(returndatasize()) - ), - // We use 100 because the length of our calldata totals up like so: 4 + 32 * 3. - // We use 0 and 32 to copy up to 32 bytes of return data into the scratch space. - // Counterintuitively, this call must be positioned second to the or() call in the - // surrounding and() call or else returndatasize() will be zero during the computation. - call(gas(), token, 0, freeMemoryPointer, 100, 0, 32) - ) - } - - require(success, "TRANSFER_FROM_FAILED"); - } - - function safeTransfer(IERC20 token, address to, uint256 amount) internal { - bool success; - - /// @solidity memory-safe-assembly - assembly { - // Get a pointer to some free memory. - let freeMemoryPointer := mload(0x40) - - // Write the abi-encoded calldata into memory, beginning with the function selector. - mstore( - freeMemoryPointer, - 0xa9059cbb00000000000000000000000000000000000000000000000000000000 - ) - mstore( - add(freeMemoryPointer, 4), - and(to, 0xffffffffffffffffffffffffffffffffffffffff) - ) // Append and mask the "to" argument. - mstore(add(freeMemoryPointer, 36), amount) // Append the "amount" argument. Masking not required as it's a full 32 byte type. - - success := and( - // Set success to whether the call reverted, if not we check it either - // returned exactly 1 (can't just be non-zero data), or had no return data. - or( - and(eq(mload(0), 1), gt(returndatasize(), 31)), - iszero(returndatasize()) - ), - // We use 68 because the length of our calldata totals up like so: 4 + 32 * 2. - // We use 0 and 32 to copy up to 32 bytes of return data into the scratch space. - // Counterintuitively, this call must be positioned second to the or() call in the - // surrounding and() call or else returndatasize() will be zero during the computation. - call(gas(), token, 0, freeMemoryPointer, 68, 0, 32) - ) - } - - require(success, "TRANSFER_FAILED"); - } -} diff --git a/test/DelegateStaking.t.sol b/test/DelegateStaking.t.sol index 44a5705..897de7b 100644 --- a/test/DelegateStaking.t.sol +++ b/test/DelegateStaking.t.sol @@ -28,6 +28,7 @@ contract DelegateStakingTest is Test { uint256 constant LOCK_PERIOD = 182 days; // 6 months uint256 constant REWARD_RATE = 0.1e18; + uint256 constant INITIAL_DEPOSIT = 10000e18; function setUp() public { // Set the block timestamp to an arbitrary value to avoid introducing assumptions into tests @@ -35,7 +36,6 @@ contract DelegateStakingTest is Test { _jumpAhead(1234); govToken = new MockGovToken(); - _mintGovToken(address(this), 100_000_000e18); vm.label(address(govToken), "govToken"); // deploy rewards distributor @@ -54,6 +54,8 @@ contract DelegateStakingTest is Test { ); vm.label(address(staking), "staking"); + _mintGovToken(address(this), INITIAL_DEPOSIT * 2); + govToken.approve(address(staking), INITIAL_DEPOSIT); staking.initialize( address(this), // owner address(govToken), @@ -72,6 +74,8 @@ contract DelegateStakingTest is Test { ); vm.label(address(delegate), "delegate"); + govToken.approve(address(delegate), INITIAL_DEPOSIT); + delegate.initialize( address(this), // owner address(govToken), @@ -86,7 +90,7 @@ contract DelegateStakingTest is Test { ); // fund reward distribution - govToken.transfer(address(rewardsDistributor), 100_000_000e18); + _mintGovToken(address(rewardsDistributor), 100_000_000e18); } function _setKeyper(address _keyper, bool _isKeyper) internal { @@ -162,7 +166,7 @@ contract DelegateStakingTest is Test { uint256 assets = govToken.balanceOf(address(delegate)) + _rewardsDistributed; - return _amount.mulDivUp(supply + 1, assets + 1); + return _amount.mulDivUp(supply, assets); } function _convertToSharesIncludeRewardsDistributed( @@ -174,7 +178,26 @@ contract DelegateStakingTest is Test { uint256 assets = govToken.balanceOf(address(delegate)) + _rewardsDistributed; - return _amount.mulDivDown(supply + 1, assets + 1); + return _amount.mulDivDown(supply, assets); + } + + function _convertToAssetsIncludeRewardsDistributed( + uint256 _shares, + uint256 _rewardsDistributed + ) internal view returns (uint256) { + uint256 supply = delegate.totalSupply(); + + uint256 assets = govToken.balanceOf(address(delegate)) + + _rewardsDistributed; + + return _shares.mulDivDown(assets, supply); + } + + function _maxWithdraw(address user) internal view returns (uint256) { + uint256 assets = delegate.convertToAssets(delegate.balanceOf(user)); + uint256 locked = delegate.totalLocked(user); + + return locked >= assets ? 0 : assets - locked; } } @@ -284,8 +307,6 @@ contract Stake is DelegateStakingTest { _depositor != ProxyUtils.getAdminAddress(address(delegate)) ); - assertEq(govToken.balanceOf(address(delegate)), 0); - _stake(_depositor, _keyper, _amount); assertEq( @@ -295,7 +316,7 @@ contract Stake is DelegateStakingTest { ); assertEq( govToken.balanceOf(address(delegate)), - _amount, + _amount + INITIAL_DEPOSIT, "Tokens were not transferred" ); vm.stopPrank(); @@ -331,6 +352,8 @@ contract Stake is DelegateStakingTest { ) public { _amount = _boundToRealisticStake(_amount); + uint256 supplyBefore = delegate.totalSupply(); + _mintGovToken(_depositor, _amount); _setKeyper(_keyper, true); @@ -339,9 +362,10 @@ contract Stake is DelegateStakingTest { _depositor != ProxyUtils.getAdminAddress(address(delegate)) ); + uint256 expectedShares = delegate.convertToShares(_amount); _stake(_depositor, _keyper, _amount); - assertEq(delegate.totalSupply(), _amount); + assertEq(delegate.totalSupply(), expectedShares + supplyBefore); } function testFuzz_UpdateTotalSupplyWhenTwoAccountsStakes( @@ -354,17 +378,23 @@ contract Stake is DelegateStakingTest { _amount1 = _boundToRealisticStake(_amount1); _amount2 = _boundToRealisticStake(_amount2); + uint256 supplyBefore = delegate.totalSupply(); + _mintGovToken(_depositor1, _amount1); _mintGovToken(_depositor2, _amount2); _setKeyper(_keyper, true); + uint256 expectedSharesDepositor1 = staking.convertToShares(_amount1); _stake(_depositor1, _keyper, _amount1); + + uint256 expectedSharesDepositor2 = staking.convertToShares(_amount2); + _stake(_depositor2, _keyper, _amount2); assertEq( delegate.totalSupply(), - _amount1 + _amount2, + supplyBefore + expectedSharesDepositor1 + expectedSharesDepositor2, "Wrong total supply" ); } @@ -443,14 +473,15 @@ contract Stake is DelegateStakingTest { _stake(_depositor1, _keyper1, _amount); _stake(_depositor2, _keyper2, _amount); - assertEq( + assertApproxEqAbs( delegate.balanceOf(_depositor1), delegate.balanceOf(_depositor2), + 1e5, "Wrong balance" ); assertEq(delegate.balanceOf(_depositor1), shares); assertEq(delegate.balanceOf(_depositor2), shares); - assertEq(delegate.totalSupply(), 2 * shares); + assertEq(delegate.totalSupply(), 2 * shares + INITIAL_DEPOSIT); } function testFuzz_Depositor1ReceivesMoreShareWhenStakingBeforeDepositor2( @@ -673,9 +704,9 @@ contract Stake is DelegateStakingTest { bobAmount = _boundToRealisticStake(bobAmount); - // alice deposits 1 - _mintGovToken(alice, 1); - _stake(alice, keyper, 1); + uint256 aliceDeposit = 1000e18; + _mintGovToken(alice, aliceDeposit); + uint256 aliceStakeId = _stake(alice, keyper, aliceDeposit); // simulate donation govToken.mint(address(delegate), bobAmount); @@ -688,35 +719,28 @@ contract Stake is DelegateStakingTest { // alice withdraw rewards (bob stake) even when there is no rewards distributed vm.startPrank(alice); - //delegate.unstake(aliceStakeId, 0); - uint256 aliceRewards = delegate.claimRewards(0); + delegate.unstake(aliceStakeId, 0); + delegate.claimRewards(0); vm.stopPrank(); uint256 aliceBalanceAfterAttack = govToken.balanceOf(alice); // attack should not be profitable for alice assertGtDecimal( - bobAmount + 1, // amount alice has spend in total + bobAmount + aliceDeposit, // amount alice has spend in total aliceBalanceAfterAttack, - 1e18, + 18, "Alice receive more than expend for the attack" ); - // as previewWithdraw rounds up, someone needs to stake again to have a dSHU total supply > 1 - // so bob can unstake - _mintGovToken(bob, aliceRewards + 10e18); - _stake(bob, keyper, aliceRewards + 10e18); - - vm.prank(bob); - delegate.unstake(bobStakeId, 0); + vm.startPrank(bob); + delegate.unstake(bobStakeId, bobAmount - 1e5); - uint256 bobBalanceAfterAttack = govToken.balanceOf(bob); - - // Alice earn less than bob - assertGt( - bobBalanceAfterAttack, - aliceBalanceAfterAttack, - "Alice earn more than Bob after the attack" + assertApproxEqRel( + govToken.balanceOf(bob), + bobAmount, + 0.01e18, + "Bob must receive the money back" ); } @@ -754,48 +778,21 @@ contract ClaimRewards is DelegateStakingTest { _jumpAhead(_jump); + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; + vm.startPrank(_depositor); delegate.claimRewards(0); - uint256 expectedRewards = REWARD_RATE * (_jump); - // need to accept a small error due to the donation attack prevention - assertApproxEqAbs( + assertEq( govToken.balanceOf(_depositor), expectedRewards, - 1e18, - "Wrong balance" - ); - } - - function testFuzz_GovTokenBalanceUnchangedWhenClaimingRewardsOnlyStaker( - address _keyper, - address _depositor, - uint256 _amount, - uint256 _jump - ) public { - _amount = _boundToRealisticStake(_amount); - _jump = _boundRealisticTimeAhead(_jump); - - _mintGovToken(_depositor, _amount); - _setKeyper(_keyper, true); - - _stake(_depositor, _keyper, _amount); - - uint256 contractBalanceBefore = govToken.balanceOf(address(delegate)); - - _jumpAhead(_jump); - - vm.prank(_depositor); - delegate.claimRewards(0); - - uint256 contractBalanceAfter = govToken.balanceOf(address(delegate)); - - // small percentage lost to the vault due to the donation attack prevention - assertApproxEqAbs( - contractBalanceAfter - contractBalanceBefore, - 0, - 1e18, "Wrong balance" ); } @@ -823,31 +820,6 @@ contract ClaimRewards is DelegateStakingTest { delegate.claimRewards(0); } - function testFuzz_ClaimAllRewardsOnlyStaker( - address _keyper, - address _depositor, - uint256 _amount, - uint256 _jump - ) public { - _amount = _boundToRealisticStake(_amount); - _jump = _boundRealisticTimeAhead(_jump); - - _mintGovToken(_depositor, _amount); - _setKeyper(_keyper, true); - - _stake(_depositor, _keyper, _amount); - - _jumpAhead(_jump); - - vm.prank(_depositor); - uint256 rewards = delegate.claimRewards(0); - - uint256 expectedRewards = REWARD_RATE * _jump; - - // need to accept a small error due to the donation attack prevention - assertApproxEqAbs(rewards, expectedRewards, 1e18, "Wrong rewards"); - } - function testFuzz_ClaimRewardBurnShares( address _keyper, address _depositor, @@ -866,11 +838,17 @@ contract ClaimRewards is DelegateStakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( expectedRewards, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); @@ -905,11 +883,17 @@ contract ClaimRewards is DelegateStakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( expectedRewards, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); @@ -1009,7 +993,14 @@ contract ClaimRewards is DelegateStakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; + vm.prank(_depositor); uint256 rewards = delegate.claimRewards(expectedRewards / 2); @@ -1034,10 +1025,17 @@ contract ClaimRewards is DelegateStakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; + uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( expectedRewards / 2, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); @@ -1068,12 +1066,13 @@ contract ClaimRewards is DelegateStakingTest { function testFuzz_RevertIf_UserHasNoShares(address _depositor) public { vm.assume( _depositor != address(0) && - _depositor != ProxyUtils.getAdminAddress(address(staking)) + _depositor != address(delegate) && + _depositor != ProxyUtils.getAdminAddress(address(delegate)) ); vm.prank(_depositor); - vm.expectRevert(DelegateStaking.UserHasNoShares.selector); - staking.claimRewards(0); + vm.expectRevert(BaseStaking.NoRewardsToClaim.selector); + delegate.claimRewards(0); } function testFuzz_RevertIf_NoRewardsToClaimForThatUser( @@ -1148,11 +1147,9 @@ contract Unstake is DelegateStakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; - - uint256 sharesToBurn = _previewWithdrawIncludeRewardsDistributed( + uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( _amount, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); @@ -1160,15 +1157,9 @@ contract Unstake is DelegateStakingTest { assertEq( delegate.totalSupply(), - totalSupplyBefore - sharesToBurn, + totalSupplyBefore - burnShares, "Wrong total supply" ); - - uint256 expectedSharesRemaining = delegate.convertToShares( - expectedRewards - ); - - assertEq(delegate.totalSupply(), expectedSharesRemaining); } function testFuzz_UnstakeShouldNotTransferRewards( @@ -1194,7 +1185,7 @@ contract Unstake is DelegateStakingTest { assertEq( govToken.balanceOf(address(delegate)), - expectedRewards, + expectedRewards + INITIAL_DEPOSIT, "Wrong balance" ); assertEq( @@ -1412,10 +1403,6 @@ contract OwnableFunctions is DelegateStakingTest { } function testFuzz_setLockPeriod(uint256 _newLockPeriod) public { - vm.expectEmit(); - - emit BaseStaking.NewLockPeriod(_newLockPeriod); - delegate.setLockPeriod(_newLockPeriod); assertEq(delegate.lockPeriod(), _newLockPeriod, "Wrong lock period"); @@ -1513,13 +1500,6 @@ contract OwnableFunctions is DelegateStakingTest { } contract ViewFunctions is DelegateStakingTest { - function testFuzz_Revertif_MaxWithdrawDepositorHasNoStakes( - address _depositor - ) public { - vm.expectRevert(DelegateStaking.UserHasNoShares.selector); - delegate.maxWithdraw(_depositor); - } - function testFuzz_MaxWithdrawDepositorHasLockedStakeNoRewards( address _keyper, address _depositor, @@ -1532,7 +1512,7 @@ contract ViewFunctions is DelegateStakingTest { _stake(_depositor, _keyper, _amount); - uint256 maxWithdraw = delegate.maxWithdraw(_depositor); + uint256 maxWithdraw = _maxWithdraw(_depositor); assertEq(maxWithdraw, 0, "Wrong max withdraw"); } @@ -1553,11 +1533,17 @@ contract ViewFunctions is DelegateStakingTest { _jumpAhead(_jump); - rewardsDistributor.collectRewardsTo(address(delegate)); + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + delegate.balanceOf(_depositor1), + REWARD_RATE * _jump + ); - uint256 rewards = REWARD_RATE * _jump; + uint256 rewards = assetsAmount - _amount1; + + rewardsDistributor.collectRewardsTo(address(delegate)); - uint256 maxWithdraw = delegate.maxWithdraw(_depositor1); + uint256 maxWithdraw = _maxWithdraw(_depositor1); assertApproxEqAbs(maxWithdraw, rewards, 0.1e18, "Wrong max withdraw"); } @@ -1579,14 +1565,10 @@ contract ViewFunctions is DelegateStakingTest { _stake(_depositor, _keyper, _amount1); _stake(_depositor, _keyper, _amount2); - uint256 maxWithdraw = delegate.maxWithdraw(_depositor); + uint256 maxWithdraw = _maxWithdraw(_depositor); assertEq(maxWithdraw, 0, "Wrong max withdraw"); } - function testFuzz_ConvertToSharesNoSupply(uint256 assets) public view { - assertEq(delegate.convertToShares(assets), assets); - } - function testFuzz_ConvertToSharesHasSupplySameBlock( address _keyper, address _depositor, @@ -1654,7 +1636,7 @@ contract ViewFunctions is DelegateStakingTest { _mintGovToken(_depositor, _amount); _setKeyper(_keyper, true); - uint256 stakeId = _stake(_depositor, _keyper, _amount); + _stake(_depositor, _keyper, _amount); uint256 withdrawAmount = delegate.exposed_calculateWithdrawAmount( _amount / 2, diff --git a/test/RewardsDistributor.t.sol b/test/RewardsDistributor.t.sol index 1170d10..47b9caf 100644 --- a/test/RewardsDistributor.t.sol +++ b/test/RewardsDistributor.t.sol @@ -103,7 +103,7 @@ contract OwnableFunctions is RewardsDistributorTest { assertEq(lastUpdate, vm.getBlockTimestamp()); } - function testFuzz_DoNotSetLastUpdateIfIsNotTheFirstTime( + function testFuzz_TransferTokensIfIsAnUpdate( address _receiver, uint256 _emissionRate ) public { @@ -116,14 +116,20 @@ contract OwnableFunctions is RewardsDistributorTest { _receiver ); + uint256 balanceBefore = govToken.balanceOf(_receiver); + + govToken.mint(address(rewardsDistributor), _emissionRate); + vm.warp(vm.getBlockTimestamp() + 1); rewardsDistributor.setRewardConfiguration(_receiver, _emissionRate); + assertEq(govToken.balanceOf(_receiver), balanceBefore + _emissionRate); + (, uint256 lastUpdateAfter) = rewardsDistributor.rewardConfigurations( _receiver ); - assertEq(lastUpdateBefore, lastUpdateAfter); + assertEq(lastUpdateBefore + 1, lastUpdateAfter); } function testFuzz_RevertIf_SetRewardConfigurationZeroAddress( @@ -224,7 +230,11 @@ contract OwnableFunctions is RewardsDistributorTest { uint256 balanceBefore = govToken.balanceOf(_to); - rewardsDistributor.withdrawFunds(_to, _amount); + rewardsDistributor.withdrawFunds( + address(rewardsDistributor.rewardToken()), + _to, + _amount + ); assertEq(govToken.balanceOf(_to), balanceBefore + _amount); } @@ -236,14 +246,18 @@ contract OwnableFunctions is RewardsDistributorTest { ) public { vm.assume(_anyone != address(this)); + govToken.mint(address(rewardsDistributor), _amount); + + address token = address(rewardsDistributor.rewardToken()); vm.expectRevert( abi.encodeWithSelector( Ownable.OwnableUnauthorizedAccount.selector, _anyone ) ); + vm.prank(_anyone); - rewardsDistributor.withdrawFunds(_to, _amount); + rewardsDistributor.withdrawFunds(token, _to, _amount); } } diff --git a/test/Staking.integration.t.sol b/test/Staking.integration.t.sol index 6849872..9f231b5 100644 --- a/test/Staking.integration.t.sol +++ b/test/Staking.integration.t.sol @@ -25,9 +25,12 @@ contract StakingIntegrationTest is Test { function setUp() public { vm.label(STAKING_TOKEN, "SHU"); vm.createSelectFork(vm.rpcUrl("mainnet"), 20254999); + (, address sender, ) = vm.readCallers(); + console.log("sender", sender); + deal(STAKING_TOKEN, sender, INITIAL_MINT * 2); Deploy deployScript = new Deploy(); - (staking, rewardsDistributor) = deployScript.run(); + (staking, rewardsDistributor, ) = deployScript.run(); } function _boundRealisticTimeAhead( @@ -258,47 +261,53 @@ contract StakingIntegrationTest is Test { assertApproxEqAbs(APR, 21e18, 1e18); } - function testForkFuzz_MultipleDepositorsStakeMinStakeSameTimestamp( - uint256 _depositorsCount, - uint256 _jump - ) public { - _depositorsCount = bound(_depositorsCount, 1, 1000); - - _jump = _boundRealisticTimeAhead(_jump); - - _setRewardAndFund(); - - for (uint256 i = 0; i < _depositorsCount; i++) { - address depositor = address( - uint160(uint256(keccak256(abi.encodePacked(i)))) - ); - vm.prank(CONTRACT_OWNER); - staking.setKeyper(depositor, true); - - deal(STAKING_TOKEN, depositor, MIN_STAKE); - - vm.startPrank(depositor); - IERC20(STAKING_TOKEN).approve(address(staking), MIN_STAKE); - staking.stake(MIN_STAKE); - vm.stopPrank(); - } - - uint256 expectedRewardsDistributed = REWARD_RATE * _jump; - - uint256 expectedRewardPerKeyper = expectedRewardsDistributed / - _depositorsCount; - - _jumpAhead(_jump); - - for (uint256 i = 0; i < _depositorsCount; i++) { - address depositor = address( - uint160(uint256(keccak256(abi.encodePacked(i)))) - ); - vm.startPrank(depositor); - uint256 rewards = staking.claimRewards(0); - vm.stopPrank(); - - assertApproxEqAbs(rewards, expectedRewardPerKeyper, 0.1e18); - } - } + // function testForkFuzz_MultipleDepositorsStakeMinStakeSameTimestamp( + // uint256 _depositorsCount, + // uint256 _jump + // ) public { + // _depositorsCount = bound(_depositorsCount, 1, 1000); + // + // _jump = _boundRealisticTimeAhead(_jump); + // + // _setRewardAndFund(); + // + // for (uint256 i = 0; i < _depositorsCount; i++) { + // address depositor = address( + // uint160(uint256(keccak256(abi.encodePacked(i)))) + // ); + // vm.prank(CONTRACT_OWNER); + // staking.setKeyper(depositor, true); + // + // deal(STAKING_TOKEN, depositor, MIN_STAKE); + // + // vm.startPrank(depositor); + // IERC20(STAKING_TOKEN).approve(address(staking), MIN_STAKE); + // staking.stake(MIN_STAKE); + // vm.stopPrank(); + // } + // + // uint256 expectedRewardsDistributed = REWARD_RATE * _jump; + // + // uint256 deadAssetsBefore = staking.convertToAssets( + // staking.balanceOf(address(staking)) + // ); + // + // // uint256 deadRewards = _previewWithdrawIncludeRewardsDistributed() + // + // uint256 expectedRewardPerKeyper = (expectedRewardsDistributed - + // deadAssets) / _depositorsCount; + // + // _jumpAhead(_jump); + // + // for (uint256 i = 0; i < _depositorsCount; i++) { + // address depositor = address( + // uint160(uint256(keccak256(abi.encodePacked(i)))) + // ); + // vm.startPrank(depositor); + // uint256 rewards = staking.claimRewards(0); + // vm.stopPrank(); + // + // assertApproxEqAbs(rewards, expectedRewardPerKeyper, 0.1e18); + // } + // } } diff --git a/test/Staking.t.sol b/test/Staking.t.sol index 29c0765..827377f 100644 --- a/test/Staking.t.sol +++ b/test/Staking.t.sol @@ -7,6 +7,8 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; + import {FixedPointMathLib} from "src/libraries/FixedPointMathLib.sol"; import {Staking} from "src/Staking.sol"; import {BaseStaking} from "src/BaseStaking.sol"; @@ -26,6 +28,7 @@ contract StakingTest is Test { uint256 constant LOCK_PERIOD = 182 days; // 6 months uint256 constant MIN_STAKE = 50_000e18; // 50k uint256 constant REWARD_RATE = 0.1e18; + uint256 constant INITIAL_DEPOSIT = 10000e18; function setUp() public { // Set the block timestamp to an arbitrary value to avoid introducing assumptions into tests @@ -33,7 +36,6 @@ contract StakingTest is Test { _jumpAhead(1234); govToken = new MockGovToken(); - _mintGovToken(address(this), 100_000_000e18); vm.label(address(govToken), "govToken"); // deploy rewards distributor @@ -51,6 +53,10 @@ contract StakingTest is Test { ); vm.label(address(staking), "staking"); + _mintGovToken(address(this), INITIAL_DEPOSIT); + + govToken.approve(address(staking), INITIAL_DEPOSIT); + staking.initialize( address(this), // owner address(govToken), @@ -65,7 +71,8 @@ contract StakingTest is Test { ); // fund reward distribution - govToken.transfer(address(rewardsDistributor), 100_000_000e18); + + _mintGovToken(address(rewardsDistributor), 100_000_000e18); } function _jumpAhead(uint256 _seconds) public { @@ -143,7 +150,7 @@ contract StakingTest is Test { uint256 assets = govToken.balanceOf(address(staking)) + _rewardsDistributed; - return _amount.mulDivUp(supply + 1, assets + 1); + return _amount.mulDivUp(supply, assets); } function _convertToSharesIncludeRewardsDistributed( @@ -155,7 +162,26 @@ contract StakingTest is Test { uint256 assets = govToken.balanceOf(address(staking)) + _rewardsDistributed; - return _amount.mulDivDown(supply + 1, assets + 1); + return _amount.mulDivDown(supply, assets); + } + + function _convertToAssetsIncludeRewardsDistributed( + uint256 _shares, + uint256 _rewardsDistributed + ) internal view returns (uint256) { + uint256 supply = staking.totalSupply(); + + uint256 assets = govToken.balanceOf(address(staking)) + + _rewardsDistributed; + + return _shares.mulDivDown(assets, supply); + } + + function _maxWithdraw(address user) internal view returns (uint256) { + uint256 assets = staking.convertToAssets(staking.balanceOf(user)); + uint256 locked = staking.totalLocked(user); + + return locked >= assets ? 0 : assets - locked; } } @@ -249,13 +275,11 @@ contract Stake is StakingTest { _mintGovToken(_depositor, _amount); _setKeyper(_depositor, true); - assertEq(govToken.balanceOf(address(staking)), 0); - _stake(_depositor, _amount); assertEq(govToken.balanceOf(_depositor), 0, "Wrong balance"); assertEq( govToken.balanceOf(address(staking)), - _amount, + _amount + INITIAL_DEPOSIT, "Wrong balance" ); } @@ -286,14 +310,22 @@ contract Stake is StakingTest { ) public { _amount = _boundToRealisticStake(_amount); + uint256 supplyBefore = staking.totalSupply(); + _mintGovToken(_depositor, _amount); _setKeyper(_depositor, true); vm.assume(_depositor != address(0)); + uint256 expectedShares = staking.convertToShares(_amount); + _stake(_depositor, _amount); - assertEq(staking.totalSupply(), _amount, "Wrong total supply"); + assertEq( + staking.totalSupply(), + supplyBefore + expectedShares, + "Wrong total supply" + ); } function testFuzz_UpdateTotalSupplyWhenTwoAccountsStakes( @@ -305,18 +337,23 @@ contract Stake is StakingTest { _amount1 = _boundToRealisticStake(_amount1); _amount2 = _boundToRealisticStake(_amount2); + uint256 supplyBefore = staking.totalSupply(); + _mintGovToken(_depositor1, _amount1); _mintGovToken(_depositor2, _amount2); _setKeyper(_depositor1, true); _setKeyper(_depositor2, true); + uint256 expectedSharesDepositor1 = staking.convertToShares(_amount1); _stake(_depositor1, _amount1); + + uint256 expectedSharesDepositor2 = staking.convertToShares(_amount2); _stake(_depositor2, _amount2); assertEq( staking.totalSupply(), - _amount1 + _amount2, + supplyBefore + expectedSharesDepositor1 + expectedSharesDepositor2, "Wrong total supply" ); } @@ -396,11 +433,6 @@ contract Stake is StakingTest { _amount1 + _amount2, "Wrong balance" ); - assertEq( - staking.totalSupply(), - _amount1 + _amount2, - "Wrong total supply" - ); } function testFuzz_Depositor1AndDepositor2ReceivesTheSameAmountOfSharesWhenStakingSameAmountInTheSameBlock( @@ -431,7 +463,7 @@ contract Stake is StakingTest { ); assertEq(staking.balanceOf(_depositor1), shares); assertEq(staking.balanceOf(_depositor2), shares); - assertEq(staking.totalSupply(), 2 * shares); + assertEq(staking.totalSupply(), 2 * shares + INITIAL_DEPOSIT); } function testFuzz_Depositor1ReceivesMoreShareWhenStakingBeforeDepositor2( @@ -678,15 +710,12 @@ contract Stake is StakingTest { _setKeyper(alice, true); _stake(alice, initialStake); - assertEq(staking.totalSupply(), initialStake); - // simulate donation govToken.mint(address(staking), donationAmount); - assertEq(staking.totalSupply(), initialStake); assertEq( govToken.balanceOf(address(staking)), - initialStake + donationAmount + initialStake + donationAmount + INITIAL_DEPOSIT ); // bob mints @@ -712,18 +741,14 @@ contract Stake is StakingTest { ); // bob unstake maximum he can unstake - uint256 maxBobCanWithdraw = staking.exposed_maxWithdraw(bob, bobStake); vm.prank(bob); - staking.unstake(bob, bobStakeId, maxBobCanWithdraw); - - uint256 bobBalance = govToken.balanceOf(bob); + staking.unstake(bob, bobStakeId, bobStake - MIN_STAKE - 1e17); - // at the end Alice still lost more than bob - assertGtDecimal( - donationAmount - aliceRewards, - bobStake - bobBalance, - 1e18, - "Alice receive more than bob" + assertApproxEqRel( + govToken.balanceOf(bob), + bobStake - MIN_STAKE, + 0.01e18, + "Bob must receive the money back" ); } } @@ -742,52 +767,23 @@ contract ClaimRewards is StakingTest { _stake(_depositor, _amount); - uint256 timestampBefore = vm.getBlockTimestamp(); - _jumpAhead(_jump); - vm.prank(_depositor); - staking.claimRewards(0); - - uint256 expectedRewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); - - // need to accept a small error due to the donation attack prevention - assertApproxEqAbs( - govToken.balanceOf(_depositor), - expectedRewards, - 1e18, - "Wrong balance" + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + staking.balanceOf(_depositor), + REWARD_RATE * _jump ); - } - function testFuzz_GovTokenBalanceUnchangedWhenClaimingRewardsOnlyStaker( - address _depositor, - uint256 _amount, - uint256 _jump - ) public { - _amount = _boundToRealisticStake(_amount); - _jump = _boundRealisticTimeAhead(_jump); - - _mintGovToken(_depositor, _amount); - _setKeyper(_depositor, true); - - _stake(_depositor, _amount); - - uint256 contractBalanceBefore = govToken.balanceOf(address(staking)); - - _jumpAhead(_jump); + uint256 expectedRewards = assetsAmount - _amount; vm.prank(_depositor); staking.claimRewards(0); - uint256 contractBalanceAfter = govToken.balanceOf(address(staking)); - - // small percentage lost to the vault due to the donation attack prevention - assertApproxEqAbs( - contractBalanceAfter - contractBalanceBefore, - 0, - 1e18, + // need to accept a small error due to the donation attack prevention + assertEq( + govToken.balanceOf(_depositor), + expectedRewards, "Wrong balance" ); } @@ -819,33 +815,6 @@ contract ClaimRewards is StakingTest { staking.claimRewards(0); } - function testFuzz_ClaimAllRewardsOnlyStaker( - address _depositor, - uint256 _amount, - uint256 _jump - ) public { - _amount = _boundToRealisticStake(_amount); - _jump = _boundRealisticTimeAhead(_jump); - - _mintGovToken(_depositor, _amount); - _setKeyper(_depositor, true); - - _stake(_depositor, _amount); - - uint256 timestampBefore = vm.getBlockTimestamp(); - - _jumpAhead(_jump); - - vm.prank(_depositor); - uint256 rewards = staking.claimRewards(0); - - uint256 expectedRewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); - - // need to accept a small error due to the donation attack prevention - assertApproxEqAbs(rewards, expectedRewards, 1e18, "Wrong rewards"); - } - function testFuzz_ClaimRewardBurnShares( address _depositor, uint256 _amount, @@ -859,17 +828,21 @@ contract ClaimRewards is StakingTest { _stake(_depositor, _amount); - uint256 timestampBefore = vm.getBlockTimestamp(); uint256 sharesBefore = staking.balanceOf(_depositor); _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + staking.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( expectedRewards, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); @@ -877,7 +850,6 @@ contract ClaimRewards is StakingTest { uint256 sharesAfter = staking.balanceOf(_depositor); - // need to accept a small error due to the donation attack prevention assertApproxEqAbs( sharesBefore - sharesAfter, burnShares, @@ -899,25 +871,32 @@ contract ClaimRewards is StakingTest { _stake(_depositor, _amount); - uint256 timestampBefore = vm.getBlockTimestamp(); + uint256 totalSupplyBefore = staking.totalSupply(); _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + staking.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( expectedRewards, - expectedRewards + REWARD_RATE * _jump ); vm.prank(_depositor); staking.claimRewards(0); + uint256 totalSupplyAfter = staking.totalSupply(); + assertApproxEqAbs( - staking.totalSupply(), - _amount - burnShares, - 1e18, + totalSupplyAfter, + totalSupplyBefore - burnShares, + 1, "Wrong total supply" ); } @@ -1000,12 +979,15 @@ contract ClaimRewards is StakingTest { _stake(_depositor, _amount); - uint256 timestampBefore = vm.getBlockTimestamp(); - _jumpAhead(_jump); - uint256 expectedRewards = (REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore)) - 1e18; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + staking.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; vm.prank(_depositor); uint256 rewards = staking.claimRewards(expectedRewards); @@ -1026,22 +1008,25 @@ contract ClaimRewards is StakingTest { _stake(_depositor, _amount); - uint256 timestampBefore = vm.getBlockTimestamp(); uint256 sharesBefore = staking.balanceOf(_depositor); _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); - uint256 rewardsToClaim = expectedRewards / 2; + // first 1000 shares was the dead shares so must decrease from the expected rewards + uint256 assetsAmount = _convertToAssetsIncludeRewardsDistributed( + staking.balanceOf(_depositor), + REWARD_RATE * _jump + ); + + uint256 expectedRewards = assetsAmount - _amount; uint256 burnShares = _previewWithdrawIncludeRewardsDistributed( - rewardsToClaim, - expectedRewards + expectedRewards / 2, + REWARD_RATE * _jump ); vm.prank(_depositor); - staking.claimRewards(rewardsToClaim); + staking.claimRewards(expectedRewards / 2); uint256 sharesAfter = staking.balanceOf(_depositor); @@ -1065,14 +1050,15 @@ contract ClaimRewards is StakingTest { staking.claimRewards(0); } - function testFuzz_RevertIf_KeyperHasNoSHares(address _depositor) public { + function testFuzz_RevertIf_UserHasNoShares(address _depositor) public { vm.assume( _depositor != address(0) && + _depositor != address(staking) && _depositor != ProxyUtils.getAdminAddress(address(staking)) ); vm.prank(_depositor); - vm.expectRevert(Staking.UserHasNoShares.selector); + vm.expectRevert(BaseStaking.NoRewardsToClaim.selector); staking.claimRewards(0); } @@ -1165,7 +1151,7 @@ contract Unstake is StakingTest { assertEq( govToken.balanceOf(address(staking)), - expectedRewards + MIN_STAKE, + expectedRewards + MIN_STAKE + INITIAL_DEPOSIT, "Wrong balance" ); } @@ -1229,7 +1215,7 @@ contract Unstake is StakingTest { uint256 _amount, uint256 _jump ) public { - _amount = _boundToRealisticStake(_amount); + _amount = bound(_amount, MIN_STAKE + 1, 5_000_000e18); _jump = _boundUnlockedTime(_jump); _mintGovToken(_depositor, _amount + MIN_STAKE); @@ -1241,32 +1227,19 @@ contract Unstake is StakingTest { _jumpAhead(_jump); - uint256 expectedRewards = REWARD_RATE * _jump; uint256 sharesToBurn = _previewWithdrawIncludeRewardsDistributed( - _amount, - expectedRewards + _amount - MIN_STAKE, + REWARD_RATE * _jump ); vm.prank(_depositor); - staking.unstake(_depositor, stakeId, 0); + staking.unstake(_depositor, stakeId, _amount - MIN_STAKE); assertEq( staking.totalSupply(), totalSupplyBefore - sharesToBurn, "Wrong total supply" ); - - uint256 expectedSharesRemaining = staking.convertToShares( - MIN_STAKE + expectedRewards - ); - - // TODO review this - assertApproxEqRel( - staking.totalSupply(), - expectedSharesRemaining, - 0.1e18, - "Wrong total supply with remaing shares" - ); } function testFuzz_AnyoneCanUnstakeOnBehalfOfKeyperWhenKeyperIsNotAKeyperAnymore( @@ -1542,16 +1515,12 @@ contract OwnableFunctions is StakingTest { } function testFuzz_setLockPeriod(uint256 _newLockPeriod) public { - vm.expectEmit(); - emit BaseStaking.NewLockPeriod(_newLockPeriod); staking.setLockPeriod(_newLockPeriod); assertEq(staking.lockPeriod(), _newLockPeriod, "Wrong lock period"); } function testFuzz_setMinStake(uint256 _newMinStake) public { - vm.expectEmit(); - emit Staking.NewMinStake(_newMinStake); staking.setMinStake(_newMinStake); assertEq(staking.minStake(), _newMinStake, "Wrong min stake"); @@ -1654,13 +1623,6 @@ contract OwnableFunctions is StakingTest { } contract ViewFunctions is StakingTest { - function testFuzz_Revertif_MaxWithdrawDepositorHasNoStakes( - address _depositor - ) public { - vm.expectRevert(Staking.UserHasNoShares.selector); - staking.maxWithdraw(_depositor); - } - function testFuzz_MaxWithdrawDepositorHasLockedStakeNoRewards( address _depositor, uint256 _amount @@ -1672,7 +1634,7 @@ contract ViewFunctions is StakingTest { _stake(_depositor, _amount); - uint256 maxWithdraw = staking.maxWithdraw(_depositor); + uint256 maxWithdraw = _maxWithdraw(_depositor); assertEq(maxWithdraw, 0, "Wrong max withdraw"); } @@ -1683,6 +1645,8 @@ contract ViewFunctions is StakingTest { uint256 _amount2, uint256 _jump ) public { + vm.assume(_depositor1 != _depositor2); + _amount1 = _boundToRealisticStake(_amount1); _amount2 = _boundToRealisticStake(_amount2); @@ -1693,20 +1657,20 @@ contract ViewFunctions is StakingTest { _stake(_depositor1, _amount1); - uint256 timestampBefore = vm.getBlockTimestamp(); - _jumpAhead(_jump); - // depositor 2 stakes and collect rewards from distirbutor _mintGovToken(_depositor2, _amount2); _setKeyper(_depositor2, true); _stake(_depositor2, _amount2); - uint256 rewards = REWARD_RATE * - (vm.getBlockTimestamp() - timestampBefore); + uint256 assetsAmount = staking.convertToAssets( + staking.balanceOf(_depositor1) + ); - uint256 maxWithdraw = staking.maxWithdraw(_depositor1); + uint256 rewards = assetsAmount - _amount1; + + uint256 maxWithdraw = _maxWithdraw(_depositor1); assertApproxEqAbs(maxWithdraw, rewards, 0.1e18, "Wrong max withdraw"); } @@ -1726,14 +1690,10 @@ contract ViewFunctions is StakingTest { _stake(_depositor, _amount1); _stake(_depositor, _amount2); - uint256 maxWithdraw = staking.maxWithdraw(_depositor); + uint256 maxWithdraw = _maxWithdraw(_depositor); assertEq(maxWithdraw, 0, "Wrong max withdraw"); } - function testFuzz_convertToSharesNoSupply(uint256 assets) public view { - assertEq(staking.convertToShares(assets), assets); - } - function testFuzz_ConvertToSharesHasSupplySameBlock( address _depositor, uint256 _assets @@ -1750,10 +1710,6 @@ contract ViewFunctions is StakingTest { assertEq(shares, _assets, "Wrong shares"); } - function testFuzz_ConvertToAssetsNoSupply(uint256 shares) public view { - assertEq(staking.convertToAssets(shares), shares); - } - function testFuzz_ConvertToAssetsHasSupplySameBlock( address _depositor, uint256 _assets diff --git a/test/helpers/DelegateStakingHarness.sol b/test/helpers/DelegateStakingHarness.sol index b1348f5..91b30f6 100644 --- a/test/helpers/DelegateStakingHarness.sol +++ b/test/helpers/DelegateStakingHarness.sol @@ -17,7 +17,7 @@ contract DelegateStakingHarness is DelegateStaking { function exposed_calculateWithdrawAmount( uint256 _amount, uint256 _maxWithdrawAmount - ) external view returns (uint256) { + ) external pure returns (uint256) { return _calculateWithdrawAmount(_amount, _maxWithdrawAmount); } } diff --git a/test/helpers/StakingHarness.sol b/test/helpers/StakingHarness.sol index 0a99125..dee2aae 100644 --- a/test/helpers/StakingHarness.sol +++ b/test/helpers/StakingHarness.sol @@ -8,13 +8,6 @@ contract StakingHarness is Staking { return nextStakeId; } - function exposed_maxWithdraw( - address keyper, - uint256 unlockedAmount - ) external view virtual returns (uint256) { - return _maxWithdraw(keyper, unlockedAmount); - } - function exposed_previewWithdraw( uint256 amount ) external view returns (uint256) {