Core Contracts

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

In `BoostController` contract `updateUserBoost` overrides Total Working Supply to the `newBoost`

Summary

n the updateUserBoost function, the pool’s working supply is updated by overwriting it with the boost value of the most recently updated user rather than accumulating (summing) the boosts of all users in the pool. This behavior is contrary to the intended Curve‑style boost mechanics described in the docs, where the working supply should reflect the aggregate effective boost of all users.

Vulnerability Details

In the BaseGauge contract, rewards are distributed proportionally based on each user’s effective “weight.” That effective weight is computed as follows:

  • The BaseGauge retrieves a user’s base weight using _getBaseWeight(account) (which typically reflects the staked amount or gauge weight).

  • It then applies a boost multiplier via _applyBoost(account, baseWeight).

  • In _applyBoost, boost parameters (like maxBoost, minBoost, and boostWindow) are obtained from the contract’s internal state. In particular, boost-related data such as the total weight, total voting power, and the user’s own voting power play a role in calculating the multiplier through a call to the BoostCalculator library.

function _applyBoost(address account, uint256 baseWeight) internal view virtual returns (uint256) {
if (baseWeight == 0) return 0;
IERC20 veToken = IERC20(IGaugeController(controller).veRAACToken());
uint256 veBalance = veToken.balanceOf(account);
uint256 totalVeSupply = veToken.totalSupply();
// Construct boost parameters from boostState
BoostCalculator.BoostParameters memory params = BoostCalculator.BoostParameters({
maxBoost: boostState.maxBoost,
minBoost: boostState.minBoost,
boostWindow: boostState.boostWindow,
totalWeight: boostState.totalWeight,
totalVotingPower: boostState.totalVotingPower,
votingPower: boostState.votingPower
});
uint256 boost = BoostCalculator.calculateBoost(
veBalance,
totalVeSupply,
params
);
return (baseWeight * boost) / 1e18;
}

The boost multiplier used in this calculation is expected to be influenced by the aggregate boost contributions of all users in the pool (i.e. the pool’s “working supply”). This aggregate is supposed to come from data updated via BoostController’s updateUserBoost function.

In the BoostController’s updateUserBoost function, the pool’s boost totals are updated in two parts:

if (newBoost >= oldBoost) {
poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
}
poolBoost.workingSupply = newBoost;//@audit instead of accumulating, workingSupply is simply overwritten
poolBoost.lastUpdateTime = block.timestamp;
  • The totalBoost is updated cumulatively—adjusting the previous total by the difference between the new and old boost of a given user.

  • In contrast, workingSupply is set to the newBoost value directly. This means that when a user (say, user2) updates their boost after another user (user1) has already updated theirs, the working supply becomes just user2’s boost.

  • The cumulative working supply (i.e. the sum of the boosted contributions of all users in the pool) is not maintained.

Impact

  • Underestimated Denominator:
    If the pool’s working supply is merely the last updated user’s boost, then the total working supply is underreported compared to the intended cumulative sum of all users’ boosts.

  • Disproportionate Rewards:
    The last user who updates their boost (or a malicious actor who deliberately updates last) will see the working supply set equal to their own boost. As a result, when calculating reward shares, that user’s weight will be divided by a smaller denominator, causing them to receive a much larger proportion of the rewards than their actual stake justifies.

  • Economic Exploitation:
    A malicious user could strategically wait until other users update their boosts, then call updateUserBoost to overwrite the pool’s working supply with only their boost. This manipulation would distort the reward calculations, enabling the attacker to capture a disproportionate share of the rewards, undermining fairness and potentially causing economic losses for honest participants.

Tools Used

Manual Review, Hardhat.

PoC

Add the test to "test/unit/core/governance/boost/BoostController.test.js", on the describe block `Boost Calculations`.

it("should overwrite workingSupply with the last updated user's boost, losing previous user's share", async () => {
// 1) user1 has 1000 veTokens, user2 has 2000 veTokens (already set in the beforeEach)
// 2) user1 updates their boost first
await boostController.connect(user1).updateUserBoost(user1.address, mockPool.getAddress());
// Check the resulting working balance for user1
const user1Boost = await boostController.getWorkingBalance(user1.address, mockPool.getAddress());
expect(user1Boost).to.be.gt(0); // should have some positive boost
// Check the pool's reported workingSupply after user1's update
let poolBoostInfo = await boostController.getPoolBoost(mockPool.getAddress());
// poolBoostInfo.workingSupply = total working supply for that pool
const workingSupplyAfterUser1 = poolBoostInfo.workingSupply;
expect(workingSupplyAfterUser1).to.equal(user1Boost);
// 3) Now user2 updates their boost second
await boostController.connect(user2).updateUserBoost(user2.address, mockPool.getAddress());
// Check user2's working balance
const user2Boost = await boostController.getWorkingBalance(user2.address, mockPool.getAddress());
expect(user2Boost).to.be.gt(user1Boost); // user2 has more veTokens, so a higher boost is likely
// 4) Check the pool's reported workingSupply again
poolBoostInfo = await boostController.getPoolBoost(mockPool.getAddress());
const workingSupplyAfterUser2 = poolBoostInfo.workingSupply;
// Because the code overwrites poolBoost.workingSupply = newBoost,
// the final working supply is now user2's alone, effectively losing user1's portion.
expect(workingSupplyAfterUser2).to.equal(user2Boost);
expect(workingSupplyAfterUser2).to.not.equal(user1Boost + user2Boost); // Not a sum—it's just overwritten
console.log("User1 Boost:", user1Boost.toString());
console.log("User2 Boost:", user2Boost.toString());
console.log("Working Supply after user1 =>", workingSupplyAfterUser1.toString());
console.log("Working Supply after user2 =>", workingSupplyAfterUser2.toString());
// This shows that user1’s prior boost is effectively erased from the pool’s workingSupply.
});

Logs:

User1 Boost: 14999
User2 Boost: 19999
Working Supply after user1 => 14999
Working Supply after user2 => 19999

Recommendations

One possible mitigation would be changing updateUserBoost function on BoostController to:

if (newBoost >= oldBoost) {
uint256 delta = newBoost - oldBoost;
poolBoost.totalBoost = poolBoost.totalBoost + delta;
poolBoost.workingSupply = poolBoost.workingSupply + delta; // Accumulate instead of overwrite
} else {
uint256 delta = oldBoost - newBoost;
poolBoost.totalBoost = poolBoost.totalBoost - delta;
// Ensure that subtraction does not underflow the workingSupply
require(poolBoost.workingSupply >= delta, "Underflow in workingSupply");
poolBoost.workingSupply = poolBoost.workingSupply - delta; // Subtract the difference
}
poolBoost.lastUpdateTime = block.timestamp;
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.