Status DataClose notification

VeChain Disclosed Report

Permanent Lock of Staked VET via Stake Underflow in unstake()

Company
Created date
Nov 22 2025

Target

hidden

Vulnerability Details

Vulnerability Description

The Stargate contract contain a logic error in the unstake() that cause a permanent lock of user funds when a validator exit after the user has already requested a delegation exit.

The protocol track a validator stake to calculate rewards. This stake must be decremented exactly once when a user stop delegating. However, the unstake() attempt to decrement this stake based on the Validator Status without verifying if the user has already decremented their stake through requestDelegationExit() call.

The Logical Flaw:

  1. When a user call requestDelegationExit(), the user stake contribution is immediately removed from the validator's delegatorsEffectiveStake.
  2. If the validator move to the EXITED status (normal lifecycle: slashing, voluntary exit, or removal), the unstake() detect this through currentValidatorStatus == VALIDATOR_STATUS_EXITED.
  3. Because the code lack a check for a prior user exit, it execute the decrement logic a second time.
  4. Then attempting to subtract the user stake from a value that was already reduced (often resulting in 0 for the user's share) cause an Underflow.

As unstake() is the only mechanism to retrieve the staked principal (VET) and the transaction unconditionally revert due to the underflow, the user fund become permanently trapped in the contract.

Exploit Scenario

  • Alice: A user who stake 100,000 VET.
  • Validator V: An active Authority Masternode.

Steps:

  1. Delegation: Alice stake 100,000 VET and delegate to Validator V. The protocol record delegatorsEffectiveStake[Validator V] += 100,000.
  2. User Exit: Alice decide to stop delegating and call requestDelegationExit().
    • The protocol correctly update: delegatorsEffectiveStake[Validator V] -= 100,000.
    • Alice must now wait for the period to end before unstaking.
  3. Validator Exit: During this waiting period, Validator V is "removed" or voluntarily exit the network. The protocol update Validator V status to EXITED.
  4. The Lock: Alice call unstake() to retrieve her 100,000 VET.
    • The contract check currentValidatorStatus. It is EXITED.
    • The contract enter the cleanup block and attempt to decrement the stake again: delegatorsEffectiveStake[Validator V] -= 100,000.
    • Since the stake was already removed in Step 2, the value underflow.
  5. The transaction revert. Alice cannot withdraw her VET. Her funds are permanently locked.

Recommendation

Modify the unstake() to check if the delegation exit has already been requested (and then the stake already removed) before attempting to decrement the stake. This can be determined by checking if delegation.endPeriod is set to a value other than type(uint32).max.

    function unstake(uint256 _tokenId) ... {
        // ... setup code ...

        (, , , , uint8 currentValidatorStatus, ) = $.protocolStakerContract.getValidation(
            delegation.validator
        );

+       // Fix: Check if exit was already requested (stake already removed)
+       bool exitAlreadyRequested = delegation.endPeriod != type(uint32).max;

        // Only decrement if validator is EXITED and we haven't already done so
        if (
-           currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
-           delegation.status == DelegationStatus.PENDING
+           (currentValidatorStatus == VALIDATOR_STATUS_EXITED || delegation.status == DelegationStatus.PENDING) &&
+           !exitAlreadyRequested
        ) {
            // existing decrement logic 
            _updatePeriodEffectiveStake(..., false);
        }
        
        // ...
    }

Validation steps

Add to packages/contracts/test/unit/Stargate/Delegation.test.ts

it("Unstake Revert if Validator Exit after User Request", async () => {
        // Setup: Stake using Mocks
        const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
        await stargateContract.connect(user).stake(LEVEL_ID, { value: levelSpec.vetAmountRequiredToStake });
        const tokenId = await stargateNFTMock.getCurrentTokenId();

        // Delegate to Validator
        await stargateContract.connect(user).delegate(tokenId, validator.address);
        
        // Fast-forward to make delegation active
        // I manually set the mock state to simulate time passing
        await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, 2);
        
        // Verify status is active
        expect(await stargateContract.getDelegationStatus(tokenId)).to.equal(2); 

        // User request exit
        // This decrement the stake for the validator ONCE.
        await stargateContract.connect(user).requestDelegationExit(tokenId);
        
        // Validator Exit (slashed/jailed/stopped)
        // force the mock validator status to EXITED (Enum value 3)
        await protocolStakerMock.helper__setValidatorStatus(validator.address, 3);

        // User attempt to unstake to get their VET back.
        // The contract see the validator is EXITED and attempt to decrement stake again.
        // Since stake was already removed in step 3 (0 - Amount) cause Underflow.
        console.log("\n๐Ÿ›‘ Attempting Unstake (Expecting Revert due to Double Decrement)...");
        
        await expect(
            stargateContract.connect(user).unstake(tokenId)
        ).to.be.reverted; // The revert confirm the bug
        
        console.log("๐Ÿ’€ CONFIRMED: User fund are permanently locked due to underflow.");
    });
๐Ÿ›‘ Attempting Unstake (Expecting Revert due to Double Decrement)...
๐Ÿ’€ CONFIRMED: User fund are permanently locked due to underflow.
    โœ” Unstake Revert if Validator Exit after User Request (69ms)


  1 passing (13s)

Attachments

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