60004 sc high double decrease effective stake bug in unstake

Submitted on Nov 17th 2025 at 14:09:08 UTC by @jo13 for Audit Comp | Vechain | Stargate Hayabusaarrow-up-right

  • Report ID: #60004

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/tree/main/packages/contracts/contracts/Stargate.sol

  • Impacts:

    • Permanent freezing of funds

    • Permanent freezing of unclaimed yield

Description

Brief/Intro

A vulnerability in the Stargate contract’s unstake() function causes the protocol to double-decrease a delegator’s effective stake for a given validator and NFT. In realistic conditions (a user requests delegation exit, the validator later exits, and the user then calls unstake()), this double-decrease causes an arithmetic underflow inside _updatePeriodEffectiveStake, leading to a permanent denial-of-service on unstaking. Affected users are unable to withdraw their staked VET or fully realize their staking rewards, causing permanent freezing of funds and unclaimed yield unless the contract is upgraded or otherwise remediated off-chain.

Vulnerability Details

High-level description

The Stargate contract tracks delegators’ "effective stake" per validator and per period using a checkpointed mapping delegatorsEffectiveStake[validator]. This mapping is updated via the internal function _updatePeriodEffectiveStake, which (simplified):

When _isIncrease == false, this function performs currentValue - effectiveStake without any explicit guard that currentValue >= effectiveStake. In normal flows, the checkpointed value is expected to be large enough, but under certain sequences of calls the protocol applies this decrease twice for the same NFT/validator combination at future periods where the total effective stake is already 0. In Solidity 0.8.x, 0 - effectiveStake reverts with a panic due to arithmetic underflow, causing unstake() to revert.

Call flow and conditions

Two key public functions contribute to the double-decrease:

  1. requestDelegationExit(uint256 _tokenId)

  2. unstake(uint256 _tokenId)

requestDelegationExit() path

When a delegation is ACTIVE, requestDelegationExit signals an exit in the external ProtocolStaker and immediately decreases the delegator's effective stake at a future period:

At this point, the effective stake for that NFT/validator pair is decreased for completedPeriods + 2, which is a future period relative to the current checkpoint.

unstake() path

Later, after the validator has completed additional periods and may have exited, the user calls unstake(_tokenId):

The important point is that unstake() may call _updatePeriodEffectiveStake(..., false) a second time for the same NFT/validator pair at a similar future period (oldCompletedPeriods + 2). Depending on how periods are advanced between requestDelegationExit and unstake, this can target a period where the delegators’ effective stake is already fully drawn down to 0.

In that scenario, inside _updatePeriodEffectiveStake:

  • currentValue = $.delegatorsEffectiveStake[_validator].upperLookup(_period) returns 0.

  • effectiveStake = _calculateEffectiveStake($, _tokenId) is positive.

  • updatedValue = currentValue - effectiveStake becomes 0 - effectiveStake, which reverts with a panic due to underflow.

Because this arithmetic occurs inside unstake(), the entire unstake operation reverts, even though the user followed a valid lifecycle.

1

Typical valid lifecycle for a delegator (relevant to the bug)

  • Stake

  • Delegate

  • Request delegation exit

  • Wait for validator/delegation exit

  • Call unstake() to reclaim principal

Impact Details

In the affected flow, a user stakes VET, delegates their NFT to a validator, requests delegation exit while the delegation is active, waits until the delegation and validator are considered exited, and then calls unstake() to reclaim their principal. Due to the double-decrease of effective stake, _updatePeriodEffectiveStake underflows and causes unstake() to revert every time, resulting in:

  • Permanent freezing of staked funds and any unclaimed yield for that position.

  • A broken staking lifecycle where the protocol fails to deliver the promised ability to exit.

  • A safety/availability issue that can impact any regular delegator following a normal exit flow.

  • Potential for griefing: attackers can deliberately create conditions that push users into the unrecoverable state.

References

  • Core contract:

    • contracts/Stargate.sol

      • unstake(uint256 _tokenId) logic and call to _updatePeriodEffectiveStake when validator or delegation is exited.

      • requestDelegationExit(uint256 _tokenId) logic and initial decrease of effective stake at completedPeriods + 2.

      • _updatePeriodEffectiveStake(StargateStorage storage $, address _validator, uint256 _tokenId, uint32 _period, bool _isIncrease) implementation.

  • Mocks:

    • contracts/mocks/ProtocolStakerMock.sol

      • Used in tests to simulate validator statuses and completed periods.

  • Tests:

    • test/unit/Stargate/Delegation.test.ts

      • New regression test: "should revert when unstaking after requesting delegation exit and validator exit".

  • VeChain Thor Staker implementation (for behavior reference):

    • builtin/staker.go and builtin/staker_native.go in the VeChain Thor repository (confirming behaviors of GetValidation, GetDelegation, period handling, and exit semantics).

Proof of Concept

A dedicated unit test was added to test/unit/Stargate/Delegation.test.ts to reproduce this behavior using the ProtocolStakerMock:

Run the test:

Observed output:

This confirms that in the described scenario, unstake() always reverts, demonstrating the existence of the vulnerability.

Was this helpful?