Description
The emergencyWithdraw() function fails to record a checkpoint when users withdraw their tokens, unlike the regular withdraw() function.
Current implementation of regular withdrawal:
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];
@---> _checkpointState.writeCheckpoint(msg.sender, 0);
_burn(msg.sender, currentPower);
raacToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
Emergency withdrawal lacking checkpoint update:
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];
❌❌
_burn(msg.sender, currentPower);
raacToken.safeTransfer(msg.sender, amount);
emit EmergencyWithdrawn(msg.sender, amount);
}
Impact
The vulnerability allows users to:
Withdraw tokens through emergency withdrawal
Retain their historical voting power in checkpoint records
Vote on proposals using this phantom voting power
This breaks a fundamental security invariant of the governance system: voting power must be backed by locked tokens.
The vulnerability affects governance functions that check historical voting power:
* @notice Gets voting power at a specific historical block
* @dev Queries the checkpoint history to find the voting power
* @param state The checkpoint state storage
* @param user The user address to query
* @param blockNumber The block number to query
* @return The voting power at the specified block
*/
function getPastVotingPower(
CheckpointState storage state,
address user,
uint256 blockNumber
) internal view returns (uint256) {
if (blockNumber >= block.number) revert InvalidBlockNumber();
return state.userCheckpoints[user].findCheckpoint(blockNumber);
}
This gets called via getVotingPowerForProposal() --> getPastVotes() --> _checkpointState.getPastVotes() --> getPastVotingPower()
.
Mitigation
Add the call to _checkpointState.writeCheckpoint(msg.sender, 0);
inside emergencyWithdraw()
before _burn()
is called.