Status DataClose notification

VeChain Disclosed Report

Double Reduction of Effective Stake Leading to Permanent Fund Lockup

Company
Created date
Nov 28 2025

Target

hidden

Vulnerability Details

Detailed Description

The Stargate protocol uses a checkpoint-based system to track effective stake per period. When a user delegates, effective stake is added for period completedPeriods + 2. When a user requests exit, the effective stake should be reduced for the appropriate period.

The vulnerability occurs in the following scenario:

  1. User delegates to Validator A: Effective stake is recorded for period completedPeriods + 2
  2. User requests exit: requestDelegationExit reduces effective stake for period completedPeriods + 2 (at the time of exit request)
  3. Validator A exits: Validator status changes to VALIDATOR_STATUS_EXITED, freezing oldCompletedPeriods
  4. User attempts to switch/unstake: The code attempts to reduce effective stake again, but the checkpoint state has been modified by the first reduction, causing underflow

Root Cause

The issue stems from two separate code paths that both reduce effective stake without tracking whether a reduction has already occurred:

  1. First Reduction (requestDelegationExit, line 568):

    • Reduces effective stake for period completedPeriods + 2 when exit is requested
    • Uses upperLookup() which finds the nearest checkpoint at or before the target period
    • Creates a new checkpoint at the target period with the reduced value
  2. Second Reduction (_delegate, lines 398-413, and unstake, lines 264-283):

    • When switching from an EXITED delegation where the validator status is VALIDATOR_STATUS_EXITED
    • Reduces effective stake for period oldCompletedPeriods + 2
    • Uses the frozen oldCompletedPeriods value from when the validator exited

The problem is that:

  • The first reduction may target a different period than where stake was originally added (due to period advancement)
  • The second reduction uses oldCompletedPeriods which may be different from the completedPeriods used in the first reduction
  • There's no mechanism to track whether stake has already been reduced
  • When upperLookup() returns a value that's already been reduced (or is 0), subtracting the effective stake again causes underflow

Vulnerability Flow

1. Delegate at completedPeriods = 5
   → Stake added for period 7

2. Request exit at completedPeriods = 6
   → First reduction: Attempts to reduce for period 8
   → upperLookup(8) finds period 7's checkpoint (value = tokenEffectiveStake)
   → Creates period 8 checkpoint with value 0

3. Advance period, delegation becomes EXITED

4. Validator exits
   → oldCompletedPeriods frozen at 7

5. User attempts to switch/unstake
   → Second reduction: Attempts to reduce for period 9 (7 + 2)
   → upperLookup(9) finds period 8's checkpoint (value = 0)
   → Calculation: 0 - tokenEffectiveStake = UNDERFLOW
   → Transaction reverts
   → Funds permanently locked

Impact

Direct Impact

  • Permanent fund lockup: User's VET is permanently locked in the contract
  • No recovery mechanism: Both delegate() and unstake() revert, leaving no path to recover funds
  • Affects all users who requested exit before their validator exits

Scale of Impact

  • If a major validator gets slashed/exits, ALL their delegators who previously requested exit are affected
  • Can easily exceed 2% of protocol TVL during validator exit events
  • Can affect hundreds to thousands of users simultaneously
  • 100% of affected user's deposit is locked (exceeds 1% threshold)

Attack Scenario

  1. User has ACTIVE delegation to Validator A
  2. User requests exit (normal operation)
  3. Validator A gets slashed or voluntarily exits (realistic scenario in PoS systems)
  4. User attempts to switch to Validator B or unstake
  5. Transaction reverts due to arithmetic underflow
  6. User's funds are permanently locked with no recovery path

Code References

Vulnerable Code Locations

  1. First Reduction - requestDelegationExit:
// File: packages/contracts/contracts/Stargate.sol
// Lines: 560-568

// decrease the effective stake
// 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);
  1. Second Reduction - _delegate:
// File: packages/contracts/contracts/Stargate.sol
// Lines: 398-413

if (
    currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
    status == DelegationStatus.PENDING
) {
    // get the completed periods of the previous validator
    (, , , uint32 oldCompletedPeriods) = $
        .protocolStakerContract
        .getValidationPeriodDetails(currentValidator);
    // decrease the effective stake of the previous validator
    _updatePeriodEffectiveStake(
        $,
        currentValidator,
        _tokenId,
        oldCompletedPeriods + 2,
        false // decrease
    );
}
  1. Second Reduction - unstake:
// File: packages/contracts/contracts/Stargate.sol
// Lines: 264-283

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
    );
}
  1. Underflow Location - _updatePeriodEffectiveStake:
// File: packages/contracts/contracts/Stargate.sol
// Lines: 1003-1009

// get the current effective stake
uint256 currentValue = $.delegatorsEffectiveStake[_validator].upperLookup(_period);

