Summary
Wrong constraint verification in veRAACToken::lock
function will results in users unable to lock RAAC tokens much ealier than expected.
Relevant links
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/veRAACToken.sol#L215
Vulnerability Details
in veRAACToken::lock
, during a lock, we verify if 3 conditions are met. Amongst which the max total verification
if (totalSupply() + amount > MAX\_TOTAL\_SUPPLY) revert TotalSupplyLimitExceeded();
totalSupply()
represents the value of veRAACTokens that are already minted.
amount
represents the amount of RAAC tokens to be locked.
MAX_TOTAL_SUPPLY
represents the amaximum amount of veRAACTokens to ever be minted.
It is wrong to mix these various amounts as though they represent the same thing especially given that, the actual amount of veRAACToken to be minted will always be less than the amount
of RAAC token locked.
This is because, the newPower
minted is deducted from bias
which is calculated using the VotingPowerLib::calculateAndUpdatePower
function.
File: contracts/libraries/governance/VotingPowerLib.sol#L75-L107
function calculateAndUpdatePower(
VotingPowerState storage state,
address user,
uint256 amount,
uint256 unlockTime
) internal returns (int128 bias, int128 slope) {
if (amount == 0 || unlockTime <= block.timestamp) revert InvalidPowerParameters();
uint256 MAX_LOCK_DURATION = 1460 days;
uint256 duration = unlockTime - block.timestamp;
uint256 initialPower = (amount * duration) / MAX_LOCK_DURATION;
bias = int128(int256(initialPower));
slope = int128(int256(initialPower / duration));
uint256 oldPower = getCurrentPower(state, user, block.timestamp);
state.points[user] = RAACVoting.Point({
bias: bias,
slope: slope,
timestamp: block.timestamp
});
_updateSlopeChanges(state, unlockTime, 0, slope);
emit VotingPowerUpdated(user, oldPower, uint256(uint128(bias)));
return (bias, slope);
}
We can observe that, the calculated power will always be lesser than the amount
of RAAC locked.
POC
it("User's veRAACToken valance is always lower than locked amount of RAAC token", async () => {
const amount = ethers.parseEther("1000");
await raacToken.mint(users[0].address, amount);
const duration = 365 * 24 * 3600;
const userBalanceBefore = await veRAACToken.balanceOf(users[0]);
await veRAACToken.connect(users[0]).lock(amount, duration);
const userBalanceAfter = await veRAACToken.balanceOf(users[0]);
expect(userBalanceAfter - userBalanceBefore).to.be.lt(amount);
});
Copy the above code in test/unit/core/tokens/veRAACToken.test.js
and run npx hardhat test test/unit/core/tokens/veRAACToken.test.js
in the terminal.
Impact
Users will be prevented from locking funds if the amount to be locked is > MAX_TOTAL_SUPPLY - totalSupply()
even if it won't break any invariant of the contract. Thus, users will not be able to create new lock positions well ahead of when the protocol intended for them to be unable to. This means that, early comers will gain an unfaire voting advantage over the late voters as the majority of the voting power will be owned by them ( due to them locking their votes early on ) and the late comers will have no means of gaining any (new) voting power given that, the false check on MAX_TOTAL_SUPPLY
will prevent them to.
Also, MAX_TOTAL_SUPPLY
will never be reached since it is the amount to be lock that is used to perform the verification and the calculated power is always be lower than the locked amount per the above explanation.
I should note that, given that one can extend his lock duration indefinitely, it's possible for those who locked their positions early on to hoard all the voting power for themselves.
Tools Used
Manual review.
Recommendations
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));
+ if (totalSupply() + newPower > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}