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 i
th epoch is not updated. After that, if the protocol adds staking rewards at the end of the i+1
th epoch, the staking rewards will not distributed to the users that stake in the i
th 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.