Core Contracts

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

Missing Boost Balance and other parameters Update in veRAACToken Functions. Incomplete Boost State Updates Result in Inaccurate Voting Power and Reward Distribution

Summary

In the veRAACToken contract, several functions are designed to manage the locking, unlocking, increasing, and extending of RAAC tokens, as well as their lock durations. The protocol utilizes a dual-gauge system for rewards distribution, where user rewards are determined based on both their boost factors and internally recorded boost balances.

However, there is a critical issue in how these functions update boost balances. Specifically, the functions lock, increase, extend, withdraw, and emergencyWithdraw do not update the users’ boost balances appropriately. Notably, the extend function fails to call _updateBoostState, and while the lock and increase functions do call _updateBoostState, that function itself does not invoke _boostState.updateUserBalance. Moreover, the current implementation passes the locked RAAC token amount (locks[msg.sender].amount) to update the boost state, even though the boost state should instead be updated using the newly calculated voting power (newPower), which accounts for time-weighted decay. This discrepancy can lead to significant reward manipulation, where users may not receive the rewards they are entitled to, thereby destabilizing the incentive structure and eroding trust in the protocol.

Vulnerability Details

  1. Boost State Update Shortcomings:

    • The _updateBoostState function is designed to update global boost parameters such as overall voting power, total voting power, and total weight based on locked tokens. It also updates the boost period by calling _boostState.updateBoostPeriod().

    • Missing Call: The function does not invoke _boostState.updateUserBalance(user, newAmount), which is necessary for updating user-specific boost balances.

    • Incorrect Parameter: Additionally, the function is called with locks[msg.sender].amount (the raw locked RAAC token amount) rather than the calculated voting power (newPower) that reflects the current time-weighted state. As a result, the boost state does not correctly represent the actual voting power of the user, leading to inaccurate reward and governance weight calculations.

    function _updateBoostState(address user, uint256 newAmount) internal {
    // Update boost calculator state
    _boostState.votingPower = _votingState.calculatePowerAtTimestamp(user, block.timestamp);
    _boostState.totalVotingPower = totalSupply();
    _boostState.totalWeight = _lockState.totalLocked;
    _boostState.updateBoostPeriod();
    @> // @info: doesn't call _boostState.updateUserBalance(user, newAmount);
    }
  2. Function-Specific Issues:

    • lock Function:
      After creating a lock via _lockState.createLock(msg.sender, amount), the function calls _updateBoostState(msg.sender, amount). Since this call does not forward the new voting power and omits the necessary user balance update in the boost state, the user's boost balance remains outdated.

    function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
    //...
    //...
    //...
    // Create lock position
    _lockState.createLock(msg.sender, amount, duration);
    @> _updateBoostState(msg.sender, amount);
    //...
    //...
    //...
    emit LockCreated(msg.sender, amount, unlockTime);
    }
    • increase Function:
      When a user increases their locked amount using _lockState.increaseLock(msg.sender, amount), _updateBoostState is invoked with locks[msg.sender].amount rather than the new calculated voting power. This misrepresentation leads to an inaccurately low boost factor, adversely affecting both reward allocations and governance influence.

    function increase(uint256 amount) external nonReentrant whenNotPaused {
    _lockState.increaseLock(msg.sender, amount);
    @> _updateBoostState(msg.sender, locks[msg.sender].amount);
    //...
    //...
    //...
    emit LockIncreased(msg.sender, amount);
    }
    • extend Function:
      The extend function does not call _updateBoostState at all. Instead, it manually updates voting power by recalculating and then minting or burning tokens accordingly. This omission means that changes in lock duration do not reflect in the global boost state, leaving user-specific boost balances stale.

      function extend(uint256 newDuration) external nonReentrant whenNotPaused {
      // Extend lock using LockManager
      uint256 newUnlockTime = _lockState.extendLock(msg.sender, newDuration);
      // Update voting power
      LockManager.Lock memory userLock = _lockState.locks[msg.sender];
      (int128 newBias, int128 newSlope) =
      _votingState.calculateAndUpdatePower(msg.sender, userLock.amount, newUnlockTime);
      // Update checkpoints
      uint256 oldPower = balanceOf(msg.sender);
      uint256 newPower = uint256(uint128(newBias));
      _checkpointState.writeCheckpoint(msg.sender, newPower);
      @> // @info: doesn't call the \_updateBoostState function to update boost factors.
      // Update veToken balance
      if (newPower > oldPower) {
      _mint(msg.sender, newPower - oldPower);
      } else if (newPower < oldPower) {
      _burn(msg.sender, oldPower - newPower);
      }
      emit LockExtended(msg.sender, newUnlockTime);
      }
  3. Potential Exploitation:

    • Reward Manipulation:
      Inaccurate boost balances can be exploited by malicious actors to either underclaim or overclaim rewards. By carefully timing lock operations and relying on stale boost state data, attackers could skew the reward distribution mechanism in their favor.

    • Governance Discrepancies:
      Since governance weight is tied to the voting power (which is derived from boost factors), any inaccuracy in boost state updates may result in misrepresentation of voting power. This can lead to governance decisions being influenced by parties whose actual stake is lower than what is recorded.

