The unstake function does not immediately update the exchange rates. It first computes the validatorSharesRemove = tokensToShares(amount, v.exchangeRate)with the old exchange rate.
Only afterwards, it updates the exchange rates (if the validator is not disabled):
// @audit shares are computed here with old rate
uint128 validatorSharesRemove = tokensToShares(amount, v.exchangeRate);
require(validatorSharesRemove > 0, "Unstake amount is too small");
if (v.disabledEpoch == 0) {
// @audit rates are updated here
updateGlobalExchangeRate();
updateValidator(v);
// ...
}
Impact
More shares for the amount are burned than required and users will lose rewards in the end.
POC
Demonstrating that users will lose rewards:
Assume someone staked 1000 amount and received 1000 shares, and v.exchangeRate = 1.0. (This user is the single staker)
Several epochs pass, interest accrues, and 1000 tokens accrue for the validator, tokensGivenToValidator = 1000. User should be entitled to 1000 in principal + 1000 in rewards = 2000 tokens.
But user calls unstake(1000), which sets validatorSharesRemove = tokensToShares(amount, v.exchangeRate) = 1000 / 1.0 = 1000. Afterwards, the exchange rate is updated: v.exchangeRate += tokensGivenToValidator / totalShares = 1.0 + 1.0 = 2.0. The staker is updated with s.shares -= validatorSharesRemove = 0 and s.staked -= amount = 0. And the user receives their 1000 tokens but notice how the user's shares are now at zero as well.
User tries to claim rewards calling redeemAllRewards which fails as the rewards are 0.
If the user had first called redeemAllRewards and unstake afterwards they'd have received their 2000 tokens.
Recommended Mitigation Steps
The exchange rates always need to be updated first before doing anything.
Move the updateGlobalExchangeRate() and updateValidator(v) calls to the beginning of the function.
Handle
cmichel
Vulnerability details
The
unstake
function does not immediately update the exchange rates. It first computes thevalidatorSharesRemove = tokensToShares(amount, v.exchangeRate)
with the old exchange rate.Only afterwards, it updates the exchange rates (if the validator is not disabled):
Impact
More shares for the amount are burned than required and users will lose rewards in the end.
POC
Demonstrating that users will lose rewards:
1000 amount
and received1000 shares
, andv.exchangeRate = 1.0
. (This user is the single staker)1000 tokens
accrue for the validator,tokensGivenToValidator = 1000
. User should be entitled to 1000 in principal + 1000 in rewards = 2000 tokens.unstake(1000)
, which setsvalidatorSharesRemove = tokensToShares(amount, v.exchangeRate) = 1000 / 1.0 = 1000
. Afterwards, the exchange rate is updated:v.exchangeRate += tokensGivenToValidator / totalShares = 1.0 + 1.0 = 2.0
. The staker is updated withs.shares -= validatorSharesRemove = 0
ands.staked -= amount = 0
. And the user receives their 1000 tokens but notice how the user's shares are now at zero as well.redeemAllRewards
which fails as therewards
are 0.If the user had first called
redeemAllRewards
andunstake
afterwards they'd have received their 2000 tokens.Recommended Mitigation Steps
The exchange rates always need to be updated first before doing anything. Move the
updateGlobalExchangeRate()
andupdateValidator(v)
calls to the beginning of the function.