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.
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
:
The newStaked
value is then only added to the totalStaked
when a new epoch starts:
However, the pendingRewardsPerToken
is calculated only over the totalStaked
value:
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:
From the above we can come to the following scenario:
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
.
No new rewards are added in this epoch and a new epoch starts.
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).
Alice's newStaked
is moved to totalStaked
.
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.
Bob's lastClaimedEpoch
is set to 1
and the lastEpochRewarded
is set to 1
.
1e18
tokens are added as a reward to this epoch. No reward calculations happen as if (latestEpoch > currentEpoch)
will be false in _checkEpochRollover()
.
A new epoch starts.
No new stakes happen and a new reward of 1e18
is added. lastClaimedEpoch
now changes the protocol state.
The currentBalance
will be 4e18
, with totalStaked = 1e18
, newStaked = 1e18
, so the pendingRewards = 2e18
.
This sets the rewardPerToken[epoch2] = 2e18
and sets the lastEpochRewarded = 2
.
Bob's newStaked
goes to the totalStaked = 2e18
.
A new epoch starts and Alice and Bob want to claim their rewards.
claimRewards(...)
also invokes _checkEpochRollover(...), where we do not have changes in the
currentBalanceand staked amounts, so now
pendingRewards = 0, which sets the
rewardPerToken[epoch3] = 2e18, as it is set to equal
rewardPerToken[lastEpochRewarded] + pendingRewardsPerToken.
lastEpochRewardedis now set to
3`.
In the rewards calculation for Alice we have:
Now for Bob we have:
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.
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.
Early stakers gain an unfair advantage over users who start staking at a later stage, which could lead to reduced participation in the protocol.
Manual review
This issue is not that straightforward to fix, as it will require some functionalities to change. Some ideas include:
Modify _checkEpochRollover()
to include newStaked
when calculating the pendingRewardsPerToken
value.
Add a snapshot mechanism so that calculations can take in account user's stakes at every point in the epoch.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.