This is updated via _updatePeriodEffectiveStake whenever delegations are added, moved, exited, or unstaked.
There is a sequence of interaction between the Stargate::requestDelegationExit, Stargate::unstake and Stargate::getDelegationStatus functions, which can cause the delegatorsEffectiveStake of a validator to be subtracted twice, corrupting the delegatorsEffectiveStake accounting and leading to a permanent freezing of funds.
The following stepper explains how the bug can occur:
1
Step
We have two users, Alice and Bob. Both of them staked and have some valid Stargate NFTs. Assume Alice and Bob staked the same amounts for simplicity (50 tokens each).
2
Step
They both delegate their stakes to the same validator. The validator's delegatorsEffectiveStake = 100 right now.
3
Step
While the validator is active Bob wants to exit, so he calls requestDelegationExit, which updates the delegatorsEffectiveStake mapping for the validator. Bob's amount will be subtracted from the total because he's exiting, so the delegatorsEffectiveStake of the validator will be 50 now.
4
Step
The validator exits. This enables the bug.
5
Step
Bob calls unstake. Because the validator exited in the meantime, unstake will reduce the validator's delegatorsEffectiveStake again. This is wrong because Bob's amount was already subtracted when he called requestDelegationExit. Excerpt from the code:
functionunstake(uint256_tokenId)externalwhenNotPausedonlyTokenOwner(_tokenId)nonReentrant{// ..// if the delegation is pending or the validator is exited or unknown// decrease the effective stake of the previous validatorif(currentValidatorStatus == VALIDATOR_STATUS_EXITED || delegation.status == DelegationStatus.PENDING){// get the completed periods of the previous validator(,,,uint32 oldCompletedPeriods)= $.protocolStakerContract.getValidationPeriodDetails(delegation.validator);// decrease the effective stake of the previous validator_updatePeriodEffectiveStake( $, delegation.validator, _tokenId, oldCompletedPeriods +2,false// decrease);}// ..}
6
Step
Bob will successfully exit, because the validator had an extra 50 tokens from Alice, but Alice's funds will be permanently locked.
Root cause
Double subtraction of delegators’ effective stake for the same delegation, combined with a global (per-validator) aggregation.
1
Issue
requestDelegationExit already removes the token’s effective stake from the delegatorsEffectiveStake[validator] mapping via:
2
Issue
If the validator exits before the user gets to unstake, but after they have already requested an exit, unstake will again call:
This will remove the same amount twice, although it was already fully removed at the first call.
3
Issue
Because delegatorsEffectiveStake is a global variable stored per validator, the second subtraction is taken out of the aggregated sum, which may now consist only of other delegators’ stake.
Impact
Critical – Permanent freezing of funds
At some point, when other delegators later attempt to unstake, _updatePeriodEffectiveStake(..., false) runs with currentValue == 0 and effectiveStake > 0, causing a checked arithmetic underflow and revert.
In other words, a user with a staked NFT will reach a state where they have a valid delegation that should be exited, but any call to unstake(...) reverts with a panic 0x11, because delegatorsEffectiveStake[validator] has been driven to 0 by other delegators’ double subtraction.
Recommended mitigation
Ensure that each delegation’s effective stake is added and removed exactly once.
Proof of Concept
Add this test in the following file Stake.test.ts and run it:
Test output:
As shown by the test, two users stake at the same level and delegate to the same validator. If the validator exits between requestDelegationExit and unstake called by one of the users, the validator's delegatorsEffectiveStake can be decreased twice, causing other delegators' funds to become permanently stuck.