MorpheusAI

MorpheusAI
Foundry
22,500 USDC
View results
Submission Details
Severity: high
Invalid

User can get less reward tokens due to griefer frontrunning

Summary

A greifing user can frontrun other users' claiming transactions to make the rewards to be claimed less than expected at a relative low cost.

Vulnerability Details

In Distrubition.sol, users can stake stETH to the protocol, and after a while, can claim reward in MOR tokens. The reward calculation is based on userData.rate and poolData.rate. However, pool rate is also determined by last time a pool is updated. In _stake, _withdraw, and claim functions, whenever those functions gets executed, poolData.lastUpdate will be updated. When this happens right before another user tries to claim rewards, the reward calculation will also be affected:

function _getCurrentUserReward(uint256 currentPoolRate_, UserData memory userData_) private pure returns (uint256) {
uint256 newRewards_ = ((currentPoolRate_ - userData_.rate) * userData_.deposited) / PRECISION;
return userData_.pendingRewards + newRewards_;
}
function _getCurrentPoolRate(uint256 poolId_) private view returns (uint256) {
PoolData storage poolData = poolsData[poolId_];
if (poolData.totalDeposited == 0) {
return poolData.rate;
}
uint256 rewards_ = getPeriodReward(poolId_, poolData.lastUpdate, uint128(block.timestamp));
return poolData.rate + (rewards_ * PRECISION) / poolData.totalDeposited;
}

In the above code, we see rate is related to getPeriodReward, and in this functinon:

function getPeriodReward(
uint256 initialAmount_,
uint256 decreaseAmount_,
uint128 payoutStart_,
uint128 interval_,
uint128 startTime_,
uint128 endTime_
) external pure returns (uint256) {
if (interval_ == 0) {
return 0;
}
// 'startTime_' can't be less than 'payoutStart_'
if (startTime_ < payoutStart_) {
startTime_ = payoutStart_;
}
uint128 maxEndTime_ = _calculateMaxEndTime(payoutStart_, interval_, initialAmount_, decreaseAmount_);
if (endTime_ > maxEndTime_) {
endTime_ = maxEndTime_;
}
// Return 0 when calculation 'startTime_' is bigger then 'endTime_'...
if (startTime_ >= endTime_) {
return 0;
}
// ... snip

We see that when startTime >= endTime, the reward is 0. When this happens, the currentPoolRate variable will be reduced to the least possible value, and when the user tries to claim rewards, the value will be less than expected.

Impact

As mentioned above, reward claimers will get less reward than expected. In a way, this can be considered as a feature, but the cost of griefing is too low. Imagine this, suppose the minimum stake value is 1 stETH (10 ** 18), and griefer staked exactly the value. Now, the griefer sees another user trying to claim rewards, the griefer stakes again, but with only 1 wei of seETH. Since the requirement of minimum staking value has already been satisfied with the previous stake, the current one will also go through. Claimer will get less rewards, and the lost reward will actually go to griefer at a cost of only 1 wei. The griefer can always withdraw all staked stETH whenever he/she gets tried of all of these griefing, making the cost almost none.

When modify the following part of Distribution.t.ts, we can see how much claim will lost:

it('should correctly calculate and withdraw rewards', async () => {
let userData;
const wallet = ethers.Wallet.createRandom();
const randomAddress = wallet.address;
await setNextTime(oneHour * 2);
await distribution.manageUsersInPrivatePool(poolId, [secondAddress, ownerAddress, randomAddress], [wei(1), wei(4), wei(1)]);
// Claim after 1 day
await setNextTime(oneDay + oneDay * 1);
await distribution.claim(poolId, randomAddress, { value: wei(0.5) });
await distribution.claim(poolId, SECOND, { value: wei(0.5) });
await distribution.claim(poolId, OWNER, { value: wei(0.5) });
expect(await depositToken.balanceOf(secondAddress)).to.eq(wei(1000));
expect(await rewardToken.balanceOf(secondAddress)).to.eq(wei(20));
userData = await distribution.usersData(secondAddress, poolId);
expect(userData.deposited).to.eq(wei(1));
expect(userData.pendingRewards).to.eq(0);
expect(await depositToken.balanceOf(ownerAddress)).to.eq(wei(1000));
expect(await rewardToken.balanceOf(ownerAddress)).to.closeTo(wei(80), wei(0.001));
userData = await distribution.usersData(ownerAddress, poolId);
expect(userData.deposited).to.eq(wei(4));
expect(userData.pendingRewards).to.eq(0);
// Withdraw after 2 days
await setNextTime(oneDay + oneDay * 2);
await distribution.manageUsersInPrivatePool(poolId, [secondAddress, ownerAddress], [wei(0), wei(0)]);
expect(await depositToken.balanceOf(secondAddress)).to.eq(wei(1000));
expect(await rewardToken.balanceOf(secondAddress)).to.closeTo(wei(20), wei(0.001));
userData = await distribution.usersData(secondAddress, poolId);
expect(userData.deposited).to.eq(wei(0));
expect(userData.pendingRewards).to.closeTo(wei(19.6), wei(0.001));
expect(await depositToken.balanceOf(ownerAddress)).to.eq(wei(1000));
expect(await rewardToken.balanceOf(ownerAddress)).to.closeTo(wei(80), wei(0.001));
userData = await distribution.usersData(ownerAddress, poolId);
expect(userData.deposited).to.eq(wei(0));
expect(userData.pendingRewards).to.closeTo(wei(78.4), wei(0.001));
await distribution.claim(poolId, SECOND, { value: wei(0.5) });
});

This modifed test simply added an new address to the private pool, and frontruns secondAddress with a claim call. The result after running this test:

1) Distribution
#changeWhitelistedUsers
should correctly calculate and withdraw rewards:
AssertionError: expected 16666855709876543209 to equal 20000000000000000000. The numerical values of the given "bigint" and "bigint" inputs were compared, and they differed.
+ expected - actual
-16666855709876543209
+20000000000000000000
at Context.<anonymous> (test/Distribution.test.ts:423:61)
at

We see if not being frontran, the user is supposed to get 20000000000000000000 MOR tokens minted, but actually gets 16666855709876543209 This is around 16.7% less than expected. Of course, the loss can vary due to the last time pool is updated, but can still cause significant loss of rewards.

Tools Used

Manual review, hardhat.

Recommendations

If such action is supposed to be a feature, make the cost of griefing other user's reward higher, such as adding a minimum stake value for each single stake call.

Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.