Core Contracts

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

Gauge stakers won't get any reward due to round-down in user weight calculation

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 // @audit incorrect precision
) + 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 1e14already.

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();
// 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; // @audit round-down to zero
}

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 * boostwill 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);
// alice stakes half of veToken to RAACGauge
raacGauge.stake(userAssetAmount / 2);
vm.stopPrank();
deal(address(rewardToken), address(raacGauge), 1_000_000e18);
gaugeController.distributeRewards(address(raacGauge));
vm.startPrank(alice);
skip(1 days);
// alice's weight is zero
assertEq(raacGauge.getUserWeight(alice), 0);
raacGauge.getReward();
vm.stopPrank();
// alice doesn't receive any reward
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:

diff --git a/contracts/core/governance/gauges/BaseGauge.sol b/contracts/core/governance/gauges/BaseGauge.sol
index 011974d..2c97052 100644
--- a/contracts/core/governance/gauges/BaseGauge.sol
+++ b/contracts/core/governance/gauges/BaseGauge.sol
@@ -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;
}
Updates

Lead Judging Commences

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

BaseGauge reward calculations divide by 1e18 despite using 1e4 precision weights, causing all user weights to round down to zero and preventing reward distribution

Support

FAQs

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