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

Rewards added during epochs which started with 0 stakers will be locked

Summary

Adding reward to any epoch which started with 0 stakers will result with those reward assets being locked in the contract. The reward is calculated based on the difference between the rewardPerToken from the current epoch and the one where the deposit occurred, so effectively reward assets will not be claimable in a such scenario and will remain locked in the contract.

Vulnerability Details

FjordStaking.sol#L734

FjordStaking.sol#L743

For example, if rewardAdmin adds a reward in the first epoch (since nothing prevents him from doing so), no one will be able to take that reward and the tokens will be locked on the contract. Same issue applies to any other epoch which started without stakers.

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);
}

When the current epoch ends, _checkEpochRollower() will update rewardPerToken array FjordStaking.sol#L707.

What prevents the user from taking the given reward, even though he was the first to stake, is the way in which the reward is calculated in the function FjordStaking:_redeem, which records the reward for each user.

function _redeem(address sender) internal {
//1. Get user data
UserData storage ud = userData[sender];
ud.unclaimedRewards +=
calculateReward(ud.totalStaked, ud.lastClaimedEpoch, currentEpoch - 1);
ud.lastClaimedEpoch = currentEpoch - 1;
if (ud.unredeemedEpoch > 0 && ud.unredeemedEpoch < currentEpoch) {
// 2. Calculate rewards for all deposits since last redeemed, there will be only 1 pending unredeemed epoch
DepositReceipt memory deposit = deposits[sender][ud.unredeemedEpoch];
// 3. Update last redeemed and pending rewards
@> ud.unclaimedRewards += calculateReward(
deposit.staked + deposit.vestedStaked, ud.unredeemedEpoch, currentEpoch - 1;
);
ud.unredeemedEpoch = 0;
ud.totalStaked += (deposit.staked + deposit.vestedStaked);
}
}
function calculateReward(uint256 _amount, uint16 _fromEpoch, uint16 _toEpoch)
internal
view
returns (uint256 rewardAmount)
{
@> rewardAmount =
(_amount * (rewardPerToken[_toEpoch] - rewardPerToken[_fromEpoch])) / PRECISION_18;
}

Let's assume a scenario where the user staked in the first epoch and a reward was added in that epoch. After that no reward was added. In the 7th epoch, the user wants to unstake what he staked in the first phase, whereby the FjordStaking::redeem function is triggered. Inside the body of the FjordStaking::redeemfunction, the FjordStaking::calculateRewardfunction is called where the parameter _fromEpoch is the current 7th epoch and _toEpoch is the first epoch. Since in the formula for calculating rewardAmount the value of rewardPerToken[_toEpoch] is the same as rewardPerToken[_fromEpoch], the reward will be zero. In this example, we see that the reward from the first phase will remain locked.

PoC

Place the following test in test/unit/unstake.t.sol:

function testUserCantClaimRewardFromDepositedEpoch() public {
address user=makeAddr("user");
deal(address(token), user, 2 ether);
uint256 userBalance=token.balanceOf(user);
console.log("Balanace of user at the begining:", userBalance);//2_000000000000000000
uint256 contractBalance=token.balanceOf(address(fjordStaking));
console.log("Balanace of contract at the begining:", contractBalance);//10_000000000000000000
uint256 epochBefore = fjordStaking.currentEpoch();
console.log("First stake epoch: ", epochBefore);
vm.startPrank(user);
token.approve(address(fjordStaking), 1 ether);
fjordStaking.stake(1 ether);// user stakes 1 ether
vm.stopPrank();
userBalance = token.balanceOf(user);
console.log("Balanace of user after stake:", userBalance);//1_000000000000000000
contractBalance=token.balanceOf(address(fjordStaking));
console.log("Balanace of contract after stake:", contractBalance);//11_0000000000000000000
vm.prank(minter);
fjordStaking.addReward(1 ether);// rewardAdmin adds reward in the first stake epoch
contractBalance = token.balanceOf(address(fjordStaking));
console.log("Balanace of contract after adding reward:",contractBalance);//12_000000000000000000
vm.warp(vm.getBlockTimestamp() + 7 * fjordStaking.epochDuration());// skip 7 epochs
(uint256 totalStakedBefore, uint256 unclaimedRewardsBefore, uint16 unredeemedEpochBefore, uint16 lastClaimedEpochBefore) = fjordStaking.userData(user);
console.log("-----------------------------------------");
console.log("totalStakedBefore", totalStakedBefore);
console.log("unclaimedRewardsBefore", unclaimedRewardsBefore);
console.log("unredeemedEpochBefore", unredeemedEpochBefore);
console.log("lastClaimedEpochBefore", lastClaimedEpochBefore);
console.log("-----------------------------------------");
vm.startPrank(user);
fjordStaking.unstake(1, 1 ether);
vm.stopPrank();
userBalance = token.balanceOf(user);
console.log("Balanace of user after unstaking:", userBalance);//2_000000000000000000
contractBalance = token.balanceOf(address(fjordStaking));
console.log("Balanace of contract after unstaking:", contractBalance);//11_000000000000000000
console.log("-----------------------------------------");
(uint256 totalStaked, uint256 unclaimedRewards, uint16 unredeemedEpoch, uint16 lastClaimedEpoch) = fjordStaking.userData(user);
console.log("totalStaked", totalStaked);
console.log("unclaimedRewards", unclaimedRewards);
console.log("unredeemedEpoch", unredeemedEpoch);
console.log("lastClaimedEpoch", lastClaimedEpoch);
console.log("-----------------------------------------");
vm.startPrank(user);
vm.expectRevert();
fjordStaking.claimReward(false);
vm.stopPrank();
}

Impact

If reward is added before or in the same epoch as when the first stake occurs, those reward funds will be locked in the contract.

Tools Used

Manual code review / Foundry tests

Recommendations

In the FjordStaking::addReward function, add a condition that will make it impossible to add a reward, if there is was no stakers at the start of the epoch.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

perseus Submitter
10 months ago
perseus Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
perseus Submitter
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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