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();
}
raacToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 unlockTime = block.timestamp + duration;
@> _lockState.createLock(msg.sender, amount, duration);
@>
_updateBoostState(msg.sender, amount);
}
veRAACToken::increase:
function increase(uint256 amount) external nonReentrant whenNotPaused {
@> _lockState.increaseLock(msg.sender, amount);
@>
@> _updateBoostState(msg.sender, locks[msg.sender].amount);
-----------------------------------------^
}
veRAACToken::extend:
function extend(uint256 newDuration) external nonReentrant whenNotPaused {
@> uint256 newUnlockTime = _lockState.extendLock(msg.sender, 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);
@> delete _lockState.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 _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.
-
Step 1: Create a Foundry project and place all the contracts in the src
directory.
-
Step 2: Create a test
directory and a mocks
folder within the src
directory (or use an existing mocks folder).
-
Step 3: Create all necessary mock contracts, if required.
-
Step 4: Create a test file (with any name) in the test
directory.
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;
uint256 initialRaacBurnTaxRateInBps = 150;
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();
}
}
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);
}
Step 6: To run the test, execute the following commands in your terminal:
forge test --mt testLocksMappingNotUpdatedDamagesReputation -vv
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];
...
}