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
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.
The user's balance increases as expected, reflecting the penalty. After another week, the user claims their reward again.
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.
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);
fjordStaking.getStreamOwner(1403);
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;