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

Stakers can't claim reward

Summary

In FjordStaking.sol, users can stake an amount of the FjordToken and are supposed to be ELIGIBLE to claimReward after at least an epoch has passed (though early claiming is penalized). But the current implementation denies them.

Vulnerability Details

Assuming that a bunch of stakers stake on the very first epoch just after the protocol is deployed. Because they just staked their stake will initially be newStaked and totalStaked will remain 0. This is good so far.

But as per the current design, the protocol expects some activity from users to happen (in a different epoch) so that totalStaked will be updated so that eventually the stakers will be able to claim rewards.

Say no activity happened, the stakers wait for several epochs and they call the function claimReward. It will revert!

The root cause of this is analyzed below;

When claimReward is called;

user's unclaimedRewards is not updated correctly.

function _redeem(address sender) internal {
// ...
if ( //..
ud.unclaimedRewards += calculateReward(
deposit.staked + deposit.vestedStaked,
ud.unredeemedEpoch,
currentEpoch - 1
);

when we evaluate the calculateReward function, we notice it is using rewardPerToken for reward amount calculation

function calculateReward(
// ...
rewardAmount =
(_amount *
(rewardPerToken[_toEpoch] - rewardPerToken[_fromEpoch])) /
PRECISION_18;

Evaluating rewardPerToken shows that it is updated to new value only when totalStaked > 0 in _checkEpochRollover. But remember that at this point there was no other activity in different epoch so totalStaked remained at 0 thus rewardPerToken for all the epochs remains 0

function _checkEpochRollover() internal {
// ...
if (totalStaked > 0) {
//...
for (uint16 i = lastEpochRewarded + 1; i < currentEpoch; i++) {
rewardPerToken[i] =
rewardPerToken[lastEpochRewarded] +
pendingRewardsPerToken;
emit RewardPerTokenChanged(i, rewardPerToken[i]);
}
} else { //.. }
totalStaked += newStaked;
// ...

totalStaked is updated too late and therefore since rewardPerToken for the epochs are used in the calculation of the reward, the unclaimedRewards is evaluated to zero thus claimReward reverts

function claimReward( //..
// ...
if (ud.unclaimedRewards == 0) revert NothingToClaim();
// ...

PoC

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../src/FjordStaking.sol";
import {FjordPoints} from "../src/FjordPoints.sol";
import {Test, console} from "forge-std/Test.sol";
import {FjordToken} from "../src/FjordToken.sol";
contract ClaimTest is Test {
FjordStaking fjordStaking;
FjordToken fjordToken;
address rewardAdmin = makeAddr("rewardAdmin");
address alice = makeAddr("alice"); // starts with 100 ether fjord tokens
address deployer = makeAddr("deployer");
FjordPoints fjordPoints;
function setUp() public {
fjordToken = new FjordToken();
fjordPoints = new FjordPoints();
fjordStaking = new FjordStaking(
address(fjordToken),
rewardAdmin,
makeAddr("dummy_sablier"),
address(0),
address(fjordPoints)
);
fjordPoints.setStakingContract(address(fjordStaking));
deal(address(fjordToken), alice, 100 ether);
deal(address(fjordToken), rewardAdmin, 100 ether);
vm.prank(alice);
fjordToken.approve(address(fjordStaking), type(uint256).max);
vm.prank(rewardAdmin);
fjordToken.approve(address(fjordStaking), type(uint256).max);
}
function test_claimReward() public {
vm.prank(alice); // alice stakes in 1st epoch
fjordStaking.stake(50 ether);
// very many days pass by
vm.warp(block.timestamp + 100 days);
vm.prank(rewardAdmin); // Reward admin adds rewards
fjordStaking.addReward(5 ether);
vm.prank(alice); // alice can't claim her rewards
vm.expectRevert(FjordStaking.NothingToClaim.selector);
fjordStaking.claimReward(false);
}
}

Impact

Tools Used

Manual Review, Foundry

Recommendations

Consider the edge case when no activity happens after users stake their tokens and when they claim their reward update their rewards accordingly.

Updates

Lead Judging Commences

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.