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

Users dont get rewarded correctly

Summary

Users who staked 1 or more epoch before reward is added dont get the reward. This happens because they are still in newStaked, and the calculation for these is wrong.

Vulnerability Details

In the _checkEpochRollover() function, the pendingRewardsPerToken is calculated only for the totalStaked at that point.

// no distribute the rewards to the users coming in the current epoch
uint256 pendingRewards = (currentBalance + totalVestedStaked + newVestedStaked)
- totalStaked - newStaked - totalRewars;
uint256 pendingRewardsPerToken = (pendingRewards * PRECISION_18) / totalStaked;

The intention as seen in the CODE COMMENTS is to disregard users who staked in that current epoch(as newStaked is not used in the division). But this is wrong since, newStaked may contain users who have deposited in the epoch before,. This happens if addreward() is the first transaction to go through in an epoch.

  1. Users stake in epoch x;

  2. in the next epoch, addreward() is called first. Here the users who staked in the previous epoch are not calculated for rewards.(since they are still in newStaked and have not been moved to totalStaked.

To counteract this they had tried added the following code in _redeem(). (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L729-L749)

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
// @audit getting rewards for 1 week less ? staking 1st week, and staking 2nd week,
// 1st week stakes get no rewards when the 2nd week staking is done ? why (intended ?)
ud.unclaimedRewards += calculateReward(
deposit.staked + deposit.vestedStaked, ud.unredeemedEpoch, currentEpoch - 1
);
ud.unredeemedEpoch = 0;
ud.totalStaked += (deposit.staked + deposit.vestedStaked);
}

But this also doesnt rectify it, since the calculated reward function returns 0 in this scenario (this happens because rewardPerToken[ud.unredeemedEpoch] and rewardPerToken[currentEpoch-1] will be the same here).

Due to all these issues users miss out on their rewards

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 } from "forge-std/Test.sol";
import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol";
import { FjordPointsMock } from "./mocks/FjordPointsMock.sol";
import { ISablierV2LockupLinear } from "lib/v2-core/src/interfaces/ISablierV2LockupLinear.sol";
import { ISablierV2Lockup } from "lib/v2-core/src/interfaces/ISablierV2Lockup.sol";
import { Broker, LockupLinear } from "lib/v2-core/src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ud60x18 } from "@prb/math/src/UD60x18.sol";
import "lib/v2-core/src/libraries/Errors.sol";
import "forge-std/console.sol";
contract FjordStakingBase is Test {
event Staked(address indexed user, uint16 indexed epoch, uint256 amount);
event VestedStaked(
address indexed user, uint16 indexed epoch, uint256 indexed streamID, uint256 amount
);
event RewardAdded(uint16 indexed epoch, address rewardAdmin, uint256 amount);
event RewardClaimed(address indexed user, uint256 amount);
event EarlyRewardClaimed(address indexed user, uint256 rewardAmount, uint256 penaltyAmount);
event Unstaked(address indexed user, uint16 indexed epoch, uint256 stakedAmount);
event VestedUnstaked(
address indexed user, uint16 indexed epoch, uint256 stakedAmount, uint256 streamID
);
event ClaimReceiptCreated(address indexed user, uint16 requestEpoch);
event UnstakedAll(
address indexed user,
uint256 totalStakedAmount,
uint256[] activeDepositsBefore,
uint256[] activeDepositsAfter
);
event RewardPerTokenChanged(uint16 epoch, uint256 rewardPerToken);
event SablierWithdrawn(address indexed user, uint256 streamID, address caller, uint256 amount);
event SablierCanceled(address indexed user, uint256 streamID, address caller, uint256 amount);
uint256 constant addRewardPerEpoch = 1 ether;
FjordStaking fjordStaking;
FjordPoints fjordpoints;
MockERC20 token;
address minter = makeAddr("minter");
address newMinter = makeAddr("new_minter");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address charlie = makeAddr("charlie");
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
address points;
bool isMock = true;
ISablierV2LockupLinear SABLIER = ISablierV2LockupLinear(SABLIER_ADDRESS);
address authorizedSender = address(this);
bool isFuzzOrInvariant = false;
function beforeSetup() internal virtual { }
function afterSetup() internal virtual { }
function setUp() public {
beforeSetup();
if (!isFuzzOrInvariant) {
vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 19_595_905 });
}
fjordpoints = new FjordPoints();
points = address(fjordpoints);
token = new MockERC20("Fjord", "FJO", 18);
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, points);
deal(address(token), address(this), 10000 ether);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), minter, 10000 ether);
vm.prank(minter);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), alice, 10000 ether);
vm.prank(alice);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), bob, 10000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), charlie, 10000 ether);
vm.prank(charlie);
token.approve(address(fjordStaking), 10000 ether);
fjordpoints.setStakingContract(address(fjordStaking));
afterSetup();
}
function testcase() public {
uint256 EPOCH_DURATION = 1 weeks;
vm.prank(alice);
fjordStaking.stake(10 ether); // alice stakes in epoch 1
vm.warp(vm.getBlockTimestamp() + 1*EPOCH_DURATION);
vm.prank(bob);
fjordStaking.stake(10 ether); // bob stakes in epoch 2
vm.warp(vm.getBlockTimestamp() + 1*EPOCH_DURATION);
vm.prank(minter);
fjordStaking.addReward(100 ether); // reward added in epoch 3
// since bob had staked in previous epoch, his reward should be calculated
// this as you will see below doesnt happen
vm.warp(vm.getBlockTimestamp() + 7*EPOCH_DURATION);
// alice claims after 9 epochs and gets reward
vm.prank(alice);
(uint256 alice_reward, uint256 alice_penalty) = fjordStaking.claimReward(false);
console.log("Alice's reward is :", alice_reward);
console.log("Alice's penalty is :", alice_penalty);
// bob claims after 8 epochs and doesnt get anything
vm.prank(bob);
vm.expectRevert(FjordStaking.NothingToClaim.selector);
(uint256 bob_reward, uint256 bob_penalty) = fjordStaking.claimReward(false); // will revert since nothing to claim
console.log("Bob's reward is :", bob_reward);
console.log("Bob's penalty is :", bob_penalty);
// eventhough bob didnt join in the same epoch as the reward distribution, he gets nothing
// since only totalStaked is taken for reward calculation. And since he had staked only once,
// his staked amount was still in newStaked and thus ignored. (also the _redeem function didnt
// his reward correctly
}

OUTPUT

Alice's reward is : 100000000000000000000
Alice's penalty is : 0
Bob's reward is : 0
Bob's penalty is : 0

We can see that eventhoug bob staked the epoch before addReward was called, he doesnt get any rewards.

Impact

Multiple impacts:

  1. Functionality that only the users who came in this epoch will be disregarded in pendingRewardPerToken calculation is wrong (older users are also disregarded).

  2. Such users dont get rewards that they deserve. thus leading to an unfair staking for them.

Tools Used

Manual Review

Recommendations

To mitigate this issue, one way is to add the checkEpochRollovermodifer to addRedeem function.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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