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) {
currentEpoch = latestEpoch;
if (totalStaked > 0) {
uint256 currentBalance = fjordToken.balanceOf(address(this));
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
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 {
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
skip(fjordStaking.epochDuration());
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 {
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
vm.prank(cindy);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
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.