You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository has been archived by the owner on Mar 3, 2024. It is now read-only.
sherlock-admin opened this issue
Aug 30, 2023
· 0 comments
Labels
DuplicateA valid issue that is a duplicate of an issue with `Has Duplicates` labelHighA valid High severity issueRewardA payout will be made for this issue
MainRewarder staking rewards are diluted by new stakers resulting in less rewards for existing stakers
Summary
The LMPVault and DestinationVault contracts call the MainRewarder.stake function too late, after new shares have been issued. This dilutes the rewards for existing stakers and allows new stakers to immediately accrue and withdraw staking rewards.
Vulnerability Detail
Both the LMPVault and DestinationVault contracts have a MainRewarder rewarder instance associated with them. The MainRewarder contract does not keep track of staked tokens itself. Instead, it uses the associated vault tokens as a tracker. This is different from the forked Synthetix StakingRewards contract.
This leads to issues when calculating the rewards per token, as the calculation uses the total token supply of the tracked token. Retrieving the total token supply with the stakeTracker.totalSupply function returns the already increased supply, due to calling the MainRewarder.stake function within the ERC20._afterTokenTransfer function of both the LMPVault (line 863) and DestinationVault (line 351) contract.
The first time someone stakes, i.e., the MainRewarder.stake is called, internally, the state variables are updated within the AbstractRewarder._updateReward. The staker's rewards are also calculated and stored in rewards[account], as seen in line 135. The culprit is that the earned function already returns rewards for the staker, even though the staker just staked and no blocks have passed yet.
This issue is caused due to the balanceOf function already incorporating the staker's shares of the tracker contract and userRewardPerTokenPaid[account] being zero for the staker's first stake.
Consequently, the rewards per token are diluted and current stakers receive less rewards than anticipated. Specifically, a new staker (i.e., user depositing into LMPVault) immediately accrues rewards and can withdraw them, effectively stealing rewards from other stakers.
The following test case demonstrates this issue by having Bob stake at the end of the reward period, immediately receiving a huge chunk of rewards and diluting the rewards for the RANDOM user:
Test case (click to reveal)
diff --git a/v2-core-audit-2023-07-14/test/mocks/StakeTrackingMock.sol b/v2-core-audit-2023-07-14/test/mocks/StakeTrackingMock.sol
index 32a3d92..ee49209 100644
--- a/v2-core-audit-2023-07-14/test/mocks/StakeTrackingMock.sol+++ b/v2-core-audit-2023-07-14/test/mocks/StakeTrackingMock.sol@@ -3,13 +3,25 @@
pragma solidity 0.8.17;
import { IStakeTracking } from "src/interfaces/rewarders/IStakeTracking.sol";
+import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";-contract StakeTrackingMock is IStakeTracking {- function totalSupply() external pure returns (uint256) {- return 100_000_000_000_000_000;+contract StakeTrackingMock is ERC20, IStakeTracking {++ constructor() ERC20("StakeTrackingMock", "SMock") {}++ function mint(address account, uint256 amount) external {+ _mint(account, amount);+ }++ function burn(address account, uint256 amount) external {+ _burn(account, amount);+ }++ function totalSupply() public view override(ERC20, IStakeTracking) returns (uint256) {+ return super.totalSupply();
}
- function balanceOf(address) external pure returns (uint256) {- return 100_000_000_000_000_000;+ function balanceOf(address account) public view override(ERC20, IStakeTracking) returns (uint256) {+ return super.balanceOf(account);
}
}
diff --git a/v2-core-audit-2023-07-14/test/rewarders/RewardVault.t.sol b/v2-core-audit-2023-07-14/test/rewarders/RewardVault.t.sol
index 8373946..8b96047 100644
--- a/v2-core-audit-2023-07-14/test/rewarders/RewardVault.t.sol+++ b/v2-core-audit-2023-07-14/test/rewarders/RewardVault.t.sol@@ -41,7 +41,7 @@ contract MainRewarderTest is BaseTest {
ERC20Mock private extraReward1;
ERC20Mock private extraReward2;
- uint256 private amount = 100_000;+ uint256 private amount = 100e18;
uint256 private newRewardRatio = 800;
uint256 private durationInBlock = 100;
@@ -169,6 +169,34 @@ contract MainRewarderTest is BaseTest {
assertEq(extraReward2BalanceAfter - extraReward2BalanceBefore, amount);
}
+ function test_getAllRewards_exploit() public {+ stakeTracker.mint(RANDOM, amount);++ assertEq(mainRewardVault.currentRewards(), 100e18);++ vm.prank(address(stakeTracker));+ mainRewardVault.stake(RANDOM, amount);++ vm.roll(block.number + 100); // skip to the end of the reward period++ uint256 earned = mainRewardVault.earned(RANDOM);+ assertEq(earned, amount);++ address bob = vm.addr(3);+ vm.label(bob, "Bob");++ stakeTracker.mint(bob, 1_000e18); // Bob acquires a large stake in the `stakeTracker`++ vm.prank(address(stakeTracker));+ mainRewardVault.stake(bob, 1_000e18); // Bob stakes at the end of the reward period++ uint256 bobEarned = mainRewardVault.earned(bob);+ uint256 randomEarned = mainRewardVault.earned(RANDOM);++ assertApproxEqRel(bobEarned, 90e18, 0.02e18); // 2% error // @audit-info Bob receives immediately almost 90e18 staking rewards+ assertApproxEqRel(randomEarned, 10e18, 0.1e18); // 10% error // @audit-info Random got diluted by Bob+ }+
function test_toke_autoStakeRewards() public {
_runTokeStakingTest(30 days, 0, true);
}
How to run this test case:
Save git diff to a file named exploit-rewards.patch and run with
git apply exploit-rewards.patch
forge test -vv --match-test "test_getAllRewards_exploit"
Result:
Running 1 testfor test/rewarders/RewardVault.t.sol:MainRewarderTest
[PASS] test_getAllRewards_exploit() (gas: 421092)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.98s
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Stakers can immediately accrue and withdraw staking rewards, leading to diluted rewards for other stakers and stealing rewards.
totalSupply() incorporates the already minted shares of the tracked token (i.e., LMPVault or DestinationVault), diluting the rewards for existing stakers.
Consider calling the stake function of the MainRewarder function before the LMPVault and DestinationVault token total supply is increased, i.e., in the _beforeTokenTransfer function instead of the _afterTokenTransfer function.
sherlock-admin2
changed the title
Nice Maroon Frog - MainRewarder staking rewards are diluted by new stakers resulting in less rewards for existing stakers
berndartmueller - MainRewarder staking rewards are diluted by new stakers resulting in less rewards for existing stakers
Oct 3, 2023
Sign up for freeto subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Labels
DuplicateA valid issue that is a duplicate of an issue with `Has Duplicates` labelHighA valid High severity issueRewardA payout will be made for this issue
berndartmueller
high
MainRewarder
staking rewards are diluted by new stakers resulting in less rewards for existing stakersSummary
The
LMPVault
andDestinationVault
contracts call theMainRewarder.stake
function too late, after new shares have been issued. This dilutes the rewards for existing stakers and allows new stakers to immediately accrue and withdraw staking rewards.Vulnerability Detail
Both the
LMPVault
andDestinationVault
contracts have aMainRewarder
rewarder instance associated with them. TheMainRewarder
contract does not keep track of staked tokens itself. Instead, it uses the associated vault tokens as a tracker. This is different from the forked SynthetixStakingRewards
contract.This leads to issues when calculating the rewards per token, as the calculation uses the total token supply of the tracked token. Retrieving the total token supply with the
stakeTracker.totalSupply
function returns the already increased supply, due to calling theMainRewarder.stake
function within theERC20._afterTokenTransfer
function of both theLMPVault
(line 863) andDestinationVault
(line 351) contract.The first time someone stakes, i.e., the
MainRewarder.stake
is called, internally, the state variables are updated within theAbstractRewarder._updateReward
. The staker's rewards are also calculated and stored inrewards[account]
, as seen in line 135. The culprit is that theearned
function already returns rewards for the staker, even though the staker just staked and no blocks have passed yet.src/rewarders/AbstractRewarder.sol#L134-L136
This issue is caused due to the
balanceOf
function already incorporating the staker's shares of the tracker contract anduserRewardPerTokenPaid[account]
being zero for the staker's first stake.src/rewarders/AbstractRewarder.earned(..)
Consequently, the rewards per token are diluted and current stakers receive less rewards than anticipated. Specifically, a new staker (i.e., user depositing into
LMPVault
) immediately accrues rewards and can withdraw them, effectively stealing rewards from other stakers.The following test case demonstrates this issue by having Bob stake at the end of the reward period, immediately receiving a huge chunk of rewards and diluting the rewards for the
RANDOM
user:Test case (click to reveal)
How to run this test case:
Save git diff to a file named
exploit-rewards.patch
and run withResult:
Impact
Stakers can immediately accrue and withdraw staking rewards, leading to diluted rewards for other stakers and stealing rewards.
Code Snippet
src/rewarders/AbstractRewarder.rewardPerToken()
totalSupply()
incorporates the already minted shares of the tracked token (i.e.,LMPVault
orDestinationVault
), diluting the rewards for existing stakers.Tool used
Manual Review
Recommendation
Consider calling the
stake
function of theMainRewarder
function before theLMPVault
andDestinationVault
token total supply is increased, i.e., in the_beforeTokenTransfer
function instead of the_afterTokenTransfer
function.Duplicate of #603
The text was updated successfully, but these errors were encountered: