Summary
The rewardsUpdate function does not verify whether the caller has a veRAACToken balance. Instead, it simply returns minBoost, which could allow an attacker to claim rewards without staking or holding veRAACToken.
Vulnerability Details
When a user calls the getRewards function, the updateRewards modifier is executed first. The reward update process follows these steps:
Inside _updateReward, the contract retrieves rewardsPerToken and lastUpdateTime.
The earned function is called for the user to calculate the rewards.
This sequence ensures that the reward calculations are up to date before the user claims rewards.
/home/aman/Desktop/audits/BAAC-audit-cyfrin/contracts/core/governance/gauges/BaseGauge.sol:167
167: function _updateReward(address account) internal {
168: rewardPerTokenStored = getRewardPerToken();
169: lastUpdateTime = lastTimeRewardApplicable();
170:
171: if (account != address(0)) {
172: UserState storage state = userStates[account];
173: state.rewards = earned(account);
174: state.rewardPerTokenPaid = rewardPerTokenStored;
175: state.lastUpdateTime = block.timestamp;
176: emit RewardUpdated(account, state.rewards);
177: }
178: }
The earned function determines a user's reward amount by:
Calling getUserWeight to retrieve the user's weight.
Multiplying the weight by the percentage of rewards changed.
Adding the result to the user's current rewards balance.
This process ensures that rewards are distributed proportionally based on user weight.
/home/aman/Desktop/audits/BAAC-audit-cyfrin/contracts/core/governance/gauges/BaseGauge.sol:583
583: function earned(address account) public view returns (uint256) {
584: return (getUserWeight(account) *
585: (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
586: ) + userStates[account].rewards;
587: }
The getUserWeight function retrieves the weight of a gauge and applies the appropriate boost to the specified user.
/home/aman/Desktop/audits/BAAC-audit-cyfrin/contracts/core/governance/gauges/BaseGauge.sol:594
594: function getUserWeight(address account) public view virtual returns (uint256) {
595: uint256 baseWeight = _getBaseWeight(account);
596: return _applyBoost(account, baseWeight);
597: }
The _applyBoost function determines the user's boost based on:
The current total supply.
The user's individual balance.
/home/aman/Desktop/audits/BAAC-audit-cyfrin/contracts/core/governance/gauges/BaseGauge.sol:229
229: function _applyBoost(address account, uint256 baseWeight) internal view virtual returns (uint256) {
...
232: IERC20 veToken = IERC20(IGaugeController(controller).veRAACToken());
233: uint256 veBalance = veToken.balanceOf(account);
234: uint256 totalVeSupply = veToken.totalSupply();
235:
236:
237: BoostCalculator.BoostParameters memory params = BoostCalculator.BoostParameters({
238: maxBoost: boostState.maxBoost,
239: minBoost: boostState.minBoost,
240: boostWindow: boostState.boostWindow,
241: totalWeight: boostState.totalWeight,
242: totalVotingPower: boostState.totalVotingPower,
243: votingPower: boostState.votingPower
244: });
245:
246: uint256 boost = BoostCalculator.calculateBoost(
247: veBalance,
248: totalVeSupply,
249: params
250: );
251:
252: return (baseWeight * boost) / 1e18;
253: }
As shown in the calculateBoost function, it will always return minBoost, even if the user's balance is 0.
/home/aman/Desktop/audits/BAAC-audit-cyfrin/contracts/libraries/governance/BoostCalculator.sol:76
76: function calculateBoost(
77: uint256 veBalance,
78: uint256 totalVeSupply,
79: BoostParameters memory params
80: ) internal pure returns (uint256) {
...
86:
87: uint256 votingPowerRatio = (veBalance * 1e18) / totalVeSupply;
88:
89: uint256 boostRange = params.maxBoost - params.minBoost;
90: uint256 boost = params.minBoost + ((votingPowerRatio * boostRange) / 1e18);
91:
92:
93: if (boost < params.minBoost) {
94: return params.minBoost;
95: }
96: if (boost > params.maxBoost) {
97: return params.maxBoost;
98: }
...
101: }
All the details mentioned above lead to an issue where users can still claim rewards even when their balance is 0.
POC
The following POC will proof the attack vector , Please add it inside RAACGaufe.test.js file and run with command npx hardhat test:
it.only("User can claim rewards without staking and holding veRAACToken", async () => {
let userBalance = await veRAACToken.balanceOf(newUser.address);
expect(userBalance).to.eq(0);
await raacGauge.connect(newUser).getReward();
await time.increase(WEEK/2);
await raacGauge.connect(newUser).getReward();
const balance = await rewardToken.balanceOf(newUser.address);
console.log("balance" , balance)
expect(balance).to.be.gt(0);
});
Logs :
RAACGauge
Reward Distribution
balance 50000164n
Impact
An attacker can claim reward tokens without holding any veRAACToken, which reduces the claimable amount for legitimate users and allows the attacker to receive free rewards.
Tools Used
Manual Review
Recommendations
One potentail fix could be calculateBoost funciton check if user balance is 0 the return 0.
@@ -82,7 +82,9 @@ library BoostCalculator {
if (totalVeSupply == 0) {
return params.minBoost;
}
-
+ if (veBalance == 0) {
+ return 0;
+ }