Core Contracts

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

Improper Lock State Updates: Misreported Locked Token Data infects Governance Participation, rewards distribution and Harms Protocol Trust.

Summary

The veRAACToken contract includes functions that allow users to retrieve information about their locked tokens—namely, the locked amount, lock expiry, and whether a lock exists. In particular, the contract provides "raw" functions that return the locked token amount and lock expiry without any time-weighting. These functions, getLockedBalance and getLockedEndTime, are defined as external and view.

However, the state variable (a mapping) named locks, which is intended to store the locked token information, is never updated within the veRAACToken contract. As a result, whenever users call these functions, they receive only default values (for example, 0 for both the locked amount and the lock expiry).

The relevant declarations are as follows:

veRAACToken::locks

mapping(address => Lock) public locks;

veRAACToken::getLockedBalance

function getLockedBalance(address account) external view returns (uint256) {
return locks[account].amount;
}

veRAACToken::getLockEndTime

function getLockEndTime(address account) external view returns (uint256) {
return locks[account].end;
}

Vulnerability Details

There are several functions in which the locks variable should be updated, but it is not modified anywhere in the contract. These functions include:

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);
@> // @info: not updating locks mapping
_updateBoostState(msg.sender, amount);
// ...
}

veRAACToken::increase:

function increase(uint256 amount) external nonReentrant whenNotPaused {
// Increase lock using LockManager
@> _lockState.increaseLock(msg.sender, amount);
@> // @info: not updating locks mapping
@> _updateBoostState(msg.sender, locks[msg.sender].amount);
-----------------------------------------^
// ...
}

veRAACToken::extend:

function extend(uint256 newDuration) external nonReentrant whenNotPaused {
// Extend lock using LockManager
@> uint256 newUnlockTime = _lockState.extendLock(msg.sender, newDuration);
@> // @info: not updating locks mapping
// ...
}

veRAACToken::withdraw:

function withdraw() external nonReentrant {
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert LockNotFound();
if (block.timestamp < userLock.end) revert LockNotExpired();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
// Clear lock data
@> delete _lockState.locks[msg.sender];
@> // @info: not updating locks mapping
delete _votingState.points[msg.sender];
// ...
}

veRAACToken::emergencyWithdraw:

function emergencyWithdraw() external nonReentrant {
if (emergencyWithdrawDelay == 0 || block.timestamp < emergencyWithdrawDelay) {
revert EmergencyWithdrawNotEnabled();
}
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert NoTokensLocked();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
@> delete _lockState.locks[msg.sender];
@> // @info: not updating locks mapping
delete _votingState.points[msg.sender];
// ...
}

As shown, the locks mapping is never updated or deleted when expected. This bug could lead users to raise disputes over the incorrect locked token information. Consequently, users may lose interest or leave the protocol, and the oversight could significantly damage the protocol's reputation.

