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;
uint256 newBoost = _calculateBoost(user, pool, 10000);
userBoost.amount = newBoost;
userBoost.lastUpdateTime = block.timestamp;
if (newBoost >= oldBoost) {
poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
}
poolBoost.workingSupply = newBoost;
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 :
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 {
raacToken = new RAACToken(
owner,
100,
50
);
veToken = new veRAACToken(address(raacToken));
boostController = new BoostController(address(veToken));
boostController.modifySupportedPool(pool, true);
raacToken.setMinter(owner);
raacToken.mint(alice, 1000e18);
raacToken.mint(bob, 5000e18);
raacToken.manageWhitelist(alice, true);
raacToken.manageWhitelist(bob, true);
raacToken.manageWhitelist(address(veToken), true);
vm.startPrank(alice);
raacToken.approve(address(veToken), type(uint256).max);
veToken.lock(1000e18, 365 days);
vm.stopPrank();
vm.startPrank(bob);
raacToken.approve(address(veToken), type(uint256).max);
veToken.lock(5000e18, 365 days);
vm.stopPrank();
}
function testWorkingSupplyOverwrite() public {
console2.log("Initial state:");
console2.log("Alice veToken balance:", veToken.balanceOf(alice));
console2.log("Bob veToken balance:", veToken.balanceOf(bob));
vm.prank(bob);
boostController.updateUserBoost(bob, pool);
(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);
vm.prank(alice);
boostController.updateUserBoost(alice, pool);
(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);
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
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;
uint256 newBoost = _calculateBoost(user, pool, 10000);
userBoost.amount = newBoost;
userBoost.lastUpdateTime = block.timestamp;
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);
}