Summary
Whenever users lock tokens, increase their locked tokens amount, or extend their lock duration, it is expected that these details are updated in the locks mapping.
* @notice Mapping of user addresses to their lock positions
*/
mapping(address => Lock) public locks;
But, this mapping is never updated.
The veRAACToken updates user details into the private _lockState mapping instead. Which is a library state.
* @notice State for managing lock positions
*/
LockManager.LockState private _lockState;
Impact
Reading from the veRAACToken to verify user details will always return wrong details and results.
This is particular harmful because, if an external contract needs to verify user details by calling getter functions, it will get back wrong details.
* @notice Gets the amount of RAAC tokens locked by an account
* @dev Returns the raw locked token amount without time-weighting
* @param account The address to check
* @return The amount of RAAC tokens locked by the account
*/
function getLockedBalance(address account) external view returns (uint256) {
return locks[account].amount;
}
* @notice Gets the lock end time for an account
* @dev Returns the timestamp when the lock expires
* @param account The address to check
* @return The unix timestamp when the lock expires
*/
function getLockEndTime(address account) external view returns (uint256) {
return locks[account].end;
}
PoC
Here is a test to prove this finding:
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {veRAACToken} from "../contracts/core/tokens/veRAACToken.sol";
import {RAACMockERC20} from "../contracts/mocks/core/tokens/RAACMockERC20.sol";
contract TestVeRAACToken is Test {
veRAACToken vrc;
RAACMockERC20 mrt;
address owner = address(0x1);
address user = address(0x2);
function setUp() public {
vm.startPrank(owner);
mrt = new RAACMockERC20(owner);
vrc = new veRAACToken(address(mrt));
mrt.mintTo(user, 10_000e18);
vm.stopPrank();
}
function testUserDetailsNotUpdated() public {
uint256 amountToLock = 1_000e18;
uint256 timeToLock = 31_536_000 seconds;
vm.startPrank(user);
mrt.approve(address(vrc), amountToLock);
vrc.lock(amountToLock, timeToLock);
vm.stopPrank();
uint256 userLockedAmount = vrc.getLockedBalance(user);
uint256 userLockedTime = vrc.getLockEndTime(user);
console2.log("This is the locked balance of the user: ", userLockedAmount);
console2.log("This is the duration the user locked their funds for: ", userLockedTime);
assert(amountToLock == userLockedAmount);
assert((timeToLock + block.timestamp) == userLockedTime);
}
}
And these are the traces:
[509948] TestVeRAACToken::testUserDetailsNotUpdated()
├─ [0] VM::startPrank(SHA-256: [0x0000000000000000000000000000000000000002])
│ └─ ← [Return]
├─ [25296] RAACMockERC20::approve(veRAACToken: [0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1], 1000000000000000000000 [1e21])
│ ├─ emit Approval(owner: SHA-256: [0x0000000000000000000000000000000000000002], spender: veRAACToken: [0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1], value: 1000000000000000000000 [1e21])
│ └─ ← [Return] true
├─ [455613] veRAACToken::lock(1000000000000000000000 [1e21], 31536000 [3.153e7])
│ ├─ [31614] RAACMockERC20::transferFrom(SHA-256: [0x0000000000000000000000000000000000000002], veRAACToken: [0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1], 1000000000000000000000 [1e21])
│ │ ├─ emit Transfer(from: SHA-256: [0x0000000000000000000000000000000000000002], to: veRAACToken: [0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1], value: 1000000000000000000000 [1e21])
│ │ └─ ← [Return] true
│ ├─ emit LockCreated(user: SHA-256: [0x0000000000000000000000000000000000000002], amount: 1000000000000000000000 [1e21], unlockTime: 31536001 [3.153e7])
│ ├─ emit PeriodCreated(startTime: 1, duration: 604800 [6.048e5], initialValue: 0)
│ ├─ emit VotingPowerUpdated(user: SHA-256: [0x0000000000000000000000000000000000000002], oldPower: 0, newPower: 250000000000000000000 [2.5e20])
│ ├─ emit CheckpointCreated(user: SHA-256: [0x0000000000000000000000000000000000000002], blockNumber: 1, power: 250000000000000000000 [2.5e20])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: SHA-256: [0x0000000000000000000000000000000000000002], value: 250000000000000000000 [2.5e20])
│ ├─ emit LockCreated(user: SHA-256: [0x0000000000000000000000000000000000000002], amount: 1000000000000000000000 [1e21], unlockTime: 31536001 [3.153e7])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [2989] veRAACToken::getLockedBalance(SHA-256: [0x0000000000000000000000000000000000000002]) [staticcall]
│ └─ ← [Return] 0
├─ [2947] veRAACToken::getLockEndTime(SHA-256: [0x0000000000000000000000000000000000000002]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("This is the locked balance of the user: ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("This is the duration the user locked their funds for: ", 0) [staticcall]
│ └─ ← [Stop]
└─ ← [Revert] panic: assertion failed (0x01)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 6.74ms (2.49ms CPU time)
Tools Used
Recommendations
There are two approaches to be considered for mitigation:
/**
* @notice Gets the amount of RAAC tokens locked by an account
* @dev Returns the raw locked token amount without time-weighting
* @param account The address to check
* @return The amount of RAAC tokens locked by the account
*/
function getLockedBalance(address account) external view returns (uint256) {
- return locks[account].amount;
+ LockManager.Lock memory userLock = _lockState.locks[account]; // read directly from _lockState mapping
+ return userLock.amount;
}
/**
* @notice Gets the lock end time for an account
* @dev Returns the timestamp when the lock expires
* @param account The address to check
* @return The unix timestamp when the lock expires
*/
function getLockEndTime(address account) external view returns (uint256) {
- return locks[account].end;
+ LockManager.Lock memory userLock = _lockState.locks[account]; // read directly from _lockState mapping
+ return userLock.end;
}
/**
* @notice Creates a new lock position for RAAC tokens
* @dev Locks RAAC tokens for a specified duration and mints veRAAC tokens representing voting power
* @param amount The amount of RAAC tokens to lock
* @param duration The duration to lock tokens for, in seconds
*/
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
...
...
// Calculate unlock time
uint256 unlockTime = block.timestamp + duration;
// Create lock position
_lockState.createLock(msg.sender, amount, duration);
_updateBoostState(msg.sender, amount);
+ locks[msg.sender] = Lock({amount: amount, end: unlockTime}); // Update the locks mapping in the veRAACToken contract
...
...
}
/**
* @notice Increases the amount of locked RAAC tokens
* @dev Adds more tokens to an existing lock without changing the unlock time
* @param amount The additional amount of RAAC tokens to lock
*/
function increase(uint256 amount) external nonReentrant whenNotPaused {
...
...
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
+ locks[msg.sender].amount = userLock.amount; // Update locks mapping in the veRAACToken contract
...
...
}
/**
* @notice Extends the duration of an existing lock
* @dev Increases the lock duration which results in updated voting power
* @param newDuration The new total duration extension for the lock, in seconds
*/
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];
+ locks[msg.sender].end = newUnlockTime; // Update locks mapping in the veRAACToken contract
...
...
}
Either of these approaches will mitigate this issue and ensure that user details are correctly read from the veRAACToken contract.