Summary
The reward calculation mechanism in BaseGauge uses inconsistent bases, leading to inaccurate reward distribution for users.
Vulnerability Details
The reward calculation process relies on different bases, resulting in discrepancies in the computed rewards for users.
In the _updateReward() function, the contract updates the stored rewardPerToken and the user's accumulated rewards:
modifier updateReward(address account) {
_updateReward(account);
_;
}
function _updateReward(address account) internal {
@> rewardPerTokenStored = getRewardPerToken();
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);
}
}
The getRewardPerToken() function determines the per-token reward amount, using totalSupply() as the denominator:
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
@> (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
However, when calculating the actual reward for a user in the earned() function, the contract uses getUserWeight(account) as the basis for calculation:
function earned(address account) public view returns (uint256) {
@> return (getUserWeight(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
The getUserWeight(account) function determines the user's weighted staked amount, incorporating a boost factor:
function getUserWeight(address account) public view virtual returns (uint256) {
uint256 baseWeight = _getBaseWeight(account);
return _applyBoost(account, baseWeight);
}
function _applyBoost(address account, uint256 baseWeight) internal view virtual returns (uint256) {
if (baseWeight == 0) return 0;
IERC20 veToken = IERC20(IGaugeController(controller).veRAACToken());
uint256 veBalance = veToken.balanceOf(account);
uint256 totalVeSupply = veToken.totalSupply();
BoostCalculator.BoostParameters memory params = BoostCalculator.BoostParameters({
maxBoost: boostState.maxBoost,
minBoost: boostState.minBoost,
boostWindow: boostState.boostWindow,
totalWeight: boostState.totalWeight,
totalVotingPower: boostState.totalVotingPower,
votingPower: boostState.votingPower
});
uint256 boost = BoostCalculator.calculateBoost(
veBalance,
totalVeSupply,
params
);
return (baseWeight * boost) / 1e18;
}
Root Cause:
getRewardPerToken() determines per-token rewards using totalSupply().
earned(account) calculates rewards based on getUserWeight(account), which applies a boost mechanism and may not be proportional to totalSupply().
As a result, if the ratio of getUserWeight(account) to totalSupply() changes over time, the reward distribution will be incorrect.
Poc
Add the following test to test/unit/core/governance/gauges/RAACGauge.test.js and execute it:
describe("Reward calculation error", () => {
it("Poc", async () => {
await veRAACToken.connect(user1).approve(raacGauge.getAddress(), ethers.MaxUint256);
await raacGauge.connect(user1).stake(ethers.parseEther("100"));
expect(await raacGauge.balanceOf(user1.address)).to.be.eq(await raacGauge.totalSupply());
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
const user1BalanceStart = await rewardToken.balanceOf(user1.address);
await time.increase(WEEK);
await raacGauge.connect(user1).getReward();
const user1BalanceEnd = await rewardToken.balanceOf(user1.address);
console.log("Actual amount of reward received:",user1BalanceEnd - user1BalanceStart);
});
});
output:
RAACGauge
Reward calculation error
Actual amount of reward received: 189999999n
Impact
The mismatch between getUserWeight(account) and totalSupply() results in incorrect reward calculations. If the ratio of getUserWeight(account) to totalSupply() fluctuates, users may receive more or fewer rewards than expected, leading to unfair reward distribution.
Tools Used
Manual Review
Recommendations
Ensure that the reward calculation in earned(account) and getRewardPerToken() is based on the same unit of measurement. Either:
Modify getRewardPerToken() to incorporate the boosted weight mechanism.
Ensure that earned(account) considers totalSupply() as its basis for reward distribution.