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

First user to stake just before an epoch rollover will receive a disproportionately large share of initial rewards

Summary

The FjordStaking contract implements an epoch-based staking system supporting regular and vested token staking via Sablier. It includes mechanisms for stake management, reward distribution, and early unstaking penalties. However, the contract's reward distribution mechanism introduces a significant delay in reward accrual for new stakers, leading to an uneven distribution of rewards that favors early participants.

Vulnerability Details

The core issue in the FjordStaking contract lies in how new stakes are handled and how rewards are calculated across epochs.

When a user stakes tokens, their stake is initially recorded in newStaked:

function stake(uint256 _amount) external checkEpochRollover redeemPendingRewards {
__SNIP__
newStaked += _amount; // new stakes are added as newStake
__SNIP__
}

The newStaked value is then only added to the totalStaked when a new epoch starts:

function _checkEpochRollover() internal {
__SNIP__
if (latestEpoch > currentEpoch) {
__SNIP__
totalStaked += newStaked;
newStaked = 0;
__SNIP__
}
}

However, the pendingRewardsPerToken is calculated only over the totalStaked value:

function _checkEpochRollover() internal {
__SNIP__
uint256 pendingRewards = (currentBalance + totalVestedStaked + newVestedStaked)
- totalStaked - newStaked - totalRewards;
uint256 pendingRewardsPerToken = (pendingRewards * PRECISION_18) / totalStaked;
__SNIP__
}
}

Finally, the actual unclaimedReward calculation happens in the redeem(...) function and is based on the difference between rewardsPerToken for the epoch between it is being calculated. Users have a separate userData structure, which also holds totalStaked amount, which again is only updated after a successful redeem:

function _redeem(address sender) internal {
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) {
__SNIP__
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;
}