// calculate the updated effective stake
uint256 updatedValue = _isIncrease
    ? currentValue + effectiveStake
    : currentValue - effectiveStake;  // <-- UNDERFLOW OCCURS HERE

Validation steps

Proof of Concept

The following test demonstrates the vulnerability:

// File: test/unit/Stargate/Rewards.test.ts
 // PoC: Finding 2 - Double Reduction of Effective Stake Leading to Permanent Fund Lockup
    it("PoC: Demonstrates double reduction causes underflow and permanent fund lockup", async () => {
        // Setup: Stake and delegate token
        const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
        tx = await stargateContract.connect(user).stake(LEVEL_ID, {
            value: levelSpec.vetAmountRequiredToStake,
        });
        await tx.wait();
        const tokenId = await stargateNFTMock.getCurrentTokenId();
        const tokenEffectiveStake = await stargateContract.getEffectiveStake(tokenId);

        // Step 1: Delegate at completedPeriods = 5 (stake recorded for period 7)
        tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
            validator.address,
            5n
        );
        await tx.wait();
        tx = await stargateContract.connect(user).delegate(tokenId, validator.address);
        await tx.wait();

        // Verify stake was added for period 7
        const effectiveStakePeriod7 = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            7
        );
        expect(effectiveStakePeriod7).to.equal(tokenEffectiveStake);

        // Step 2: Advance to period 7 and request exit (completedPeriods = 6)
        // FIRST REDUCTION: requestDelegationExit reduces for period 6 + 2 = 8
        // upperLookup(8) finds period 7's checkpoint, creates period 8 checkpoint with value 0
        tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
            validator.address,
            6n
        );
        await tx.wait();
        
        const delegationStatus1 = await stargateContract.getDelegationStatus(tokenId);
        expect(delegationStatus1).to.equal(DELEGATION_STATUS_ACTIVE);

        tx = await stargateContract.connect(user).requestDelegationExit(tokenId);
        await tx.wait();

        // Verify first reduction: period 8 checkpoint created with value 0
        const effectiveStakePeriod8AfterFirstReduction = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            8
        );
        expect(effectiveStakePeriod8AfterFirstReduction).to.equal(0n);

        // Step 3: Advance period so delegation becomes EXITED
        tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
            validator.address,
            7n
        );
        await tx.wait();

        const delegationStatus2 = await stargateContract.getDelegationStatus(tokenId);
        expect(delegationStatus2).to.equal(DELEGATION_STATUS_EXITED);

        // Step 4: Validator exits (freezes oldCompletedPeriods at 7)
        tx = await protocolStakerMock.helper__setValidatorStatus(
            validator.address,
            VALIDATOR_STATUS_EXITED
        );
        await tx.wait();

        const validatorInfo = await protocolStakerMock.getValidation(validator.address);
        expect(validatorInfo._status).to.equal(VALIDATOR_STATUS_EXITED);

        const validationPeriodDetails = await protocolStakerMock.getValidationPeriodDetails(validator.address);
        expect(validationPeriodDetails.completedPeriods).to.equal(7n);

        // Step 5: Attempt to switch validators triggers SECOND REDUCTION
        // Code tries to reduce for period: oldCompletedPeriods + 2 = 7 + 2 = 9
        // upperLookup(9) returns period 8's value (0), causing underflow: 0 - tokenEffectiveStake
        const effectiveStakePeriod9Before = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            9
        );
        expect(effectiveStakePeriod9Before).to.equal(0n);

        // This reverts due to arithmetic underflow (Solidity 0.8.20 built-in protection)
        await expect(
            stargateContract.connect(user).delegate(tokenId, otherValidator.address)
        ).to.be.reverted;

        // Step 6: Verify unstake also fails (same double reduction logic)
        await expect(
            stargateContract.connect(user).unstake(tokenId)
        ).to.be.reverted;

        // Step 7: Verify checkpoint state
        const finalEffectiveStakePeriod7 = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            7
        );
        const finalEffectiveStakePeriod8 = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            8
        );
        const effectiveStakePeriod9 = await stargateContract.getDelegatorsEffectiveStake(
            validator.address,
            9
        );

        // Period 7 still has stake (Finding 1 bug), period 8 has 0, period 9 lookup returns 0
        expect(finalEffectiveStakePeriod7).to.equal(tokenEffectiveStake);
        expect(finalEffectiveStakePeriod8).to.equal(0n);
        expect(effectiveStakePeriod9).to.equal(0n);

        // Final verification: Delegation still exists, proving funds are locked
        // User cannot recover funds because both delegate() and unstake() revert
        const delegationId = await stargateContract.getDelegationIdOfToken(tokenId);
        expect(delegationId).to.be.greaterThan(0n);
        
        const finalDelegationStatus = await stargateContract.getDelegationStatus(tokenId);
        expect(finalDelegationStatus).to.equal(DELEGATION_STATUS_EXITED);
    });


Attachments

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