Status DataClose notification

VeChain Disclosed Report

Permanent freeze of delegator funds when validator exits after user‑initiated exit

Company
Created date
Nov 23 2025

Target

hidden

Vulnerability Details

Impacted components

  • Contract: Stargatepackages/contracts/contracts/Stargate.sol
  • Functions:
    • requestDelegationExit(uint256 _tokenId)
    • unstake(uint256 _tokenId)
    • Internal helper _updatePeriodEffectiveStake(...)

description

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:

  1. First, when the user calls requestDelegationExit while the validator is still ACTIVE.
  2. Later, when the validator itself transitions to EXITED and the user finally calls 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.

Root cause (code‑level)

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:

  1. On requestDelegationExit (for ACTIVE delegations):

    (, , , uint32 completedPeriods) =
        $.protocolStakerContract.getValidationPeriodDetails(delegation.validator);
    
    _updatePeriodEffectiveStake(
        $,
        delegation.validator,
        _tokenId,
        completedPeriods + 2,
        false
    );
    
  2. 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.


Validation steps

Below is a concise reproduction flow using the existing test scaffold (Hardhat + mocks), without relying on any external tooling.

  1. Stake and delegate a single NFT

    • Call stargate.stake(levelId) with the exact required VET to mint an NFT tokenId.
    • Call stargate.delegate(tokenId, validator) where validator is ACTIVE in ProtocolStakerMock.
  2. Make the delegation ACTIVE

    • Call protocolStakerMock.helper__setValidationCompletedPeriods(validator, 2).
    • Confirm stargate.getDelegationStatus(tokenId) == DelegationStatus.ACTIVE.
  3. User requests exit while validator is ACTIVE (first decrease)

    • Call stargate.requestDelegationExit(tokenId) from the NFT owner.
    • This schedules a decrease of effective stake via _updatePeriodEffectiveStake(..., false) at a future period.
  4. Validator later becomes EXITED

    • Call protocolStakerMock.helper__setValidatorStatus(validator, VALIDATOR_STATUS_EXITED).
    • Optionally increase completedPeriods again via helper__setValidationCompletedPeriods(validator, 4).
    • Confirm stargate.getDelegationStatus(tokenId) == DelegationStatus.EXITED.
  5. Attempt to unstake (second decrease → revert)

    • Call stargate.unstake(tokenId) from the NFT owner.
    • The call reverts due to underflow in _updatePeriodEffectiveStake, and the NFT cannot be unstaked anymore.
  6. Re‑attempts to unstake also revert

    • Any future call to stargate.unstake(tokenId) continues to revert, proving permanent funds lock.

This sequence is fully automated in the PoC test below.


Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$612
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden