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

Reward Allocation Bug in Epoch Transition Mechanism

Summary

In a different issue I identified a vulnerability in the addReward function, where front-running the function could prevent the condition if (latestEpoch > currentEpoch) in _checkEpochRollover() from executing, leading to delayed reward distributions. Once this issue is resolved and the epoch rollover is correctly triggered, a new issue from a different root cause becomes evident.

In the updated code, when the condition if (latestEpoch > currentEpoch) passes, the totalRewards gets updated correctly. However, the loop intended to update rewardPerToken does not execute if lastEpochRewarded + 1 equals currentEpoch. This results in a scenario where totalRewards is updated, but rewardPerToken is not, leading to permanently lost rewards.

Vulnerability Details

Upon fixing the previously identified issue where front-running the addReward function prevented epoch rollover, a new vulnerability emerges. Specifically, when the if (latestEpoch > currentEpoch) condition in the _checkEpochRollover() function passes, totalRewards is correctly updated, but rewardPerToken may not be updated due to the loop condition.

Updating totalRewards:

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L702C17-L705C48

uint256 pendingRewards = (currentBalance + totalVestedStaked + newVestedStaked)
- totalStaked - newStaked - totalRewards;
uint256 pendingRewardsPerToken = (pendingRewards * PRECISION_18) / totalStaked;
totalRewards += pendingRewards;

In this segment, totalRewards is increased by pendingRewards.

Failure to Update rewardPerToken

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L706C17-L709C18

for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
rewardPerToken[i] = rewardPerToken[lastEpochRewarded] + pendingRewardsPerToken;
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}

This loop will not execute if lastEpochRewarded + 1 equals currentEpoch, meaning rewardPerToken does not get updated for the new epoch.

POC

Updated addReward that triggers the inner logic of _checkEpochRollover

function addReward(uint256 _amount) external onlyRewardAdmin {
//CHECK
if (_amount == 0) revert InvalidAmount();
//EFFECT
//uint16 previousEpoch = currentEpoch; @audit out for testing
currentEpoch--; // @audit Added this line for testing
//INTERACT
fjordToken.safeTransferFrom(msg.sender, address(this), _amount);
_checkEpochRollover();
//emit RewardAdded(previousEpoch, msg.sender, _amount); @audit out for testing
emit RewardAdded(currentEpoch, msg.sender, _amount); // @audit Added this line for testing
}

Foundry test

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../FjordStakingBase.t.sol";
import "forge-std/Test.sol";
contract Unstake_Unit_Test is FjordStakingBase {
function afterSetup() internal override {
fjordStaking.stake(10 ether);
}
function test_frontrun() public {
// Total added reward is 7 ether over 7 epochs but get frontrun
_addRewardAttack(1 ether, 7);
assertEq(fjordStaking.currentEpoch(), 8);
console2.log("fjordStaking.rewardPerToken(1) ", fjordStaking.rewardPerToken(1));
console2.log("fjordStaking.rewardPerToken(2) ", fjordStaking.rewardPerToken(2));
console2.log("fjordStaking.rewardPerToken(3) ", fjordStaking.rewardPerToken(3));
console2.log("fjordStaking.rewardPerToken(4) ", fjordStaking.rewardPerToken(4));
console2.log("");
}
function test_Correct_rewards() public {
// Total added reward is 7 ether over 7 epochs
_addRewardAndEpochRollover(1 ether, 7);
assertEq(fjordStaking.currentEpoch(), 8);
console2.log("fjordStaking.rewardPerToken(1) ", fjordStaking.rewardPerToken(1));
console2.log("fjordStaking.rewardPerToken(2) ", fjordStaking.rewardPerToken(2));
console2.log("fjordStaking.rewardPerToken(3) ", fjordStaking.rewardPerToken(3));
console2.log("fjordStaking.rewardPerToken(4) ", fjordStaking.rewardPerToken(4));
console2.log("");
}
function _addRewardAttack(uint256 reward, uint256 times) internal returns (uint16) {
for (uint256 i = 0; i < times; i++) {
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration());
// Bad Actor
vm.prank(alice);
fjordStaking.stake(1);
vm.prank(minter);
fjordStaking.addReward(reward);
}
return fjordStaking.currentEpoch();
}
}

Logs

[PASS] test_Correct_rewards() (gas: 342181)
Logs:
fjordStaking.rewardPerToken(1) 0
fjordStaking.rewardPerToken(2) 200000000000000000
fjordStaking.rewardPerToken(3) 300000000000000000
fjordStaking.rewardPerToken(4) 400000000000000000
[PASS] test_frontrun() (gas: 1077331)
Logs:
fjordStaking.rewardPerToken(1) 0
fjordStaking.rewardPerToken(2) 0
fjordStaking.rewardPerToken(3) 0
fjordStaking.rewardPerToken(4) 0

Impact

Lost Rewards: The expected rewards per token (rewardPerToken) are not updated, causing the rewards to be lost permanently.

Tools Used

Foundry

Recommendations

Modify the _checkEpochRollover function to ensure that the rewardPerToken array is updated even if lastEpochRewarded + 1 equals currentEpoch.

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]);
}
++ rewardPerToken[currentEpoch - 1] = rewardPerToken[lastEpochRewarded] + pendingRewardsPerToken;
} else {
for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
rewardPerToken[i] = rewardPerToken[lastEpochRewarded];
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
}
totalStaked += newStaked;
totalVestedStaked += newVestedStaked;
newStaked = 0;
newVestedStaked = 0;
lastEpochRewarded = currentEpoch - 1;
}
}

same test logs after update

[PASS] test_Correct_rewards() (gas: 346819)
Logs:
fjordStaking.rewardPerToken(1) 0
fjordStaking.rewardPerToken(2) 200000000000000000
fjordStaking.rewardPerToken(3) 300000000000000000
fjordStaking.rewardPerToken(4) 400000000000000000
[PASS] test_frontrun() (gas: 1226680)
Logs:
fjordStaking.rewardPerToken(1) 100000000000000000
fjordStaking.rewardPerToken(2) 199999999999999999
fjordStaking.rewardPerToken(3) 299999999999999998
fjordStaking.rewardPerToken(4) 399999999999999997
Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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