diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a464789..a55b3b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: )" >> $GITHUB_ENV - name: Run tests - run: forge test + run: forge test --no-match-contract Integration coverage: runs-on: ubuntu-latest @@ -59,7 +59,7 @@ jobs: )" >> $GITHUB_ENV - name: Run coverage - run: forge coverage --report summary --report lcov --ir-minimum + run: forge coverage --report summary --report lcov --ir-minimum --no-match-contract Integration # To ignore coverage for certain directories modify the paths in this step as needed. The # below default ignores coverage results for the test and script directories. Alternatively, diff --git a/foundry.toml b/foundry.toml index 9445c55..1a879d0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -16,3 +16,6 @@ # Speed up compilation and tests during development. optimizer = false +[rpc_endpoints] + mainnet = "${MAINNET_RPC_URL}" + diff --git a/script/Constants.sol b/script/Constants.sol new file mode 100644 index 0000000..74f24d9 --- /dev/null +++ b/script/Constants.sol @@ -0,0 +1,7 @@ +pragma solidity 0.8.26; + +address constant STAKING_TOKEN = 0xe485E2f1bab389C08721B291f6b59780feC83Fd7; // shutter token +address constant CONTRACT_OWNER = 0x36bD3044ab68f600f6d3e081056F34f2a58432c4; // shuter multisig +uint256 constant LOCK_PERIOD = 182 days; +uint256 constant MIN_STAKE = 50_000e18; +uint256 constant REWARD_RATE = 0.1333333333e18; diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 0000000..6117d2c --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "@forge-std/Script.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {RewardsDistributor} from "src/RewardsDistributor.sol"; +import {Staking} from "src/Staking.sol"; +import "./Constants.sol"; + +contract Deploy is Script { + function run() + public + returns (Staking stakingProxy, RewardsDistributor rewardsDistributor) + { + vm.startBroadcast(); + + rewardsDistributor = new RewardsDistributor( + CONTRACT_OWNER, + STAKING_TOKEN + ); + + stakingProxy = Staking( + address( + new TransparentUpgradeableProxy( + address(new Staking()), + address(CONTRACT_OWNER), + "" + ) + ) + ); + + stakingProxy.initialize( + CONTRACT_OWNER, + STAKING_TOKEN, + address(rewardsDistributor), + LOCK_PERIOD, + MIN_STAKE + ); + + vm.stopBroadcast(); + } +} diff --git a/src/RewardsDistributor.sol b/src/RewardsDistributor.sol index 78481a2..486c489 100644 --- a/src/RewardsDistributor.sol +++ b/src/RewardsDistributor.sol @@ -76,7 +76,15 @@ contract RewardsDistributor is Ownable2Step, IRewardsDistributor { // difference in time since last update uint256 timeDelta = block.timestamp - rewardConfiguration.lastUpdate; - if (rewardConfiguration.emissionRate != 0 && timeDelta != 0) { + // the contract must have funds to distribute + // we don't want to revert in case its zero to not block the staking contract + uint256 funds = rewardToken.balanceOf(address(this)); + + if ( + rewardConfiguration.emissionRate != 0 && + timeDelta != 0 && + funds != 0 + ) { rewards = rewardConfiguration.emissionRate * timeDelta; // update the last update timestamp @@ -98,10 +106,11 @@ contract RewardsDistributor is Ownable2Step, IRewardsDistributor { ) external override onlyOwner { require(receiver != address(0), ZeroAddress()); - rewardConfigurations[receiver] = RewardConfiguration( - emissionRate, - block.timestamp - ); + // only update last update if it's the first time + if (rewardConfigurations[receiver].lastUpdate == 0) { + rewardConfigurations[receiver].lastUpdate = block.timestamp; + } + rewardConfigurations[receiver].emissionRate = emissionRate; emit RewardConfigurationSet(receiver, emissionRate); } diff --git a/src/Staking.sol b/src/Staking.sol index ce8a10c..641b357 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -10,7 +10,6 @@ import {FixedPointMathLib} from "@solmate/utils/FixedPointMathLib.sol"; import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol"; // TODO is this vulnerable to first deposit attack? -// TODO check calculations contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /*////////////////////////////////////////////////////////////// LIBRARIES @@ -177,7 +176,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { uint256 _lockPeriod, uint256 _minStake ) public initializer { - // TODO set name and symbol + __ERC20_init("Staked SHU", "sSHU"); // Transfer ownership to the DAO contract _transferOwnership(newOwner); @@ -198,7 +197,6 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// - Only keypers can stake /// @param amount The amount of SHU to stake /// @return stakeId The index of the stake - /// TODO slippage protection function stake( uint256 amount ) external onlyKeyper updateRewards returns (uint256 stakeId) { @@ -263,7 +261,6 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @param stakeId The stake index /// @param amount The amount /// TODO check for reentrancy - /// TODO slippage protection function unstake( address keyper, uint256 stakeId, @@ -374,7 +371,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @param _rewardsDistributor The address of the rewards distributor contract function setRewardsDistributor( address _rewardsDistributor - ) external onlyOwner updateRewards { + ) external onlyOwner { rewardsDistributor = IRewardsDistributor(_rewardsDistributor); emit NewRewardsDistributor(_rewardsDistributor); @@ -382,9 +379,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice Set the lock period /// @param _lockPeriod The lock period in seconds - function setLockPeriod( - uint256 _lockPeriod - ) external onlyOwner updateRewards { + function setLockPeriod(uint256 _lockPeriod) external onlyOwner { lockPeriod = _lockPeriod; emit NewLockPeriod(_lockPeriod); @@ -401,10 +396,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @notice Set a keyper /// @param keyper The keyper address /// @param isKeyper Whether the keyper is a keyper or not - function setKeyper( - address keyper, - bool isKeyper - ) external onlyOwner updateRewards { + function setKeyper(address keyper, bool isKeyper) external onlyOwner { _setKeyper(keyper, isKeyper); } @@ -414,7 +406,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { function setKeypers( address[] memory _keypers, bool isKeyper - ) external onlyOwner updateRewards { + ) external onlyOwner { for (uint256 i = 0; i < _keypers.length; i++) { _setKeyper(_keypers[i], isKeyper); } @@ -452,6 +444,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { /// @return The maximum amount of assets that a keyper can withdraw function maxWithdraw(address keyper) public view virtual returns (uint256) { uint256 shares = balanceOf(keyper); + require(shares > 0, KeyperHasNoShares()); uint256 assets = convertToAssets(shares); @@ -461,7 +454,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { : minStake; if (assets < compare) { - // TODO check this + // need this branch as convertToAssets rounds down return 0; } else { return assets - compare; @@ -481,7 +474,7 @@ contract Staking is ERC20VotesUpgradeable, Ownable2StepUpgradeable { uint256 compare = locked >= minStake ? locked : minStake; if (assets < compare) { - // TODO check this + // need this branch as convertToAssets rounds down return 0; } else { return assets - compare; diff --git a/test/RewardsDistributor.t.sol b/test/RewardsDistributor.t.sol index 071d4ad..e925cf0 100644 --- a/test/RewardsDistributor.t.sol +++ b/test/RewardsDistributor.t.sol @@ -266,7 +266,9 @@ contract CollectRewards is RewardsDistributorTest { uint256 _jump, uint256 _emissionRate ) public { - vm.assume(address(_receiver) != address(0)); + vm.assume( + _receiver != address(0) && _receiver != address(rewardsDistributor) + ); _emissionRate = bound(_emissionRate, 1, 1e18); rewardsDistributor.setRewardConfiguration(_receiver, _emissionRate); diff --git a/test/Staking.integration.t.sol b/test/Staking.integration.t.sol new file mode 100644 index 0000000..6849872 --- /dev/null +++ b/test/Staking.integration.t.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "@forge-std/Test.sol"; + +import {FixedPointMathLib} from "@solmate/utils/FixedPointMathLib.sol"; +import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Staking} from "src/Staking.sol"; +import {RewardsDistributor} from "src/RewardsDistributor.sol"; +import {IRewardsDistributor} from "src/interfaces/IRewardsDistributor.sol"; +import {MockGovToken} from "test/mocks/MockGovToken.sol"; +import {ProxyUtils} from "test/helpers/ProxyUtils.sol"; +import {Staking} from "src/Staking.sol"; +import {Deploy} from "script/Deploy.s.sol"; +import "script/Constants.sol"; + +contract StakingIntegrationTest is Test { + Staking staking; + RewardsDistributor rewardsDistributor; + + uint256 constant CIRCULATION_SUPPLY = 81_000_000e18; + + function setUp() public { + vm.label(STAKING_TOKEN, "SHU"); + vm.createSelectFork(vm.rpcUrl("mainnet"), 20254999); + + Deploy deployScript = new Deploy(); + (staking, rewardsDistributor) = deployScript.run(); + } + + function _boundRealisticTimeAhead( + uint256 _time + ) internal pure returns (uint256) { + return bound(_time, 1, 105 weeks); // two years + } + + function _jumpAhead(uint256 _seconds) public { + vm.warp(vm.getBlockTimestamp() + _seconds); + } + + function _setRewardAndFund() public { + vm.prank(CONTRACT_OWNER); + rewardsDistributor.setRewardConfiguration( + address(staking), + REWARD_RATE + ); + + uint256 poolSize = 10_000_000e18; + deal(STAKING_TOKEN, CONTRACT_OWNER, poolSize); + + vm.prank(CONTRACT_OWNER); + IERC20(STAKING_TOKEN).transfer(address(rewardsDistributor), poolSize); + } + + function _calculateReturnOverPrincipal( + uint256 _rewardsReceived, + uint256 _staked, + uint256 _days + ) internal pure returns (uint256) { + // using scalar math + uint256 SCALAR = 1e18; + + uint256 aprScalar = ((_rewardsReceived * SCALAR) * 365 days * 100) / + (_staked * _days); + + return aprScalar; + } + + function testFork_DeployStakingContracts() public view { + assertEq(staking.owner(), CONTRACT_OWNER); + assertEq(address(staking.stakingToken()), STAKING_TOKEN); + assertEq( + address(staking.rewardsDistributor()), + address(rewardsDistributor) + ); + assertEq(staking.lockPeriod(), LOCK_PERIOD); + assertEq(staking.minStake(), MIN_STAKE); + + assertEq(rewardsDistributor.owner(), CONTRACT_OWNER); + assertEq(address(rewardsDistributor.rewardToken()), STAKING_TOKEN); + } + + function testFork_SetRewardConfiguration() public { + vm.prank(CONTRACT_OWNER); + rewardsDistributor.setRewardConfiguration( + address(staking), + REWARD_RATE + ); + + (uint256 emissionRate, uint256 lastUpdate) = rewardsDistributor + .rewardConfigurations(address(staking)); + + assertEq(emissionRate, REWARD_RATE); + assertEq(lastUpdate, block.timestamp); + } + + function testFork_25PercentParticipationRateGives20PercentAPR() public { + _setRewardAndFund(); + + uint256 staked = (CIRCULATION_SUPPLY * 25) / 100; + + deal(STAKING_TOKEN, address(this), staked); + + vm.prank(CONTRACT_OWNER); + staking.setKeyper(address(this), true); + + IERC20(STAKING_TOKEN).approve(address(staking), staked); + staking.stake(staked); + + uint256 jump = 86 days; + + _jumpAhead(jump); + + uint256 rewardsReceived = staking.claimRewards(0); + + uint256 APR = _calculateReturnOverPrincipal( + rewardsReceived, + staked, + jump + ); + + // 1% error margin + assertApproxEqAbs(APR, 21e18, 1e18); + } + + function testForkFuzz_MultipleDepositorsStakeMinAmountDifferentTimestamp( + uint256 _jump + ) public { + uint256 depositorsCount = 400; + + _setRewardAndFund(); + + _jump = bound(_jump, 1 minutes, 12 hours); + + uint256[] memory timeStaked = new uint256[](depositorsCount); + uint256 previousDepositorShares; + + for (uint256 i = 1; i <= depositorsCount; i++) { + address participant = address(uint160(i)); + + deal(STAKING_TOKEN, participant, MIN_STAKE); + + vm.prank(CONTRACT_OWNER); + staking.setKeyper(participant, true); + + vm.startPrank(participant); + IERC20(STAKING_TOKEN).approve(address(staking), MIN_STAKE); + staking.stake(MIN_STAKE); + vm.stopPrank(); + + uint256 shares = staking.balanceOf(participant); + if (i > 1) { + assertGt(previousDepositorShares, shares); + } + previousDepositorShares = shares; + + timeStaked[i - 1] = vm.getBlockTimestamp(); + + _jumpAhead(_jump); + } + + uint256 previousRewardsReceived; + + for (uint256 i = 1; i <= depositorsCount; i++) { + address participant = address(uint160(i)); + + uint256 expectedTimestamp = timeStaked[i - 1] + 365 days; + // jump the diferrence between expected and actual time + _jumpAhead(expectedTimestamp - vm.getBlockTimestamp()); + + vm.startPrank(participant); + uint256 rewardsReceived = staking.claimRewards(0); + + vm.stopPrank(); + + if (i > 1) { + assertGt(rewardsReceived, previousRewardsReceived); + } + + uint256 assetsAfter = staking.convertToAssets( + staking.balanceOf(participant) + ); + assertApproxEqAbs(assetsAfter, MIN_STAKE, 1e18); + } + } + + function testFork_ClaimRewardsAtTheEndOfSemester() public { + _setRewardAndFund(); + + uint256 staked = (CIRCULATION_SUPPLY * 25) / 100; + + deal(STAKING_TOKEN, address(this), staked); + + vm.prank(CONTRACT_OWNER); + staking.setKeyper(address(this), true); + + IERC20(STAKING_TOKEN).approve(address(staking), staked); + staking.stake(staked); + + uint256 jump = 86 days; + + _jumpAhead(jump); + + vm.prank(CONTRACT_OWNER); + staking.setKeyper(address(1), true); + + uint256 rewardsReceived = staking.claimRewards(0); + + uint256 APR = _calculateReturnOverPrincipal( + rewardsReceived, + staked, + jump + ); + + // 1% error margin + assertApproxEqAbs(APR, 21e18, 1e18); + } + + function testFork_ClaimRewardsEveryDayAndReestakeUntilEndSemester() public { + _setRewardAndFund(); + + uint256 staked = (CIRCULATION_SUPPLY * 25) / 100; + + deal(STAKING_TOKEN, address(this), staked); + + vm.prank(CONTRACT_OWNER); + staking.setKeyper(address(this), true); + + IERC20(STAKING_TOKEN).approve(address(staking), staked); + staking.stake(staked); + + uint256 previousTimestamp = vm.getBlockTimestamp(); + + for (uint256 i = 1; i < 2064; i++) { + _jumpAhead(1 hours); + + previousTimestamp = vm.getBlockTimestamp(); + uint256 rewardsReceived = staking.claimRewards(0); + + IERC20(STAKING_TOKEN).approve(address(staking), rewardsReceived); + staking.stake(rewardsReceived); + } + + _jumpAhead(1 hours); + + uint256 assets = staking.convertToAssets( + staking.balanceOf(address(this)) + ); + + uint256 APR = _calculateReturnOverPrincipal( + assets - staked, + staked, + 86 days + ); + + 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); + } + } +} diff --git a/test/Staking.t.sol b/test/Staking.t.sol index 5da3c4b..68a6190 100644 --- a/test/Staking.t.sol +++ b/test/Staking.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.26; import "@forge-std/Test.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {FixedPointMathLib} from "@solmate/utils/FixedPointMathLib.sol"; import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; @@ -138,6 +139,8 @@ contract StakingTest is Test { contract Initializer is StakingTest { function test_Initialize() public view { + assertEq(IERC20Metadata(address(staking)).name(), "Staked SHU"); + assertEq(IERC20Metadata(address(staking)).symbol(), "sSHU"); assertEq(staking.owner(), address(this), "Wrong owner"); assertEq( address(staking.stakingToken()), @@ -1270,13 +1273,8 @@ contract Unstake is StakingTest { staking.unstake(_depositor, stakeId, 0); } - function testFuzz_RevertIf_UnstakeResultsInBalanceLowerThanMinStaked( - address _depositor - ) public { - vm.assume( - _depositor != address(0) && - _depositor != ProxyUtils.getAdminAddress(address(staking)) - ); + function test_RevertIf_UnstakeResultsInBalanceLowerThanMinStaked() public { + address depositor = address(uint160(123)); // create multiple users staking to make the rewards amount accumulated // for _depositor not greater enough to withdraw the min stake @@ -1284,21 +1282,27 @@ contract Unstake is StakingTest { address user = address( uint160(uint256(keccak256(abi.encodePacked(i)))) ); - _mintGovToken(user, MIN_STAKE); + govToken.mint(user, MIN_STAKE); _setKeyper(user, true); - _stake(user, MIN_STAKE); + vm.startPrank(user); + govToken.approve(address(staking), MIN_STAKE); + staking.stake(MIN_STAKE); + vm.stopPrank(); } - _mintGovToken(_depositor, MIN_STAKE); - _setKeyper(_depositor, true); + _setKeyper(depositor, true); - uint256 stakeId = _stake(_depositor, MIN_STAKE); + vm.startPrank(depositor); + govToken.mint(depositor, MIN_STAKE); + govToken.approve(address(staking), MIN_STAKE); + uint256 stakeId = staking.stake(MIN_STAKE); + vm.stopPrank(); _jumpAhead(vm.getBlockTimestamp() + LOCK_PERIOD); - vm.prank(_depositor); + vm.prank(depositor); vm.expectRevert(Staking.WithdrawAmountTooHigh.selector); - staking.unstake(_depositor, stakeId, MIN_STAKE); + staking.unstake(depositor, stakeId, MIN_STAKE); } function testFuzz_RevertIf_StakeDoesNotBelongToKeyper( @@ -1463,14 +1467,10 @@ contract OwnableFunctions is StakingTest { "Wrong balance" ); - // get keyper stake ids - uint256[] memory keyperStakeIds = staking.getKeyperStakeIds(_keyper); assertEq(keyperStakeIds.length, 0, "Wrong stake ids"); } - // TEST CASES FOR NON OWNERS - function testFuzz_RevertIf_NonOwnerSetRewardsDistributor( address _newRewardsDistributor, address _nonOwner @@ -1483,7 +1483,8 @@ contract OwnableFunctions is StakingTest { vm.assume( _nonOwner != address(0) && - _nonOwner != ProxyUtils.getAdminAddress(address(staking)) + _nonOwner != ProxyUtils.getAdminAddress(address(staking)) && + _nonOwner != address(this) ); vm.prank(_nonOwner); @@ -1502,7 +1503,8 @@ contract OwnableFunctions is StakingTest { ) public { vm.assume( _nonOwner != address(0) && - _nonOwner != ProxyUtils.getAdminAddress(address(staking)) + _nonOwner != ProxyUtils.getAdminAddress(address(staking)) && + _nonOwner != address(this) ); vm.prank(_nonOwner); @@ -1521,7 +1523,8 @@ contract OwnableFunctions is StakingTest { ) public { vm.assume( _nonOwner != address(0) && - _nonOwner != ProxyUtils.getAdminAddress(address(staking)) + _nonOwner != ProxyUtils.getAdminAddress(address(staking)) && + _nonOwner != address(this) ); vm.prank(_nonOwner); @@ -1541,7 +1544,8 @@ contract OwnableFunctions is StakingTest { ) public { vm.assume( _nonOwner != address(0) && - _nonOwner != ProxyUtils.getAdminAddress(address(staking)) + _nonOwner != ProxyUtils.getAdminAddress(address(staking)) && + _nonOwner != address(this) ); vm.prank(_nonOwner); @@ -1561,7 +1565,8 @@ contract OwnableFunctions is StakingTest { ) public { vm.assume( _nonOwner != address(0) && - _nonOwner != ProxyUtils.getAdminAddress(address(staking)) + _nonOwner != ProxyUtils.getAdminAddress(address(staking)) && + _nonOwner != address(this) ); vm.prank(_nonOwner);