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

Broken staking accounting in current epoch unstake logic

Summary

The unstake function in the FjordStaking has a logic flaw when handling unstaking in the current epoch. This is because the contract only decreases newStaked and doesn't update totalStaked or userData[msg.sender].totalStaked when a user unstakes in the current epoch. Malicious users could use this to game the protocol to receive more rewards than intended as rewards are distributed based on the totalStaked value

Vulnerability Details

The unstake function goes thus:

if (currentEpoch != _epoch) {
totalStaked -= _amount;
userData[msg.sender].totalStaked -= _amount;
} else {
// unstake immediately
newStaked -= _amount;
}

Now here's where the main issue emanates from. When a user unstakes tokens in the current epoch, the contract only decreases newStaked and DOESN'T update totalStaked or userData[msg.sender].totalStaked.

Lets consider 2 scenarios. 1st scenario will be Unstaking in a previous epoch and the second will be Unstaking in the current epoch

Scenario 1: Unstaking in a previous epoch`
  1. Alice stakes tokens:

    • Alice stakes 100 tokens in epoch 1.

    • totalStaked is increased by 100.

    • userData[msg.sender].totalStaked is increased by 100.

  2. Alice unstakes tokens in epoch 2:

    • Alice unstakes 50 tokens in epoch 2.

    • totalStaked is decreased by 50.

    • userData[msg.sender].totalStaked is decreased by 50.

In this scenario, the accounting is correct because both totalStaked and userData[msg.sender].totalStaked are updated.

Scenario 2: Unstaking in the current epoch
  1. Bob stakes tokens:

    • Bob stakes 100 tokens in epoch 1.

    • totalStaked is increased by 100.

    • userData[msg.sender].totalStaked is increased by 100.

  2. Bob unstakes tokens in the same epoch:

    • Bob unstakes 50 tokens in epoch 1.

    • newStaked is decreased by 50.

    • totalStaked and userData[msg.sender].totalStaked are not updated.

Here in this scenario, the accounting is INCORRECT because totalStaked and userData[msg.sender].totalStaked are not updated. So therefore:

This ccauses an inflated totalStaked value. The totalStaked value remains higher than the actual staked amount. This will cause incorrect reward calculations, as rewards are distributed based on the totalStaked value
Also, the user's totalStaked amount is not reduced, even though they have unstaked tokens therefore causing inconsistencies in the user's staking data.

In simpler terms, Bob(in our case) could exploit this to receive more rewards than he deserves. For example:

Bob stakes and unstakes in the same epoch

  • Bob stakes 100 tokens in epoch 1.

  • Bob unstakes 100 tokens in epoch 1.

As for the reward calculation,

  • The totalStaked value remains 100, even though Bob has unstaked all their tokens.

  • Rewards are calculated based on the inflated totalStaked value, leading to higher rewards for Bob.

Impact

Protocol could end up with incorrect reward calculations and potential exploitation by users such as Bob to receive more rewards than intended as rewards are distributed based on the totalStaked value.

Tools Used

Manual

Recommendations

Both newStaked and totalStaked (along with the user's total staked amount) should be updated when unstaking in the current epoch.

function unstake(uint16 _epoch, uint256 _amount)
external
checkEpochRollover
redeemPendingRewards
returns (uint256 total)
{
if (_amount == 0) revert InvalidAmount();
DepositReceipt storage dr = deposits[msg.sender][_epoch];
if (dr.epoch == 0) revert DepositNotFound();
if (dr.staked < _amount) revert UnstakeMoreThanDeposit();
// _epoch is same as current epoch then user can unstake immediately
if (currentEpoch != _epoch) {
// _epoch less than current epoch then user can unstake after at complete lockCycle
if (currentEpoch - _epoch <= lockCycle) revert UnstakeEarly();
}
//EFFECT
dr.staked -= _amount;
if (currentEpoch != _epoch) {
totalStaked -= _amount;
userData[msg.sender].totalStaked -= _amount;
} else {
// unstake immediately
newStaked -= _amount;
+ totalStaked -= _amount; // Ensure totalStaked reflects the current staked amount accurately
+ userData[msg.sender].totalStaked -= _amount; // Maintain user-specific staking accuracy
}
if (dr.staked == 0 && dr.vestedStaked == 0) {
// no longer a valid unredeemed epoch
if (userData[msg.sender].unredeemedEpoch == currentEpoch) {
userData[msg.sender].unredeemedEpoch = 0;
}
delete deposits[msg.sender][_epoch];
_activeDeposits[msg.sender].remove(_epoch);
}
total = _amount;
//INTERACT
fjordToken.safeTransfer(msg.sender, total);
points.onUnstaked(msg.sender, _amount);
emit Unstaked(msg.sender, _epoch, _amount);
}
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.