Core Contracts

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

Workingsupply would always be overwritten in boostcontroller.sol impacting reward calculations

Summary

In the BoostController.sol updateUserBoost() function, the working supply is incorrectly overwritten instead of being properly accumulated. This leads to a situation where the pool's working supply becomes less than the sum of individual user boosts, potentially impacting reward distributions and boost mechanics.

Vulnerability Details

The vulnerability exists in the updateUserBoost() function where the working supply is directly set to the new boost amount instead of being properly accumulated: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/boost/BoostController.sol#L177

/**
* @notice Updates the boost value for a user in a specific pool
* @param user Address of the user whose boost is being updated
* @param pool Address of the pool for which to update the boost
* @dev Calculates new boost based on current veToken balance and updates pool totals
*/
function updateUserBoost(address user, address pool) external override nonReentrant whenNotPaused {
if (paused()) revert EmergencyPaused();
if (user == address(0)) revert InvalidPool();
if (!supportedPools[pool]) revert PoolNotSupported();
UserBoost storage userBoost = userBoosts[user][pool];
PoolBoost storage poolBoost = poolBoosts[pool];
uint256 oldBoost = userBoost.amount;
// Calculate new boost based on current veToken balance
uint256 newBoost = _calculateBoost(user, pool, 10000);
userBoost.amount = newBoost;
userBoost.lastUpdateTime = block.timestamp;
// Update pool totals safely
if (newBoost >= oldBoost) {
poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
}
poolBoost.workingSupply = newBoost; // audit - Seting working supply directly to new boost. Vulnerable line
poolBoost.lastUpdateTime = block.timestamp;
emit BoostUpdated(user, pool, newBoost);
emit PoolBoostUpdated(pool, poolBoost.totalBoost, poolBoost.workingSupply);

The function flow:

  • User calls updateUserBoost()

  • Old boost amount is stored

  • New boost is calculated based on veToken balance

  • Total boost is correctly updated by adding/subtracting the difference

  • Working supply is incorrectly overwritten with just the new boost amount

When multiple users update their boosts:

  • First user (Bob) updates → working supply becomes Bob's boost amount

  • Second user (Alice) updates → working supply becomes Alice's boost amount

  • Bob's contribution is completely lost from working supply

This creates a "last writer wins" scenario where only the most recent updater's boost is reflected in the working supply.

Proof of Concept

Test Flow:

  • Initial setup with Alice having 1000 tokens and Bob having 5000 tokens

  • Bob updates his boost first

  • Alice updates her boost second

  • Working supply is verified to be less than sum of individual boosts

Key Observations:

  • After Bob's update: Working supply = Bob's boost amount

  • After Alice's update: Working supply = Alice's boost amount

  • Final working supply < (Bob's boost + Alice's boost)

Creaete a test file and add this code to it :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {BoostController} from "../../../../../contracts/core/governance/boost/BoostController.sol";
import {veRAACToken} from "../../../../../contracts/core/tokens/veRAACToken.sol";
import {RAACToken} from "../../../../../contracts/core/tokens/RAACToken.sol";
contract BoostControllerTest is Test {
BoostController public boostController;
veRAACToken public veToken;
RAACToken public raacToken;
address public alice = address(0x1);
address public bob = address(0x2);
address public pool = address(0x3);
address public owner = address(this);
function setUp() public {
// Deploy real RAAC token with initial parameters
raacToken = new RAACToken(
owner, // initial owner
100, // 1% swap tax
50 // 0.5% burn tax
);
// Deploy veToken
veToken = new veRAACToken(address(raacToken));
// Deploy BoostController
boostController = new BoostController(address(veToken));
// Setup roles and permissions
boostController.modifySupportedPool(pool, true);
raacToken.setMinter(owner);
// Mint tokens to users
raacToken.mint(alice, 1000e18);
raacToken.mint(bob, 5000e18);
// Whitelist users to avoid tax on transfers
raacToken.manageWhitelist(alice, true);
raacToken.manageWhitelist(bob, true);
raacToken.manageWhitelist(address(veToken), true);
// Setup users
vm.startPrank(alice);
raacToken.approve(address(veToken), type(uint256).max);
veToken.lock(1000e18, 365 days); // Lock for 1 year
vm.stopPrank();
vm.startPrank(bob);
raacToken.approve(address(veToken), type(uint256).max);
veToken.lock(5000e18, 365 days); // Lock for 1 year
vm.stopPrank();
}
function testWorkingSupplyOverwrite() public {
// Initial state
console2.log("Initial state:");
console2.log("Alice veToken balance:", veToken.balanceOf(alice));
console2.log("Bob veToken balance:", veToken.balanceOf(bob));
// Step 1: Bob updates his boost first
vm.prank(bob);
boostController.updateUserBoost(bob, pool);
// Get Bob's boost info
(uint256 bobBoostAmount, , , ) = boostController.getUserBoost(bob, pool);
(uint256 totalBoost, uint256 workingSupply, , ) = boostController.getPoolBoost(pool);
console2.log("\nAfter Bob's boost update:");
console2.log("Bob's boost amount:", bobBoostAmount);
console2.log("Pool total boost:", totalBoost);
console2.log("Pool working supply:", workingSupply);
// Step 2: Alice updates her boost
vm.prank(alice);
boostController.updateUserBoost(alice, pool);
// Get Alice's boost info and updated pool info
(uint256 aliceBoostAmount, , , ) = boostController.getUserBoost(alice, pool);
(totalBoost, workingSupply, , ) = boostController.getPoolBoost(pool);
console2.log("\nAfter Alice's boost update:");
console2.log("Alice's boost amount:", aliceBoostAmount);
console2.log("Bob's boost amount:", bobBoostAmount);
console2.log("Pool total boost:", totalBoost);
console2.log("Pool working supply:", workingSupply);
// Demonstrate the vulnerability
assertLt(
workingSupply,
bobBoostAmount + aliceBoostAmount,
"Working supply should be less than sum of boosts (overwritten)"
);
console2.log("\nVulnerability demonstrated:");
console2.log("Expected working supply:", bobBoostAmount + aliceBoostAmount);
console2.log("Actual working supply:", workingSupply);
console2.log("Missing boost amount:", bobBoostAmount + aliceBoostAmount - workingSupply);
}

Impact

This vulnerability has severe implications:

  • Economic Impact:

  • Incorrect reward calculations due to understated working supply

  • Users lose their boost effects when others update

  • Potential for manipulation of reward distributions

The working supply is used to calculate user rewards/shares in the pool

  • When overwritten, it understates the total working supply, leading to incorrect reward distributions

  • Only the last user's boost is considered in the working supply while total boost tracks correctly

Users get larger share of rewards than they should because workingSupply is understated

  • Last user to update gets disproportionate rewards since their boost becomes the entire workingSupply

  • Earlier users' boosts are effectively ignored in reward calculations

// From POC:
// Bob updates first - workingSupply = bobBoost
// Alice updates second - workingSupply = aliceBoost (Bob's boost lost)
// But totalBoost = bobBoost + aliceBoost (correct)
This means:
If totalRewards = 1000
Bob's boost = 400
Alice's boost = 600
Working supply = 600 (Alice's only)
Total boost = 1000 (correct)
Alice would get: (600 * 1000) / 600 = 1000 rewards
Instead of correct: (600 * 1000) / 1000 = 600 rewards
Attacker Would time their boost updates to be the last updater

Tools Used

Recommendations

similar to how totalboost is adjusted, do same for workingsupply.

/**
* @notice Updates the boost value for a user in a specific pool
* @param user Address of the user whose boost is being updated
* @param pool Address of the pool for which to update the boost
* @dev Calculates new boost based on current veToken balance and updates pool totals
*/
function updateUserBoost(address user, address pool) external override nonReentrant whenNotPaused {
if (paused()) revert EmergencyPaused();
if (user == address(0)) revert InvalidPool();
if (!supportedPools[pool]) revert PoolNotSupported();
UserBoost storage userBoost = userBoosts[user][pool];
PoolBoost storage poolBoost = poolBoosts[pool];
uint256 oldBoost = userBoost.amount;
// Calculate new boost based on current veToken balance
uint256 newBoost = _calculateBoost(user, pool, 10000); // Base amount //check here thoroughly audit;isue 7
userBoost.amount = newBoost;
userBoost.lastUpdateTime = block.timestamp;
// Update pool totals safely
if (newBoost >= oldBoost) {
poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
+ poolBoost.workingSupply = poolBoost.workingSupply + (newBoost - oldBoost);
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
+ poolBoost.workingSupply = poolBoost.workingSupply - (oldBoost - newBoost);
}
poolBoost.lastUpdateTime = block.timestamp;
emit BoostUpdated(user, pool, newBoost);
emit PoolBoostUpdated(pool, poolBoost.totalBoost, poolBoost.workingSupply);
}
Updates

Lead Judging Commences

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

BoostController::updateUserBoost overwrites workingSupply with single user's boost value instead of accumulating, breaking reward multipliers and allowing last updater to capture all benefits

Support

FAQs

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

Give us feedback!