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

`FjordStaking::stakeVested` Can Claim Weekly Rewards Early, While Long-Term Stakers Miss Out Due to Non-Stacked Epoch Rewards in `FjordStaking::claimReward`.

Summary

Description: Users who stake using the stakeVested function can begin claiming rewards after the first week, even with a penalty, and continue claiming weekly. These users benefit because the RewardPerTokenChanged is updated after each claim, allowing them to accumulate rewards. In contrast, users who wait multiple weeks without claiming are disadvantaged, as claimReward only grants rewards for a single epoch, regardless of how long they waited. Additionally, there appears to be a discrepancy in the reward calculation, where the amount received differs from what is emitted in the EarlyRewardClaimed event.

Vulnerability Details

  1. The user stakes their asset and then calls claimReward with the parameter set to true, allowing them to receive their reward after the first week with a penalty applied.

  2. The user's balance increases as expected, reflecting the penalty. After another week, the user claims their reward again.

  3. We then skip a total of 5 weeks, and the user claims once more. Despite expecting 5 weeks' worth of rewards, the user receives only a slightly larger amount, indicating they only received one epoch's worth due to the RewardPerTokenChanged being updated.

  4. We call balanceOf and notice that the token balance is slightly different from the expected amount, indicating a discrepancy between the protocol's expected reward value and what the claimReward function actually provides.

function test_VestedStakersCanClaimWeekly() public {
createStreamAndStake();
_addRewardAndEpochRollover(1 ether, 5);
uint256 streamID = createStreamAndStake();
fjordStaking.getStreamData(address(alice), streamID);
uint256 streamIDTwo = createStreamAndStake();
fjordStaking.getStreamData(address(alice), streamIDTwo);
// streamId = 1403;
fjordStaking.getStreamOwner(1403); // ensuring its alice that owns this streamID;
vm.startPrank(alice);
token.balanceOf(address(alice));
fjordStaking.claimReward(true);
skip(1 weeks);
fjordStaking.claimReward(true);
token.balanceOf(address(alice));
skip(5 weeks);
fjordStaking.claimReward(true);
skip(1 weeks);
token.balanceOf(address(alice));
fjordStaking.claimReward(true);
token.balanceOf(address(alice));
}

Impact

This creates unfairness and potential misuse of the staking functions, resulting in some users being dissatisfied and leading to uneven distribution of rewards among participants. Additionally, it grants users early access to rewards, contrary to the expectation that rewards should be penalized between weeks 3 and 6 of staking.

Tools Used

Manual Review

Recommendations

For the math miscalculation, it seems to stem from the penalty division, leading to discrepancies. It might be beneficial to use OpenZeppelin SafeMath.sol or Math.sol contracts to ensure accurate calculations.

Users should not be allowed to withdraw their rewards until after 3 weeks, with penalties applied as described in the protocol. Implement a check to ensure that the initial unclaimedRewards in the userData is 3 or above, and add a boolean flag to indicate whether the user has claimed rewards within their first 3 weeks.

For the staking issue, consider attaching a number of epochs to each user. This way, when the checkEpochRollover modifier is called, you can retrieve the number of epochs the user is entitled to, ensuring correct reward distribution.

struct UserData {
uint256 totalStaked;
uint256 unclaimedRewards;
uint16 unredeemedEpoch;
uint16 lastClaimedEpoch;
+ bool penaltyApplies;
}
function claimReward(bool _isClaimEarly)
external
checkEpochRollover
redeemPendingRewards
returns (uint256 rewardAmount, uint256 penaltyAmount)
{
//CHECK
UserData storage ud = userData[msg.sender];
if (
claimReceipts[msg.sender].requestEpoch > 0
|| claimReceipts[msg.sender].requestEpoch >= currentEpoch - 1
) revert ClaimTooEarly();
if (ud.unclaimedRewards == 0) revert NothingToClaim();
+ if (ud.unclaimedRewards >= 3 || ud.penaltyApplies == true);
//EFFECT
if (!_isClaimEarly) {
claimReceipts[msg.sender] =
ClaimReceipt({ requestEpoch: currentEpoch, amount: ud.unclaimedRewards });
emit ClaimReceiptCreated(msg.sender, currentEpoch);
return (0, 0);
}
- rewardAmount = ud.unclaimedRewards;
+ rewardAmount = ud.unclaimedRewards * epochAmount;
penaltyAmount = rewardAmount / 2; //openzeppelin/safeMath here.
rewardAmount -= penaltyAmount;
if (rewardAmount == 0) return (0, 0);
totalRewards -= (rewardAmount + penaltyAmount);
userData[msg.sender].unclaimedRewards -= (rewardAmount + penaltyAmount);
//INTERACT
fjordToken.safeTransfer(msg.sender, rewardAmount);
emit EarlyRewardClaimed(msg.sender, rewardAmount, penaltyAmount);
}

Add the updating of the epochAmount within the _checkEpochRollover function.

function _checkEpochRollover() internal {
+ epochAmount = 0;
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;
+ epoch ++;
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
} else {
for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
rewardPerToken[i] = rewardPerToken[lastEpochRewarded];
+ epoch ++;
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
}

Add the slot.

/// @notice Constant
uint256 public constant PRECISION_18 = 1e18;
/// @notice Claim cooldown duration in epoch cycles.
uint8 public constant claimCycle = 3;
+ uint8 epochAmount;
Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

EyanIngles Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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