Core Contracts

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

Incorrect Working Supply Update in `BoostController::updateUserBoost`

https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/governance/boost/BoostController.sol#L177-L203

Summary

The updateUserBoost function in the BoostController contract incorrectly updates the workingSupply of a pool by setting it directly to the new boost amount (newBoost) for a user. This overrides the existing workingSupply of the pool instead of incrementing it, leading to incorrect calculations of the pool's working supply. This issue is particularly problematic when multiple users interact with the pool, as the workingSupply will not reflect the cumulative boost contributions of all users.


Vulnerability Details

Explanation

The updateUserBoost function is designed to update a user's boost amount in a specific pool and adjust the pool's total boost and working supply accordingly. However, the function incorrectly sets the workingSupply to the new boost amount (newBoost) for the user, overriding the existing workingSupply of the pool. This leads to incorrect calculations of the pool's working supply, as it does not account for the contributions of other users.

Root Cause in the Contract Function

The issue lies in the following line of the updateUserBoost function:

function updateUserBoost(address user, address pool) external override nonReentrant whenNotPaused {
if (paused()) revert EmergencyPaused(); //@audit-info already using whenNotPaused modifier
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; // Set working supply directly to new boost
poolBoost.lastUpdateTime = block.timestamp;
emit BoostUpdated(user, pool, newBoost);
emit PoolBoostUpdated(pool, poolBoost.totalBoost, poolBoost.workingSupply);
}

Instead of incrementing the workingSupply by the difference between the new and old boost amounts, the function sets the workingSupply directly to the new boost amount. This results in the workingSupply being overwritten, leading to incorrect calculations.


Proof of Concept

Scenario Example

  1. User 1 Boosts: User 1 updates their boost, and the workingSupply is set to their boost amount (e.g., 5000), since they have voting power.

  2. User 2 Boosts: User 2 who has no voting power updates their boost, and the workingSupply is overwritten to the minimum boost amount (10000), ignoring User 1's contribution.

  3. Incorrect Working Supply: The workingSupply does not reflect the cumulative boost contributions of all users, leading to incorrect calculations.

Code

The vulnerability is demonstrated in the following Foundry test suite. Convert to foundry project using the steps highlighted here. Then in the test/ folder create a Test file named BoostControllerTest.t.sol and paste the test into it. Make sure the imports path are correct and run the test using forge test --mt testBoostUpdatesBug -vvv :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "contracts/core/governance/boost/BoostController.sol";
import "contracts/mocks/core/pools/MockPool.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
import "contracts/core/tokens/RAACToken.sol";
contract BoostControllerTest is Test {
BoostController public boostController;
veRAACToken public veRAAC;
RAACToken public raacToken;
MockPool public mockPool;
address owner;
address[] public users;
address manager;
bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
uint256 public constant INITIAL_MINT = 1000000 ether;
function setUp() public {
owner = address(this);
users.push(address(0x1));
users.push(address(0x2));
manager = address(0x3);
raacToken = new RAACToken(owner, 100, 50);
raacToken.setMinter(owner);
veRAAC = new veRAACToken(address(raacToken));
boostController = new BoostController(address(veRAAC));
mockPool = new MockPool();
raacToken.manageWhitelist(address(veRAAC), true);
// Setup initial token balances and approvals
for (uint256 i = 0; i < users.length; i++) {
raacToken.mint(users[i], INITIAL_MINT);
vm.prank(users[i]);
raacToken.approve(address(veRAAC), type(uint256).max);
}
boostController.grantRole(MANAGER_ROLE, manager);
vm.prank(manager);
boostController.modifySupportedPool(address(mockPool), true);
uint256 amount = 1000 ether;
uint256 duration = 365 days;
//USER 1 locks RAAC token and has veRAACToken
vm.prank(users[0]);
veRAAC.lock(amount, duration);
}
function testBoostUpdatesBug() public {
address user1 = users[0];
address user2 = users[1];
(uint256 totalBoost, uint256 workingSupply, uint256 baseSupply, uint256 lastUpdateTime) =
boostController.getPoolBoost(address(mockPool));
console.log("Initial Working Supply", workingSupply);
console.log("Initial Total Boost", totalBoost);
//USER 1 BOOST with veRAACToken power
vm.prank(user1);
boostController.updateUserBoost(user1, address(mockPool));
(uint256 totalBoost2, uint256 workingSupply2,,) = boostController.getPoolBoost(address(mockPool));
console.log("Working Supply After User1 Boost", workingSupply2);
console.log("Total Boost After User1 Boost", totalBoost2);
//USER 2 BOOST but has no veRAACToken power, therefore, he'll have the minimum boost of 10000
vm.prank(user2);
boostController.updateUserBoost(user2, address(mockPool));
(uint256 userBoostAmount,,,) = boostController.getUserBoost(user2, address(mockPool));
(uint256 totalBoost3, uint256 workingSupply3,,) = boostController.getPoolBoost(address(mockPool));
console.log("Final Working Supply After USER2 boost", workingSupply3);
console.log("Final Total Boost After USER2 boost", totalBoost3);
//Proves that working supply was set to boost amount of USER 2 with no veRAACToken
assertEq(workingSupply3, userBoostAmount);
assertGt(workingSupply2, workingSupply3);
assertGt(totalBoost3, workingSupply3);
}
}

In this test:

  • User 1 updates their boost, and the workingSupply is set to their boost amount.

  • User 2 updates their boost, and the workingSupply is overwritten to their boost amount, ignoring User 1's contribution.

  • The final workingSupply does not reflect the cumulative boost contributions of both users.

Bash Result

╰─ forge test --mt testBoostUpdates -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/BoostControllerTest.t.sol:BoostControllerTest
[PASS] testBoostUpdates() (gas: 252159)
Logs:
Initial Working Supply 0
Initial Total Boost 0
Working Supply After User1 Boost 25000
Total Boost After User1 Boost 25000
Final Working Supply After USER2 boost 10000
Final Total Boost After USER2 boost 35000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.59ms (480.30µs CPU time)
Ran 1 test suite in 10.85ms (1.59ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

  • Incorrect Working Supply: The workingSupply of the pool does not reflect the cumulative boost contributions of all users, leading to incorrect calculations breaking the protocol's boost mechanism .


Tools Used

  • Foundry: Used to write and execute the test suite that demonstrates the vulnerability.

  • Manual Review


Recommendations

  1. Increment Working Supply:

    • Update the updateUserBoost function to increment the workingSupply by the difference between the new and old boost amounts instead of setting it directly to the new boost amount.

function updateUserBoost(address user, address pool) external override nonReentrant whenNotPaused {
if (paused()) revert EmergencyPaused(); //@audit-info already using whenNotPaused modifier
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);
+ poolBoost.workingSupply = poolBoost.workingSupply + (newBoost - oldBoost); // Fix: Increment working supply
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
+ poolBoost.workingSupply = poolBoost.workingSupply - (oldBoost - newBoost); // Fix: Decrement working supply
}
- poolBoost.workingSupply = newBoost;
poolBoost.lastUpdateTime = block.timestamp;
emit BoostUpdated(user, pool, newBoost);
emit PoolBoostUpdated(pool, poolBoost.totalBoost, poolBoost.workingSupply);
}
Updates

Lead Judging Commences

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