A delegator can trigger two scheduled decreases of the validator’s cumulative delegators effective stake by first calling requestDelegationExit while the validator is ACTIVE and later calling unstake after the validator becomes EXITED.
Vulnerability Details
Updates of delegatorsEffectiveStake are handled through _updatePeriodEffectiveStake, which reads the current cumulative value for _period using upperLookup and then adds or subtracts the token’s current effective stake:
function_updatePeriodEffectiveStake(StargateStoragestorage $,address_validator,uint256_tokenId,uint32_period,bool_isIncrease)private{// calculate the effective stakeuint256 effectiveStake =_calculateEffectiveStake($, _tokenId);// get the current effective stakeuint256 currentValue = $.delegatorsEffectiveStake[_validator].upperLookup(_period);// calculate the updated effective stakeuint256 updatedValue = _isIncrease? currentValue + effectiveStake: currentValue - effectiveStake;// push the updated effective stake $.delegatorsEffectiveStake[_validator].push(_period, SafeCast.toUint224(updatedValue));}
There are two different paths which schedule a decrease of delegatorsEffectiveStake. These are represented below as steps.
1
Exit requested while validator is ACTIVE (schedules a decrease at completedPeriods + 2)
This path schedules a decrease when the delegator calls requestDelegationExit while the validator is ACTIVE.
2
Unstake when validator is EXITED (or delegation is PENDING) (schedules a decrease at oldCompletedPeriods + 2)
This path schedules a decrease when unstake is called and the validator is EXITED or the delegation is PENDING.
Because there is no specific guard preventing both scheduling actions for the same token, both scheduled decreases may subtract the same token’s effective stake at different future periods.
This is the snippet of the faulty code in unstake():
Impact Details
The share formula used by _claimableRewardsForPeriod:
Consequences:
When the second decrease lands on a period where the cumulative already reflects the first decrease (zero in a single-delegator validator), the subtraction attempts cause an underflow and therefore revert inside _updatePeriodEffectiveStake.
When there are multiple delegators so the cumulative stays > 0, the double subtraction understates the denominator from that later period onward, inflating all other positions' shares.
Critical impact: Permanent freezing of funds — in pools where there is a sole delegator, the second scheduled decrease causes an underflow reverting _updatePeriodEffectiveStake. All flows that must apply this scheduled decrease will keep reverting, effectively freezing funds permanently.
Proof of Concept
PoC logs and test cases (expand to view)
PoC 1 — demonstrates the vulnerability (revert / underflow when sole delegator):
PoC 2 — demonstrates expected behaviour when validator stays ACTIVE (no second scheduled decrease):
PoC tests to copy into Delegation.test.ts:
Summary
Root cause: lack of guard to prevent scheduling the same token's effective stake to be decreased twice via two different code paths (requestDelegationExit while ACTIVE and unstake when EXITED/PENDING).
Consequences: underflow reverts when sole delegator -> permanent freeze; incorrect reward distribution when multiple delegators -> inflated shares for others.
Files referenced: Stargate.sol (target repository link above).
// Get the latest completed period of the validator
(, , , uint32 completedPeriods) = $.protocolStakerContract.getValidationPeriodDetails(
delegation.validator
);
(, uint32 exitBlock) = $.protocolStakerContract.getDelegationPeriodDetails(delegationId);
// decrease the effective stake
_updatePeriodEffectiveStake($, delegation.validator, _tokenId, completedPeriods + 2, false);
if (
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
);
// if the delegation is pending or the validator is exited or unknown
// decrease the effective stake of the previous validator
if (
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
);
}
if (delegation.status == DelegationStatus.PENDING) {
// We emit an event to signal that the NFT exited the current pending delegation
// to ensure that indexers can correctly track the delegation status
emit DelegationExitRequested(
_tokenId,
delegation.validator,
delegation.delegationId,
Clock.clock()
);
}
uint256 delegationId = $.delegationIdByTokenId[_tokenId];
(address validator, , , ) = $.protocolStakerContract.getDelegation(delegationId);
uint256 delegationPeriodRewards = $.protocolStakerContract.getDelegatorsRewards(
validator,
_period
);
// get the effective stake of the token
uint256 effectiveStake = _calculateEffectiveStake($, _tokenId);
// get the effective stake of the delegator in the period
uint256 delegatorsEffectiveStake = $.delegatorsEffectiveStake[validator].upperLookup(
_period
);
// avoid division by zero
if (delegatorsEffectiveStake == 0) {
return 0;
}
// return the claimable amount
return (effectiveStake * delegationPeriodRewards) / delegatorsEffectiveStake;
delegatorsEffectiveStake before unstake: 0
balance before unstake: 49999998999435010749972320
expected payout: 1000000000000000000
delegatorsEffectiveStake after revert: 0
balance after revert: 49999998999309969383653880
delta: -125041366318440
✔ schedules a second decrease and reverts (underflow) if I am the sole delegator (43ms)
delegatorsEffectiveStake before unstake: 0
balance before unstake: 49999998999435010749972320
expected payout: 1000000000000000000
delegatorsEffectiveStake after unstake: 0
balance after unstake: 49999999998914239240352854
delta: 999479228490380534
✔ does not schedule a second decrease when the validator stays active (67ms)