Stargate – packages/contracts/contracts/Stargate.solrequestDelegationExit(uint256 _tokenId)unstake(uint256 _tokenId)_updatePeriodEffectiveStake(...)Stargate tracks delegators’ “effective stake” per validator and period in order to compute rewards fairly. This stake is increased when a delegation is created, and decreased when the delegation ends or is cancelled.
There is a timing pattern where the same delegation is decreased twice:
requestDelegationExit while the validator is still ACTIVE.unstake.The second decrease is applied on top of an already‑zeroed effective stake, which causes an arithmetic underflow inside _updatePeriodEffectiveStake. Because this helper is called deep in the unstake logic, the entire unstake transaction reverts.
Once the system is in this state, every attempt to unstake the NFT will revert, permanently locking the user’s staked VET. This matches HackenProof’s “Permanent lock of end‑user funds” category.
Effective stake updates are performed by:
function _updatePeriodEffectiveStake(
StargateStorage storage $,
address _validator,
uint256 _tokenId,
uint32 _period,
bool _isIncrease
) private {
uint256 effectiveStake = _calculateEffectiveStake($, _tokenId);
uint256 currentValue = $.delegatorsEffectiveStake[_validator].upperLookup(_period);
uint256 updatedValue = _isIncrease
? currentValue + effectiveStake
: currentValue - effectiveStake; // <─ underflow if currentValue < effectiveStake
$.delegatorsEffectiveStake[_validator].push(_period, SafeCast.toUint224(updatedValue));
}
Decreases (_isIncrease == false) are executed in two places for the same delegation lifecycle:
On requestDelegationExit (for ACTIVE delegations):
(, , , uint32 completedPeriods) =
$.protocolStakerContract.getValidationPeriodDetails(delegation.validator);
_updatePeriodEffectiveStake(
$,
delegation.validator,
_tokenId,
completedPeriods + 2,
false
);
On unstake, when the validator is EXITED or the delegation was PENDING:
(, , , , uint8 currentValidatorStatus, ) =
$.protocolStakerContract.getValidation(delegation.validator);
if (
currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
delegation.status == DelegationStatus.PENDING
) {
(, , , uint32 oldCompletedPeriods) =
$.protocolStakerContract.getValidationPeriodDetails(delegation.validator);
_updatePeriodEffectiveStake(
$,
delegation.validator,
_tokenId,
oldCompletedPeriods + 2,
false
);
}
If the user calls requestDelegationExit while the validator is still ACTIVE, the first call to _updatePeriodEffectiveStake(..., false) correctly removes the delegator from the effective‑stake checkpoint from a future period onward.
However, if before the user unstake, the validator later becomes EXITED, the unstake function will hit the second branch and call _updatePeriodEffectiveStake(..., false) again. At this point the effective stake for future periods is already zero, so currentValue will be 0, and the function attempts to compute:
updatedValue = 0 - effectiveStake;
This underflows and causes the entire unstake call to revert. Any subsequent unstake attempts will hit the same logic and revert again.
From the user’s perspective, their NFT can no longer be unstaked and their VET is permanently stuck in the protocol.
Below is a concise reproduction flow using the existing test scaffold (Hardhat + mocks), without relying on any external tooling.
Stake and delegate a single NFT
stargate.stake(levelId) with the exact required VET to mint an NFT tokenId.stargate.delegate(tokenId, validator) where validator is ACTIVE in ProtocolStakerMock.Make the delegation ACTIVE
protocolStakerMock.helper__setValidationCompletedPeriods(validator, 2).stargate.getDelegationStatus(tokenId) == DelegationStatus.ACTIVE.User requests exit while validator is ACTIVE (first decrease)
stargate.requestDelegationExit(tokenId) from the NFT owner._updatePeriodEffectiveStake(..., false) at a future period.Validator later becomes EXITED
protocolStakerMock.helper__setValidatorStatus(validator, VALIDATOR_STATUS_EXITED).completedPeriods again via helper__setValidationCompletedPeriods(validator, 4).stargate.getDelegationStatus(tokenId) == DelegationStatus.EXITED.Attempt to unstake (second decrease → revert)
stargate.unstake(tokenId) from the NFT owner._updatePeriodEffectiveStake, and the NFT cannot be unstaked anymore.Re‑attempts to unstake also revert
stargate.unstake(tokenId) continues to revert, proving permanent funds lock.This sequence is fully automated in the PoC test below.