Core Contracts

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

Reward amount decreases over time

Summary

Function FeeCollector::claimRewards() allows an user to claim accumulated rewards. However, the pending reward amount is incorrectly computed such that the amount is getting lower over time.

Vulnerability Details

The function FeeCollector::_calculatePendingRewards() handles calculating user pending rewards. The reward amount is calculated as (totalDistributed * userVotingPower) / totalVotingPower which is proportional to userVotingPower / totalVotingPower.

However, the problem arises because:

  • The userVotingPower is returned from veRAACToken.getVotingPower(user) in which the return value decays over time

  • veRAACToken.totalVotingPower() does not depend on time because this value is the total supply of veRAACToken.

As a result, user pending reward amount is getting lower over time.

// FeeCollector
function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
if (user == address(0)) revert InvalidAddress();
@> uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}
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;
}
// veRAACToken
function getVotingPower(address account) public view returns (uint256) {
return _votingState.getCurrentPower(account, block.timestamp);
}
// VotingPowerLib
function getCurrentPower(
VotingPowerState storage state,
address account,
uint256 timestamp
) internal view returns (uint256) {
RAACVoting.Point memory point = state.points[account];
if (point.timestamp == 0) return 0;
if (timestamp < point.timestamp) {
return uint256(uint128(point.bias));
}
uint256 timeDelta = timestamp - point.timestamp;
// Calculate decay
int128 adjustedBias = point.bias;
if (timeDelta > 0) {
// Calculate decay per second and multiply by time delta
int128 decay = (point.slope * int128(int256(timeDelta))) / int128(int256(1));
adjustedBias = point.bias - decay;
}
// Return 0 if power has fully decayed
return adjustedBias > 0 ? uint256(uint128(adjustedBias)) : 0;
}

PoC

Add the test to test/unit/core/collectors/FeeCollector.test.js

describe("Fee Collection and Distribution", function () {
// ...
it.only("user reward decreases over time", async function () {
await feeCollector.connect(owner).distributeCollectedFees();
// take snapshot
const snapshot = await takeSnapshot();
// scenario 1: user claims reward instantly after fee is distributed
const initialBalance = await raacToken.balanceOf(user1.address);
await feeCollector.connect(user1).claimRewards(user1.address);
const balanceAfter = await raacToken.balanceOf(user1.address);
// reward if claim instantly
const rewardClaimed = balanceAfter - initialBalance
await snapshot.restore();
// scenario 2: user claims reward 4 weeks after fee is distributed
await time.increase(4 * WEEK);
const initialBalance2 = await raacToken.balanceOf(user1.address);
await feeCollector.connect(user1).claimRewards(user1.address);
const balanceAfter2 = await raacToken.balanceOf(user1.address);
// reward if claim after 4 weeks
const rewardClaimed2 = balanceAfter2 - initialBalance2
expect(rewardClaimed2).to.eq(rewardClaimed)
});

Run the test and console shows:

FeeCollector
Fee Collection and Distribution
1) user reward decreases over time
0 passing (2s)
1 failing
1) FeeCollector
Fee Collection and Distribution
user reward decreases over time:
AssertionError: expected 3013698630136992 to equal 3269406392694065.
+ expected - actual
-3013698630136992
+3269406392694065

Impact

  • User reward amount's getting lower over time as voting power decays, which can be unfair for many users

  • Users who locks their tokens in-between the claim period can benefit more rewards than users who locks tokens before claim period

Tools Used

Manual

Recommendations

function _calculatePendingRewards(address user) internal view returns (uint256) {
- uint256 userVotingPower = veRAACToken.getVotingPower(user);
+ uint256 userVotingPower = veRAACToken.balanceOf(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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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 4 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.