Summary
The veRAACToken::increase function contains a critical vulnerability in the calculation of voting power (bias) when users increase their locked token amount. The issue arises because the function does not account for the decay of the user's existing voting power over time. Instead, it calculates the new bias based on the original locked amount, ignoring the fact that the user's voting power has decreased due to decay.
This flaw allows users to exploit the system by:
Initially locking a small amount of tokens.
Waiting for their voting power to decay partially.
Increasing their locked amount to gain disproportionately higher voting power compared to users who lock the same total amount upfront.
Vulnerability Details
veRAACToken::increase allows a user with an existing lock to increase the amount they have locked. See below:
* @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 {
_lockState.increaseLock(msg.sender, amount);
_updateBoostState(msg.sender, locks[msg.sender].amount);
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
(int128 newBias, int128 newSlope) = _votingState.calculateAndUpdatePower(
msg.sender,
userLock.amount + amount,
userLock.end
);
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
emit LockIncreased(msg.sender, amount);
}
The key line to note is where the newBias is calculated with:
(int128 newBias, int128 newSlope) = _votingState
.calculateAndUpdatePower(
msg.sender,
userLock.amount + amount,
userLock.end
);
VotingPowerLib::calculateAndUpdatePower updates the user's bias as follows:
* @notice Calculates and updates voting power for a user
* @dev Updates points and slope changes for power decay
* @param state The voting power state
* @param user The user address
* @param amount The amount of tokens
* @param unlockTime The unlock timestamp
* @return bias The calculated voting power bias
* @return slope The calculated voting power slope
*/
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);
}
The crease function passes userLock.amount + amount. The idea is that we calculate a new bias value based on the amount that the user wants to deposit and the last amount the user deposited. Then the new bias gotten from there using the above function.
The error occurs in the calculation of the new bias. Since veRAACToken::increase uses userLock.amount, it assumes the initial value that the user deposited into the protocol. This doesn't take the rate of decay into account. The idea of ve mechanics is that the tokens lose value over time as their power tends towards 0. So when a user increases their locked amount, the previous balance they deposited should have been decaying over time and the amount that should be considered when a user increases their lock is the current amount factoring decay + new amount user wants to deposit. In the current implementation, the rate of decay is not considered which can lead to a host of issues including that a user could opt to initially lock a small amount and come back and increase the lock by a larger amount and get more voting power than a user who just locked the same amount at once using veRAACToken::lock.
##Proof Of Code (POC)
The following tests were run in the veRAACToken.test.js file in the "Lock Mechanism" describe block.
it("new bias skew in increase function", async () => {
const initialAmount = ethers.parseEther("1000");
const additionalAmount = ethers.parseEther("500");
const duration = 365 * 24 * 3600;
await veRAACToken.connect(users[0]).lock(initialAmount, duration);
await time.increase(duration / 2);
const user0BiasPreIncrease = await veRAACToken.getVotingPower(
users[0].address
);
console.log("User 0 Bias: ", user0BiasPreIncrease);
const amount = user0BiasPreIncrease + additionalAmount;
console.log("Amount: ", amount);
const positionPreIncrease = await veRAACToken.getLockPosition(
users[0].address
);
const positionEndTime = positionPreIncrease.end;
console.log("Position End Time: ", positionEndTime);
const currentTimestamp = await time.latest();
const duration1 = positionEndTime - BigInt(currentTimestamp);
console.log("Duration 1: ", duration1);
const user0expectedBiasAfterIncrease =
(amount * duration1) / (await veRAACToken.MAX_LOCK_DURATION());
console.log(
"Expected Bias After Increase: ",
user0expectedBiasAfterIncrease
);
await veRAACToken.connect(users[0]).increase(additionalAmount);
const user0actualBiasPostIncrease = await veRAACToken.getVotingPower(
users[0].address
);
console.log("User 0 Post Increase Bias: ", user0actualBiasPostIncrease);
assert(user0expectedBiasAfterIncrease < user0actualBiasPostIncrease);
const position = await veRAACToken.getLockPosition(users[0].address);
expect(position.amount).to.equal(initialAmount + additionalAmount);
});
it("2 users locking same amount with different methods end up with different voting power", async () => {
const initialAmount = ethers.parseEther("1000");
const duration = 365 * 24 * 3600;
await veRAACToken.connect(users[0]).lock(initialAmount, duration);
await veRAACToken
.connect(users[1])
.lock(BigInt(initialAmount) / BigInt(2), duration);
await time.increase(duration / 2);
await veRAACToken
.connect(users[1])
.increase(BigInt(initialAmount) / BigInt(2));
const user0BiasPostIncrease = await veRAACToken.getVotingPower(
users[0].address
);
console.log("User 0 Bias: ", user0BiasPostIncrease);
const user1BiasPostIncrease = await veRAACToken.getVotingPower(
users[1].address
);
console.log("User 1 Bias: ", user1BiasPostIncrease);
assert(user0BiasPostIncrease < user1BiasPostIncrease);
});
Impact
The vulnerability in the veRAACToken::increase function has significant implications for the fairness and integrity of the protocol. Specifically:
Unfair Voting Power Distribution: Users who exploit this vulnerability by initially locking a small amount and later increasing it can gain disproportionately higher voting power compared to users who lock the same total amount upfront. This skews voting outcomes and undermines the fairness of governance decisions.
Reward Distribution Manipulation: Exploiting this bug allows users to manipulate their voting power to claim a larger share of rewards than they are entitled to.
Economic Imbalance: The exploit could lead to an imbalance in the protocol's tokenomics, as users who exploit the bug gain an unfair advantage over honest participants.
Tools Used
Manual Review, Hardhat
Recommendations
Update the increase Function: Modify the increase function to pass the current decayed balance of the user's existing lock to calculateAndUpdatePower.
Update the increase function to:
function increase(uint256 amount) external nonReentrant whenNotPaused {
_lockState.increaseLock(msg.sender, amount);
_updateBoostState(msg.sender, locks[msg.sender].amount);
uint256 currentBalance = getCurrentPower(_votingState, msg.sender, block.timestamp);
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
(int128 newBias, int128 newSlope) = _votingState.calculateAndUpdatePower(
msg.sender,
currentBalance + amount,
userLock.end
);
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
emit LockIncreased(msg.sender, amount);
}