DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

The protocol adding staking rewards to the `FjordStaking` contract after a epoch, in which no one trigger `_checkEpochRollover()`, will cause the rewards be wrongly distributed

Summary

The protocol adding staking rewards to the FjordStaking contract after a epoch, in which no one trigger _checkEpochRollover(), will cause the rewards be wrongly distributed.

Vulnerability Details

An i'th epoch will rollover to a i+1'th epoch when a user does an action that have a checkEpochRollover modifier in the i+1 epoch. The _checkEpochRollover() will update the rewardPerToken for the appropriate epochs

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L706-L708
https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L711-L713

function _checkEpochRollover() internal {
uint16 latestEpoch = getEpoch(block.timestamp);
if (latestEpoch > currentEpoch) {
//Time to rollover
currentEpoch = latestEpoch;
if (totalStaked > 0) {
uint256 currentBalance = fjordToken.balanceOf(address(this));
// no distribute the rewards to the users coming in the current epoch
uint256 pendingRewards = (currentBalance + totalVestedStaked + newVestedStaked)
- totalStaked - newStaked - totalRewards;
uint256 pendingRewardsPerToken = (pendingRewards * PRECISION_18) / totalStaked;
totalRewards += pendingRewards;
>> for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
>> rewardPerToken[i] = rewardPerToken[lastEpochRewarded] + pendingRewardsPerToken;
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
} else {
>> for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
>> rewardPerToken[i] = rewardPerToken[lastEpochRewarded];
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
}
...
}
}

The vulnerability will occur when no one interact with the FjordStaking contract during the i+1'th epoch, then the epoch will not get rollover, which the rewardPerToken for the ith epoch is not updated. After that, if the protocol adds staking rewards at the end of the i+1th epoch, the staking rewards will not distributed to the users that stake in the ith epoch, when in reality it should.

Add this coded PoC to test/POC/POC.t.sol

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../FjordStakingBase.t.sol";
contract StakeRewardScenarios is FjordStakingBase {
address cindy = makeAddr("cindiy");
function afterSetup() internal override {
deal(address(token), bob, 10000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), cindy, 10000 ether);
vm.prank(cindy);
token.approve(address(fjordStaking), 10000 ether);
}
function testNoActionInThirdEpoch() public {
// First epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Second epoch
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Third epoch
// No actions during this epoch
skip(fjordStaking.epochDuration());
// The end of the third epoch / The start of the fourth epoch
// The protocol adds staking rewards
vm.prank(minter);
fjordStaking.addReward(2 ether);
{
vm.prank(alice);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(alice);
console.log("Alice's rewards: %e", amount);
}
{
vm.expectRevert(FjordStaking.NothingToClaim.selector);
vm.prank(bob);
fjordStaking.claimReward(false);
}
}
function testHasActionInThirdEpoch() public {
// First epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Second epoch
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Third epoch
vm.prank(cindy);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// The end of the third epoch / The start of the fourth epoch
// The protocol adds staking rewards
vm.prank(minter);
fjordStaking.addReward(2 ether);
{
vm.prank(alice);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(alice);
console.log("Alice's rewards: %e", amount);
}
{
vm.prank(bob);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(bob);
console.log("Bob's rewards: %e", amount);
}
}
}

Logs:

testHasActionInThirdEpoch()
Alice's rewards: 1e18
Bob's rewards: 1e18
testNoActionInThirdEpoch()
Alice's rewards: 2e18

When there is no action in the third epoch, the staking rewards is not distributed to Bob, who is staking in the second epoch.

Impact

The staking rewards will be wrongly distributed when no one trigger _checkEpochRollover() in the previous epoch.

Tools Used

Manual Review.

Recommendations

Add a permissionless function to rollover the epochs. The protocol must call this function during an epoch.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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