Summary
The Base Guage has a staking section which allows users to stake and earn reward tokens but this is not correctly implemented and A user can take the reward earned by another user.
Vulnerability Details
The reward calculation does not save the Finish time of distribution hence users(especially the last user) who do not claim their rewards during the time period and wait for a second after finished time, will not be able to do so as this rewards will be stolen by another user
-
During reward notification
* @notice Notifies contract of reward amount
* @dev Updates reward rate based on new amount
* @param amount Amount of rewards to distribute
*/
function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
@audit>>. lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
LastUpdatetime is saved
Reward rate is calculated based on the duration 7 days. e.g DISTRIBUTE 7000 USD for 7 days. 1000 USD will be distributed per day.
function notifyReward(
PeriodState storage state,
uint256 amount,
uint256 maxEmission,
uint256 periodDuration
) internal view returns (uint256) {
if (amount > maxEmission) revert RewardCapExceeded();
if (amount + state.distributed > state.emission) {
revert RewardCapExceeded();
}
uint256 rewardRate = amount / periodDuration;
if (rewardRate == 0) revert ZeroRewardRate();
return rewardRate;
}
But the Finished at is based on the LASTUPDATETIME set during the reward notification
* @notice Gets end time of current period
* @return Period end timestamp
*/
function periodFinish() public view returns (uint256) {
return lastUpdateTime + getPeriodDuration();
}
But the LASTUPDATETIME is dynamic and keeps changing per reward distribution hence finished at keeps more allowing A user to take rewards even after reward distribution has ended.
* @notice Gets latest applicable reward time
* @return Latest of current time or period end
*/
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish() ? block.timestamp : periodFinish();
}
During every updatereward call, called during stake, getreward, checkpoint, withdraw.
* @notice Updates reward state for an account
* @dev Calculates and updates reward state including per-token rewards
* @param account Address to update rewards for
*/
function _updateReward(address account) internal {
rewardPerTokenStored = getRewardPerToken();
@audit>> lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
UserState storage state = userStates[account];
state.rewards = earned(account);
state.rewardPerTokenPaid = rewardPerTokenStored;
state.lastUpdateTime = block.timestamp;
emit RewardUpdated(account, state.rewards);
}
}
After day 1 Alice calls and gets her share of the 1000 USD per day, gets 100 USD.
At day 8 After the duration of distribution has ended,
Alice calls to get reward,
since lastupdate time keeps updating and returning block.timestamp ( block.timestamp< lastupdate+7days) lastupdate was day 1.
there fore at day 8 Alice is still able to withdraw another 100 USD.
Until rewards become zero Alice can keep withdrawing other users shares,
Note even on day 8 if everyone decide to get their rewards the last user's call will revert and alice can continue to make call to take this rewards till the rewards become lesser than they have accumulated.
* @notice Calculates current reward per token
* @return Current reward per token value
*/
@audit >> keeps changing>> function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
@audit >> return rewardPerTokenStored + (
@audit >> time keeps changing>> (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
* @notice Calculates earned rewards for account
* @param account Address to calculate earnings for
* @return Amount of rewards earned
*/
@audit >> function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
@audit >> depends on time >> (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
see synthetic implementation => https://github.com/Synthetixio/synthetix/blob/d7b5c386ffbd0a53a129cfbd8a683a6a3f62cc36/contracts/StakingRewards.sol#L129-L131.
function notifyRewardAmount(uint256 reward) external onlyRewardsDistribution updateReward(address(0)) {
if (block.timestamp >= periodFinish) {
rewardRate = reward.div(rewardsDuration);
} else {
uint256 remaining = periodFinish.sub(block.timestamp);
uint256 leftover = remaining.mul(rewardRate);
rewardRate = reward.add(leftover).div(rewardsDuration);
}
uint balance = rewardsToken.balanceOf(address(this));
require(rewardRate <= balance.div(rewardsDuration), "Provided reward too high");
@audit>> lastUpdateTime = block.timestamp;
@audit>> saved >> periodFinish = block.timestamp.add(rewardsDuration);
emit RewardAdded(reward);
}
Impact
A user can claim rewards over the 7 days allotted rewards that they would effectively be stealing another user's reward.
Tools Used
Manual Review
Recommendations
Saved start-time and only calculated Period finish-at based on a fixed timestamp during notification or use synthetics implementation.