Moreover, as shown above, the veRAACToken::increase function uses the locks mapping to retrieve the locked amount and update the boost state, which directly impacts the reward distribution in the BaseGauge, GaugeController, and BoostController contracts.

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 testLocksMappingNotUpdatedDamagesReputation() public {
uint256 ALICE_NEEDED_AMOUNT = 4e18;
uint256 ALICE_LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, ALICE_NEEDED_AMOUNT);
vm.stopPrank();
console.log("ALICE RAAC TOKEN BALACE : ", raacToken.balanceOf(ALICE));
console.log("ALICE VE-RAAC TOKEN BALANCE : ", veRaacToken.balanceOf(ALICE));
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), ALICE_NEEDED_AMOUNT);
veRaacToken.lock(ALICE_NEEDED_AMOUNT, ALICE_LOCK_DURATION);
vm.stopPrank();
console.log("ALICE RAAC TOKEN UPDATED BALACE : ", raacToken.balanceOf(ALICE));
console.log("ALICE VE-RAAC TOKEN UPDATED BALANCE : ", veRaacToken.balanceOf(ALICE));
uint256 aliceCurrentPower = veRaacToken.getVotingPower(ALICE);
uint256 veRaacTokenTotalSupply = veRaacToken.getTotalVotingPower();
uint256 aliceLockedBalance = veRaacToken.getLockedBalance(ALICE);
uint256 aliceLockEndTime = veRaacToken.getLockEndTime(ALICE);
console.log("Alice current power : ", aliceCurrentPower);
console.log("veRaac Token Total Supply : ", veRaacTokenTotalSupply);
console.log("Alice's Locked Balance : ", aliceLockedBalance);
console.log("Alice's Locked End Time : ", aliceLockEndTime);
assertEq(aliceCurrentPower, veRaacToken.balanceOf(ALICE));
assertEq(veRaacTokenTotalSupply, aliceCurrentPower);
assertNotEq(aliceLockedBalance, ALICE_NEEDED_AMOUNT);
assertNotEq(aliceLockEndTime, block.timestamp + ALICE_LOCK_DURATION, "lock end time mismatch");
assertEq(aliceLockedBalance, 0);
assertEq(aliceLockEndTime, 0);
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testLocksMappingNotUpdatedDamagesReputation -vv
  1. Step 7: Review the output. The expected output should indicate that both getLockedBalance and getLockEndTime return default values (zero), confirming the vulnerability:

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testLocksMappingNotUpdatedDamagesReputation() (gas: 591112)
Logs:
ALICE RAAC TOKEN BALACE : 4000000000000000000
ALICE VE-RAAC TOKEN BALANCE : 0
ALICE RAAC TOKEN UPDATED BALACE : 0
ALICE VE-RAAC TOKEN UPDATED BALANCE : 1000000000000000000
Alice current power : 1000000000000000000
veRaac Token Total Supply : 1000000000000000000
Alice's Locked Balance : 0
Alice's Locked End Time : 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.14ms (356.60µs CPU time)
Ran 1 test suite in 10.79ms (2.14ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that getLockedBalance and getLockEndTime return incorrect default values.

Impact

  • User Disputes: Users may raise disputes over the incorrect locked token information.

  • Reputation Damage: The oversight could harm the protocol's reputation.

  • Governance Participation: Users might mistakenly believe they have no locked tokens (and consequently no voting power), leading to reduced participation in governance.

  • Reward Distribution: The boost state update sets the new balance to 0 in the increase function, which consequently results in no boost being applied to rewards distribution.

Tools Used

  • Manual Code Review

  • Foundry

Recommendations

Update the locks mapping at all critical points so that users receive correct information about their locked tokens.

One possible solution is as follows:

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);
+ locks[msg.sender] = Lock({ amount: amount, end: unlockTime});
_updateBoostState(msg.sender, amount);
...
}

veRAACToken::increase:

function increase(uint256 amount) external nonReentrant whenNotPaused {
// Increase lock using LockManager
_lockState.increaseLock(msg.sender, amount);
+ locks[msg.sender].amount += amount;
_updateBoostState(msg.sender, locks[msg.sender].amount);
...
}

veRAACToken::extend:

function extend(uint256 newDuration) external nonReentrant whenNotPaused {
// Extend lock using LockManager
uint256 newUnlockTime = _lockState.extendLock(msg.sender, newDuration);
+ locks[msg.sender].end += newDuration;
...
}

veRAACToken::withdraw:

function withdraw() external nonReentrant {
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert LockNotFound();
if (block.timestamp < userLock.end) revert LockNotExpired();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
// Clear lock data
delete _lockState.locks[msg.sender];
+ delete locks[msg.sender];
delete _votingState.points[msg.sender];
...
}

veRAACToken::emergencyWithdraw:

function emergencyWithdraw() external nonReentrant {
if (emergencyWithdrawDelay == 0 || block.timestamp < emergencyWithdrawDelay) {
revert EmergencyWithdrawNotEnabled();
}
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert NoTokensLocked();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
delete _lockState.locks[msg.sender];
+ delete locks[msg.sender];
delete _votingState.points[msg.sender];
...
}
Updates

Lead Judging Commences

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

veRAACToken::getLockEndTime and getLockedBalance returns 0 by reading from unused locks mapping instead of _lockState, making lock expiry times unavailable to clients

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

veRAACToken::getLockEndTime and getLockedBalance returns 0 by reading from unused locks mapping instead of _lockState, making lock expiry times unavailable to clients

Support

FAQs

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