Summary
GaugeController has weight precision of 4, however BaseGauge calculates weight precision in 18. Due to this, user's earned amount will be zero at all times.
Vulnerability Details
Root Cause Analysis
The following is how user's earned amount is calculated in gauge:
function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
@> (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
User reward is divided by 1e18
but it's not for token decimals. Why? earned
function should return token amount in 18 decimals, so no need to divide by 1e18
Also if we don't divide by weight decimal, earned amount will be inflated by weight decimal. So 1e18
represents weight decimal here. But weight decimal is 1e4
, not 1e18
.
So user's earned amount is deflated by 1e14
already.
But this is not the most critical error.
If we take a look into how user weight is calculated:
function getUserWeight(address account) public view virtual returns (uint256) {
uint256 baseWeight = _getBaseWeight(account);
return _applyBoost(account, baseWeight);
}
A boost is applied to Gauge's weight in GaugeController.
And boost is calculated like the following:
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;
}
baseWeight
is RAACGauge's weight in GaugeController, which has 4 precision. Maximum value is 10000 because weight cannot exceed 100%
boost
is RAACGauge's current boost, which also has 4 precision. Maximum value is 25000 because max boost rate is 2.5x
Thus baseWeight * boost
will not exceed 2.5e8, so boosted weight will always return 0
POC
Scenario
alice has 10000 veToken
alice stakes 5000 veToken into RAACGauage
RAACGauge has 1M reward token and it is being distributed
1 day passes
alice's user weight is zero due to mentioned vulnerability
alice tries to get reward but they don't receive anything
How to run POC
pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.sol";
import {RAACGauge} from "../contracts/core/governance/gauges/RAACGauge.sol";
import {veRAACToken} from "../contracts/core/tokens/veRAACToken.sol";
import {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {MockToken} from "../contracts/mocks/core/tokens/MockToken.sol";
import {GaugeController} from "../contracts/core/governance/gauges/GaugeController.sol";
import {IGaugeController} from "../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
contract GaugeTest is Test {
RAACGauge raacGauge;
RAACToken raacToken;
MockToken veToken;
MockToken rewardToken;
GaugeController gaugeController;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 userAssetAmount = 10000e18;
function setUp() external {
raacToken = new RAACToken(address(this), 0, 0);
raacToken.setMinter(address(this));
veToken = new MockToken("veRAACToken", "veRAAC", 18);
raacToken.manageWhitelist(address(veToken), true);
rewardToken = new MockToken("Reward Token", "RWD", 18);
gaugeController = new GaugeController(address(veToken));
raacGauge = new RAACGauge(address(rewardToken), address(veToken), address(gaugeController));
raacGauge.grantRole(keccak256("CONTROLLER_ROLE"), address(this));
raacGauge.setBoostParameters(25000, 10000, 7 days);
gaugeController.addGauge(address(raacGauge), IGaugeController.GaugeType.RAAC, 10000);
}
function testStake() external {
veToken.mint(alice, userAssetAmount);
vm.startPrank(alice);
veToken.approve(address(raacGauge), userAssetAmount);
raacGauge.stake(userAssetAmount / 2);
vm.stopPrank();
deal(address(rewardToken), address(raacGauge), 1_000_000e18);
gaugeController.distributeRewards(address(raacGauge));
vm.startPrank(alice);
skip(1 days);
assertEq(raacGauge.getUserWeight(alice), 0);
raacGauge.getReward();
vm.stopPrank();
assertEq(rewardToken.balanceOf(alice), 0);
}
}
Impact
Since user's weight is always calculated as zero, gauge stakers won't receive any reward from it.
Tools Used
Foundry
Recommendations
Should be fixed like the following:
@@ -249,7 +249,7 @@ abstract contract BaseGauge is IGauge, ReentrancyGuard, AccessControl, Pausable
params
);
- return (baseWeight * boost) / 1e18;
+ return (baseWeight * boost) / 1e4;
}
// External functions
@@ -582,7 +582,7 @@ abstract contract BaseGauge is IGauge, ReentrancyGuard, AccessControl, Pausable
*/
function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
- (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
+ (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e4
) + userStates[account].rewards;
}