Proof of Concept

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 {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
contract VeRAACTokenTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
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");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp function.

function testUpdateBoostStateDoesNotUpdateUsersBoostBalanceAndOtherParameters() public {
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(ALICE, LOCK_AMOUNT);
vm.stopPrank();
uint256 veRaacRaacTokenBalance = raacToken.balanceOf(address(veRaacToken));
// Step 1: Alice approves and locks tokens.
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT / 2, LOCK_DURATION);
vm.stopPrank();
veRaacRaacTokenBalance = raacToken.balanceOf(address(veRaacToken));
vm.warp(block.timestamp + 1 days);
// Step 2: ALICE increases their lock to update internal state (including checkpoints and boost state).
vm.startPrank(ALICE);
veRaacToken.increase(LOCK_AMOUNT / 2);
vm.stopPrank();
TimeWeightedAverage.Period memory aliceBoostTimePeriod = veRaacToken.getUserBoostTimePeriods(ALICE);
console.log("Alice's data persists even after withdrawal...");
console.log("startTime: ", aliceBoostTimePeriod.startTime);
console.log("endTime: ", aliceBoostTimePeriod.endTime);
console.log("lastUpdateTime: ", aliceBoostTimePeriod.lastUpdateTime);
console.log("value: ", aliceBoostTimePeriod.value);
console.log("weightedSum: ", aliceBoostTimePeriod.weightedSum);
console.log("totalDuration: ", aliceBoostTimePeriod.totalDuration);
console.log("weight: ", aliceBoostTimePeriod.weight);
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testUpdateBoostStateDoesNotUpdateUsersBoostBalanceAndOtherParameters -vv
  1. Step 7: Review the output. The expected output should indicate that users internal balance and users specific parameters or members haven't updated.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testUpdateBoostStateDoesNotUpdateUsersBoostBalanceAndOtherParameters() (gas: 711963)
Logs:
Alice's data persists even after withdrawal...
startTime: 0
endTime: 0
lastUpdateTime: 0
value: 0
weightedSum: 0
totalDuration: 0
weight: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.99ms (829.20µs CPU time)
Ran 1 test suite in 12.82ms (3.99ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that the users boost internal balance and other specific parameters or members haven't updated.

Notice: Some modifications have been made to let you show the issue.

veRAACToken::getUserBoostTimePeriods: (newly introduced getter for ease)

// * @dev temporary not exist in actual
function getUserBoostTimePeriods(address user) external view returns (TimeWeightedAverage.Period memory) {
return _boostState.userPeriods[user];
}

Impact

  • Distorted Voting Power Calculations:
    The failure to update user-specific boost balances accurately leads to an inaccurate representation of voting power. Since boost factors are a key determinant in voting weight, any miscalculation undermines the integrity of governance outcomes.

  • Inconsistent Reward Distribution:
    Rewards based on boost factors will be misallocated because the internal state does not reflect the true, updated voting power. Users may receive lower rewards than they are entitled to, or malicious actors might exploit this discrepancy to skew the reward system.

  • Global Parameter Inconsistencies:
    The omission of proper boost state updates creates discrepancies in the overall governance system. Historical and real-time data used for governance decision-making become unreliable, making audits and performance tracking challenging.

  • Increased Vulnerability to Exploitation:
    Attackers can exploit stale or inaccurate boost data to manipulate governance proposals and reward distribution. This manipulation can lead to governance takeovers or biased proposals that favor certain participants over others.

  • Erosion of User Trust:
    Persistent inaccuracies in voting power and reward distribution will erode user confidence in the protocol, potentially reducing long-term engagement and participation in governance.

Tools Used

  • Manual Review

  • Foundry

  • Console Log (foundry)

  • SRC Mutation

Recommendations

  • Modify the _updateBoostState function to include a call to _boostState.updateUserBalance(user, newAmount) so that user-specific boost balances are accurately updated whenever a lock is created or increased.

  • In the extend function, add a call to _updateBoostState after extending the lock and calculating new voting power. This will ensure that changes to the lock duration correctly reflect in the global boost state and user boost balances.

One possible solution is as follows:

veRAACToken::_updateBoostState:

function _updateBoostState(address user, uint256 newAmount) internal {
// Update boost calculator state
_boostState.votingPower = _votingState.calculatePowerAtTimestamp(user, block.timestamp);
_boostState.totalVotingPower = totalSupply();
_boostState.totalWeight = _lockState.totalLocked;
_boostState.updateBoostPeriod();
+ _boostState.updateUserBalance(user, newAmount);
}

veRAACToken::extend:

function extend(uint256 newDuration) external nonReentrant whenNotPaused {
// Extend lock using LockManager
uint256 newUnlockTime = _lockState.extendLock(msg.sender, newDuration);
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
(int128 newBias, int128 newSlope) =
_votingState.calculateAndUpdatePower(msg.sender, userLock.amount, newUnlockTime);
// Update checkpoints
uint256 oldPower = balanceOf(msg.sender);
uint256 newPower = uint256(uint128(newBias));
+ _updateBoostState(msg.sender, newPower);
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Update veToken balance
if (newPower > oldPower) {
_mint(msg.sender, newPower - oldPower);
} else if (newPower < oldPower) {
_burn(msg.sender, oldPower - newPower);
}
emit LockExtended(msg.sender, newUnlockTime);
}

veRAACToken::lock:

function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount > MAX_LOCK_AMOUNT) revert AmountExceedsLimit();
if (totalSupply() + amount > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
if (duration < MIN_LOCK_DURATION || duration > MAX_LOCK_DURATION) {
revert InvalidLockDuration();
}
// Do the transfer first - this will revert with ERC20InsufficientBalance if user doesn't have enough tokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);
// Calculate unlock time
uint256 unlockTime = block.timestamp + duration;
// Create lock position
_lockState.createLock(msg.sender, amount, duration);
- _updateBoostState(msg.sender, amount);
// Calculate initial voting power
(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, amount, unlockTime);
// Update checkpoints
uint256 newPower = uint256(uint128(bias));
+ _updateBoostState(msg.sender, newPower);
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}

veRAACToken::increase:

function increase(uint256 amount) external nonReentrant whenNotPaused {
// Increase lock using LockManager
_lockState.increaseLock(msg.sender, amount);
- _updateBoostState(msg.sender, locks[msg.sender].amount);
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
// @info: adding amount in already updated locked amount
(int128 newBias, int128 newSlope) =
_votingState.calculateAndUpdatePower(msg.sender, userLock.amount + amount, userLock.end);
// Update checkpoints
uint256 newPower = uint256(uint128(newBias));
+ _updateBoostState(msg.sender, newPower);
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Transfer additional tokens and mint veTokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
emit LockIncreased(msg.sender, amount);
}
Updates

Lead Judging Commences

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

veRAACToken::_updateBoostState should be called later inside lock/increase

veRAACToken::_updateBoostState not called in extend/withdraw

Support

FAQs

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