Core Contracts

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

User's cannot claim full rewards via flawed totalSupply implementation

Summary

The RAAC protocol implements a voting escrow mechanism where users lock RAAC tokens to receive veRAAC, which grants them voting power. However, the contract does not correctly account for the gradual decrease in voting power due to the slope mechanism, which causes an overestimation of the total supply of veRAAC. This leads to an inaccurate distribution of rewards, where users receive fewer rewards than they are entitled to. The vulnerability arises because the contract uses a standard totalSupply() function to determine the total voting power, instead of dynamically adjusting it based on each user's decaying bias over time.

Vulnerability Details

To understand this, we need to go over how totalSupply works in voting escrows. These are the formulae for calculating a user's bias and slope. The bias is the user's balance at a point in time. A user's bias goes down over time which means their voting power goes down over time. See below:

bias = (amountLocked / MAX_TIME ) times (lockEndTime - block.timestamp)
slope = amountLocked / MAX_TIME

The slope can also be described as the rate of decay of tokens per second. It signifies how much each token should drop per second. So if a user has a bias when they lock tokens with a timestamp recorded, we can calculate the the user's bias at any given point by comparing how much the token has decayed and subtracting it from their bias at the time tokens were initially locked.

currentBias= point.bias - (point.slope times (block.timestamp - point.timestamp))

This is what determines the user's bias. So all is well and good but now we know that the user's bias (balance) always goes down as time passes, the total supply of all veTokens obviously can't be gotten using the usual totalSupply function that an ERC20 contract uses. This is actually a problem that happened right here with RAAC and I will use their protocol to show why.

So in veRAACToken, this is how users lock RAAC to get veRAAC:

/**
* @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 {
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));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}

The main part we are focusing on is where users are minted veRAAC in the function. You can see that when a user creates a lock, their bias is calculated and then they are minted that bias which represents their balance. This works well but that voting power is only valid until 1 second has passed because of the slope. The slope implies that every second, the user's balance decreases so for example, say the bias was 100 and the slope (rate of decay) was 1. So once the user is minted veRAAC tokens, which are 100, once a second passes, that balance should be reduced to 99 to reflect their new bias. the totalsupply should also be updates to reflect this change which is very important because if the users lock duration is over but they haven't withdraw yet, their bias is 0 yet the totalSupply will still be 100 since they havent withdrawn which burns the tokens. This is where the exploit occurs. As a result, the user's tokens are still taken into consideration in totalSupply calculations when the tokens hold no power, they should no longer be considered in totalSupply calculations

RAAC did not implement voting escrow total supply mechanics and what this meant was that their totalSupply was inaccurate. This is how voting power was calculated in veRAACToken.sol.

function getTotalVotingPower() external view override returns (uint256) {
return totalSupply();
}

which as we discussed doesn't factor slope changes in the calculation.

As a result, when user's are to claim rewards via FeeCollector::claimRewards, the rewards are first calculated via FeeCollector::_calculatePendingRewards:

function _calculatePendingRewards(address user) internal view returns (uint256) {
uint256 userVotingPower = veRAACToken.getVotingPower(user);
if (userVotingPower == 0) return 0;
uint256 totalVotingPower = veRAACToken.getTotalVotingPower();
if (totalVotingPower == 0) return 0;
uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
return share > userRewards[user] ? share - userRewards[user] : 0;
}

As seen above, veRAACToken::getTotalVotingPower is called which we referenced above, simply returns the totalSupply of veRAAC tokens which is wrongly implemented as it doesn't account for each user's bias changes over time. As a result, the totalSupply value is inflated which leads to user's getting less rewards that intended.

Proof Of Code (POC)

This test was run in the FeeCollector.test.js file in the "Fee Collection and Distribution" describe block. To run this test, import the assert statement from chai at the top of the file for the test to run as expected

it("users get less rewards due to totalSupply overestimation", async function () {
//c for testing purposes
//c users have already locked tokens in the beforeEach block
//c wait for half of the year so the user's biases to change
await time.increase(ONE_YEAR / 2); // users voting power should be reduced as duration has passed
//c get user biases
const user1Bias = await veRAACToken.getVotingPower(user1.address);
const user2Bias = await veRAACToken.getVotingPower(user2.address);
const expectedTotalSupply = user1Bias + user2Bias;
console.log("User 1 Bias: ", user1Bias);
console.log("User 2 Bias: ", user2Bias);
console.log("Expected Total Supply: ", expectedTotalSupply);
//c get actual total supply
const actualTotalSupply = await veRAACToken.totalSupply();
console.log("Actual Total Supply: ", actualTotalSupply);
//c due to improper tracking, expectedtotalsupply will be less than the actual total supply
assert(expectedTotalSupply < actualTotalSupply);
//c get the share of totalfees allocated to veRAAC holders
const tx1 = await feeCollector.connect(owner).distributeCollectedFees();
const tx1Receipt = await tx1.wait();
const eventLogs = tx1Receipt.logs;
let veRAACShare;
for (let log of eventLogs) {
if (log.fragment && log.fragment.name === "FeeDistributed") {
veRAACShare = log.args[0];
break;
}
}
console.log("veRAAC Share: ", veRAACShare);
//c manually calculate user pending rewards and compare it to the rewards actually received which will show that the user received less rewards than they should have
const user1PendingRewards =
(veRAACShare * user1Bias) / expectedTotalSupply;
console.log("User 1 Pending Rewards: ", user1PendingRewards);
const initialBalance = await raacToken.balanceOf(user1.address);
const tx2 = await feeCollector.connect(user1).claimRewards(user1.address);
const tx2Receipt = await tx2.wait();
const eventLogs2 = tx2Receipt.logs;
let user1Reward;
for (let log of eventLogs2) {
if (log.fragment && log.fragment.name === "RewardClaimed") {
user1Reward = log.args[1];
break;
}
}
console.log("User 1 Actual Rewards: ", user1Reward);
assert(user1PendingRewards > user1Reward);
});
});

Impact

Users Receive Fewer Rewards Than They Deserve: Since the total supply of veRAAC is incorrectly high, each user's reward allocation is diluted. Users who have locked tokens for a long time may see their expected rewards significantly reduced.

Tools Used

Manual Review, Hardhat

Recommendations

Implement proper totalSupply mechanics that follow voting escrow methodology. See an example solution below:

  • The contract allows lock ends to be only on the exact timestamp a week ends (timestamp % 604800 == 0)

  • Upon a user creating a lock, the contract adds their bias and slope to the global variables.

  • Then it adds the user's slope to a mapping slopeChanges(uint256 timestamp => int256 slopeChange)

  • Any time a week's end is crossed, it applies all the necessary slope changes (decreases the global slope)

By doing all of the above, the contract makes sure that at all times the sum of all user balance equals exactly the total supply.

Updates

Lead Judging Commences

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

veRAACToken::getTotalVotingPower returns non-decaying totalSupply while individual voting powers decay, causing impossible governance quorums and stuck rewards in FeeCollector

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

veRAACToken::getTotalVotingPower returns non-decaying totalSupply while individual voting powers decay, causing impossible governance quorums and stuck rewards in FeeCollector

Support

FAQs

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

Give us feedback!