From the above we can come to the following scenario:

  1. Alice stakes1e18 tokens in epoch 1. Her newStaked = 1e18 and totalStaked = 0. Her lastClaimEpoch is set to 0 and the ud.unredeemedEpoch, lastEpochRewarded is also 0.

  2. No new rewards are added in this epoch and a new epoch starts.

  3. Bob now stakes 1e18, so the _checkEpochRollover now indicates a change in epochs and sets the rewardPerToken[epoch1] = 0 (as totalStaked is still 0, we don't have pendingReward calculations so the rewardPerToken is not changed, and it is initially 0).

  4. Alice's newStaked is moved to totalStaked.

  5. The redeem() function doesn't make any changes for Bob, as if (ud.unredeemedEpoch > 0 && ud.unredeemedEpoch < currentEpoch) is false, so his UserData.totalStaked does not indicate a change.

  6. Bob's lastClaimedEpoch is set to 1 and the lastEpochRewarded is set to 1.

  7. 1e18 tokens are added as a reward to this epoch. No reward calculations happen as if (latestEpoch > currentEpoch) will be false in _checkEpochRollover().

  8. A new epoch starts.

  9. No new stakes happen and a new reward of 1e18 is added. lastClaimedEpoch now changes the protocol state.

  10. The currentBalance will be 4e18, with totalStaked = 1e18, newStaked = 1e18, so the pendingRewards = 2e18.

  11. This sets the rewardPerToken[epoch2] = 2e18 and sets the lastEpochRewarded = 2.

  12. Bob's newStaked goes to the totalStaked = 2e18.

  13. A new epoch starts and Alice and Bob want to claim their rewards.

  14. claimRewards(...) also invokes _checkEpochRollover(...), where we do not have changes in the currentBalanceand staked amounts, so nowpendingRewards = 0, which sets the rewardPerToken[epoch3] = 2e18, as it is set to equal rewardPerToken[lastEpochRewarded] + pendingRewardsPerToken. lastEpochRewardedis now set to3`.

  15. In the rewards calculation for Alice we have:

ud.unclaimedRewards += calculateReward(ud.totalStaked, ud.lastClaimedEpoch, currentEpoch - 1) = 0 * (rewardPerToken[epoch2] - rewardPerToken[epoch 0]) = 0 // ud.lastClaimedEpoch is 0, as this is Alice staked in epoch 1, so lastClaimedEpoch = stake epoch - 1
... later the unclaimed rewards gets updated
ud.unclaimedRewards += calculateReward(deposit.staked + deposit.vestedStaked, ud.unredeemedEpoch, currentEpoch - 1) = 1e18 * (rewardPerToken[epoch2] - rewardPerToken[epoch1]) = (1e18 * (2e18 - 0)) / 1e18 = 2e18 // there is division by PRECISION_18; ud.unredeemedEpoch = the epoch that Alice staked in
  1. Now for Bob we have:

ud.unclaimedRewards += calculateReward(ud.totalStaked, ud.lastClaimedEpoch, currentEpoch - 1) = 0 * (rewardPerToken[epoch3] - rewardPerToken[epoch 1]) = 0 // ud.lastClaimedEpoch is 1, as this is as Bob staked in epoch 2
...
ud.unclaimedRewards += calculateReward(deposit.staked + deposit.vestedStaked, ud.unredeemedEpoch, currentEpoch - 1) = 1e18 * (rewardPerToken[epoch3] - rewardPerToken[epoch2]) = 1e18* (2e18 - 2e18) = 0 // ud.unredeemedEpoch is the epoch Bob staked in
  1. From the above we can see that Alice has claimed all of the rewards and Bob has nothing left to claim and his claimRewards(...) call will revert.

  2. It will only be on the next reward addition, that Bob will start accumulating rewards and that the proportion of rewards becomes equal between users.

Coded PoC
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
import { Test, console } from "forge-std/Test.sol";
import { FjordStaking } from "../src/FjordStaking.sol";
import { FjordPoints } from "../src/FjordPoints.sol";
import { FjordToken } from "../src/FjordToken.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 { ud60x18 } from "@prb/math/src/UD60x18.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FjordAuditTests is Test {
FjordStaking fjordStaking;
FjordToken token;
address minter = makeAddr("minter");
address newMinter = makeAddr("new_minter");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address naruto = makeAddr("naruto");
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
address points;
bool isMock = false;
ISablierV2LockupLinear SABLIER = ISablierV2LockupLinear(SABLIER_ADDRESS);
address authorizedSender = address(this);
function setUp() public {
token = new FjordToken();
points = address(new FjordPoints());
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, points);
FjordPoints(points).setStakingContract(address(fjordStaking));
FjordPoints(points).setPointsPerEpoch(1 ether);
deal(address(token), address(this), 10_000 ether);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), minter, 10_000 ether);
vm.prank(minter);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), alice, 10_000 ether);
vm.prank(alice);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), bob, 10_000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), naruto, 10_000 ether);
vm.prank(naruto);
token.approve(address(fjordStaking), 10_000 ether);
}
function testFirstUserToStakeGetTheWholeFirstReward() public {
console.log("Alice's balance before stake: %d", token.balanceOf(address(alice)));
console.log("Bob's balance before stake: %d", token.balanceOf(address(bob)));
uint256 aliceStake = 0;
uint256 aliceInitialBalance = token.balanceOf(address(alice));
uint256 bobStake = 0;
uint256 bobInitialBalance = token.balanceOf(address(bob));
uint256 totalRewards = 0;
vm.prank(alice);
fjordStaking.stake(1 ether);
aliceStake += 1 ether;
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration());
vm.prank(bob);
fjordStaking.stake(1 ether);
bobStake += 1 ether;
_addRewardAndEpochRollover(1 ether, 1);
totalRewards += 1 ether;
vm.prank(minter);
fjordStaking.addReward(1 ether);
totalRewards += 1 ether;
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration());
vm.prank(bob);
vm.expectRevert(FjordStaking.NothingToClaim.selector);
fjordStaking.claimReward(false);
_addRewardAndEpochRollover(1 ether, 1); // From this reward onwards, the rewards will be split between Alice and Bob equally
totalRewards += 1 ether;
vm.prank(alice);
fjordStaking.claimReward(false);
vm.prank(bob);
fjordStaking.claimReward(false);
_addRewardAndEpochRollover(0, 6);
vm.prank(alice);
fjordStaking.completeClaimRequest();
vm.prank(bob);
fjordStaking.completeClaimRequest();
console.log("Alice's balance after reward claim: %d", token.balanceOf(address(alice)));
console.log("Bob's balance after reward claim: %d", token.balanceOf(address(bob)));
assertEq(
token.balanceOf(address(alice)),
aliceInitialBalance - aliceStake + (totalRewards - 0.5 ether)
);
assertEq(
token.balanceOf(address(bob)), bobInitialBalance - bobStake + (totalRewards - 2.5 ether)
);
}
function _addRewardAndEpochRollover(uint256 reward, uint256 times) internal returns (uint16) {
vm.startPrank(minter);
for (uint256 i = 0; i < times; i++) {
if (reward > 0) fjordStaking.addReward(reward);
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration());
}
vm.stopPrank();
return fjordStaking.currentEpoch();
}
}

Impact

Early stakers gain an unfair advantage over users who start staking at a later stage, which could lead to reduced participation in the protocol.

Tools Used

Manual review

Recommendations

This issue is not that straightforward to fix, as it will require some functionalities to change. Some ideas include:

  1. Modify _checkEpochRollover() to include newStaked when calculating the pendingRewardsPerToken value.

  2. Add a snapshot mechanism so that calculations can take in account user's stakes at every point in the epoch.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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