Core Contracts

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

Incorrect base weight implementation in `BaseGauge:_getBaseWeight` leads to wrong reward distribution

Summary

The BaseGauge::_getBaseWeight is designed to gets base weight for an account (as described in the natspec). But the _getBaseWeight function incorrectly uses the gauge's total weight instead of individual user's staked amount, affecting reward calculations. The natspect suggests that it is a Virtual function to be implemented by child contracts but the child contracts: RAACGauge and RWAGauge don't implement this function.

Vulnerability Details

/**
@> * @notice Gets base weight for an account
@> * @dev Virtual function to be implemented by child contracts
* @param account Address to get weight for
* @return Base weight value
*/
function _getBaseWeight(address account) internal view virtual returns (uint256) {
@> return IGaugeController(controller).getGaugeWeight(address(this));
}

Impact

All users in the gauge get rewards based on the gauge's total weight rather than their stake. In this way, a user with a tiny stake can receive the same rewards as a large staker (assuming the same boost). Users with small stakes get drastically overcompensated and large stakers get undercompensated. This breaks the principle of stake-based reward distribution. Additionally, this can drain reward tokens faster than intended.

Add Foundry to the project following this procedure

Create a file named RaacGauge.t.sol and copy/paste this:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Test, console2} from "forge-std/Test.sol";
import {RAACGauge} from "../contracts/core/governance/gauges/RAACGauge.sol";
import {GaugeController} from "../contracts/core/governance/gauges/GaugeController.sol";
import {IGaugeController} from "../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
import {IGauge} from "../contracts/interfaces/core/governance/gauges/IGauge.sol";
import {MockToken} from "../contracts/mocks/core/tokens/MockToken.sol";
contract RaacGaugeTest is Test {
MockToken public rewardToken;
MockToken public veRAACToken;
GaugeController public gaugeController;
RAACGauge public raacGauge;
bytes32 public constant CONTROLLER_ROLE = keccak256("CONTROLLER_ROLE");
bytes32 public constant EMERGENCY_ADMIN = keccak256("EMERGENCY_ADMIN");
bytes32 public constant FEE_ADMIN = keccak256("FEE_ADMIN");
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 public WEEK = 7 * 24 * 3600;
uint256 public WEIGHT_PRECISION = 10000;
function setUp() public {
rewardToken = new MockToken("Reward Token", "RWD", 18);
veRAACToken = new MockToken("veRAAC Token", "veRAAC", 18);
// Setup initial state
veRAACToken.mint(alice, 200 ether);
veRAACToken.mint(bob, 1000 ether);
rewardToken.mint(alice, 1000 ether);
rewardToken.mint(bob, 1000 ether);
gaugeController = new GaugeController(address(veRAACToken));
vm.warp(block.timestamp + 3 weeks);
raacGauge = new RAACGauge(address(rewardToken), address(veRAACToken), address(gaugeController));
// Setup roles
raacGauge.grantRole(raacGauge.CONTROLLER_ROLE(), owner);
vm.startPrank(alice);
rewardToken.approve(address(raacGauge), type(uint256).max);
veRAACToken.approve(address(raacGauge), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
rewardToken.approve(address(raacGauge), type(uint256).max);
veRAACToken.approve(address(raacGauge), type(uint256).max);
vm.stopPrank();
// Add gauge to controller
gaugeController.grantRole(gaugeController.GAUGE_ADMIN(), owner);
gaugeController.addGauge(address(raacGauge), IGaugeController.GaugeType.RAAC, WEIGHT_PRECISION);
// Move time forward
vm.warp(block.timestamp + 1 weeks);
// Set initial gauge weight through voting
vm.prank(alice);
gaugeController.vote(address(raacGauge), WEIGHT_PRECISION);
// Set emission rate
vm.prank(owner);
raacGauge.setWeeklyEmission(10000 ether);
// Transfer reward tokens to gauge for distribution
rewardToken.mint(address(raacGauge), 100000 ether);
vm.prank(owner);
raacGauge.setBoostParameters(25000, 10000, WEEK);
// Set initial weight after time alignment
vm.prank(address(gaugeController));
raacGauge.setInitialWeight(5000); // 50% weight
vm.warp(block.timestamp + 1);
console2.log("\nContracts:");
console2.log("rewardToken: ", address(rewardToken));
console2.log("veRAACToken: ", address(veRAACToken));
console2.log("raacGauge: ", address(raacGauge));
console2.log("");
}
function test_wrongEarnedRewardsCalculation() public {
// Alice stakes: 100 tokens
vm.prank(alice);
raacGauge.stake(100 ether);
//Bob stakes: 900 tokens
vm.prank(bob);
raacGauge.stake(900 ether);
uint256 aliceBalanceAfter = veRAACToken.balanceOf(alice);
uint256 bobBalanceAfter = veRAACToken.balanceOf(bob);
assertEq(aliceBalanceAfter, 100 ether); //same voting power
assertEq(bobBalanceAfter, 100 ether); //same voting power
vm.prank(address(gaugeController));
raacGauge.notifyRewardAmount(1000 ether);
vm.prank(alice);
raacGauge.voteEmissionDirection(5000);
vm.warp(block.timestamp + 1 weeks / 2);
// Get earned rewards
assertEq(raacGauge.getUserWeight(alice), raacGauge.getUserWeight(bob));
assertEq(raacGauge.earned(alice), raacGauge.earned(bob));
console2.log("AliceWeight: ", raacGauge.getUserWeight(alice));
console2.log("BobWeight: ", raacGauge.getUserWeight(bob));
console2.log("Alice earned: ", raacGauge.earned(alice));
console2.log("Bob earned: ", raacGauge.earned(bob));
console2.log("----------------");
}
}

Run forge test --match-test test_wrongEarnedRewardsCalculation -vv

Logs:
Contracts:
rewardToken: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
veRAACToken: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
raacGauge: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
AliceWeight: 2249800
BobWeight: 2249800
Alice earned: 1124899
Bob earned: 1124899
----------------
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.61ms (814.58µs CPU time)

The test shows that Alice and Bob earn the same rewards due same weight despite Bob stake being 9x higher than Alice.
Note: In the test, Alice and Bob have the same veRAACToken balance (100 ether each), therefore they get the same boost multiplier.

Tools Used

Manual review

Recommendations

function _getBaseWeight(address account) internal view virtual returns (uint256) {
- return IGaugeController(controller).getGaugeWeight(address(this));
+ return _balances[account];
}

Now add this test:

function test_wrongEarnedRewardsCalculationAfterRecommandation() public {
// Alice stakes: 100 tokens
vm.prank(alice);
raacGauge.stake(100 ether);
//Bob stakes: 900 tokens
vm.prank(bob);
raacGauge.stake(900 ether);
uint256 aliceBalanceAfter = veRAACToken.balanceOf(alice);
uint256 bobBalanceAfter = veRAACToken.balanceOf(bob);
assertEq(aliceBalanceAfter, 100 ether); //same voting power
assertEq(bobBalanceAfter, 100 ether); //same voting power
vm.prank(address(gaugeController));
raacGauge.notifyRewardAmount(1000 ether);
vm.prank(alice);
raacGauge.voteEmissionDirection(5000);
vm.warp(block.timestamp + 1 weeks / 2);
// Get earned rewards
assertLt(raacGauge.getUserWeight(alice), raacGauge.getUserWeight(bob));
assertLt(raacGauge.earned(alice), raacGauge.earned(bob));
console2.log("AliceWeight: ", raacGauge.getUserWeight(alice));
console2.log("BobWeight: ", raacGauge.getUserWeight(bob));
console2.log("Alice earned: ", raacGauge.earned(alice));
console2.log("Bob earned: ", raacGauge.earned(bob));
console2.log("----------------");
}

and run forge test --match-test test_wrongEarnedRewardsCalculationAfterRecommandation -vv

Logs:
Contracts:
rewardToken: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
veRAACToken: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
raacGauge: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
AliceWeight: 1124900
BobWeight: 10124100
Alice earned: 562449
Bob earned: 5062049
----------------

The test shows that Bob earns correctly 9x then Alice.

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.