60173 sc high the phantom claimable periods can permanently lock the staked vet for ended delegations

Submitted on Nov 19th 2025 at 15:01:52 UTC by @XDZIBECX for Audit Comp | Vechain | Stargate Hayabusaarrow-up-right

  • Report ID: #60173

  • 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

Description

Brief / Intro

In this contract there is a mismatch between how Stargate decides which periods are claimable and how it tracks the last period that actually paid out. After a delegation has cleanly ended and the user has already claimed all legitimate rewards up to the end period, the contract can still report a growing “claimable” window of zero-reward periods. This happens because:

  • _claimableDelegationPeriods() keeps returning this phantom window.

  • _claimRewards() is a no-op when the window contains zero rewards and never advances lastClaimedPeriod.

  • _exceedsMaxClaimablePeriods() eventually becomes permanently true for that NFT.

  • As a result, unstake() and delegate() revert with MaxClaimablePeriodsExceeded, permanently freezing the staked VET of that NFT, even though the delegation ended and all real rewards were claimed.

A test demonstrating this issue is included below.


Vulnerability Details

Root cause summary:

  • Once a delegation has ended and the user has claimed all rewards up to endPeriod, lastClaimedPeriod becomes endPeriod.

  • nextClaimablePeriod = lastClaimedPeriod + 1 = endPeriod + 1.

  • _claimableDelegationPeriods() uses the condition endPeriod > nextClaimablePeriod to detect ended delegations. After the user has claimed up to endPeriod, that condition becomes false, so the function falls through to the "active" branch and returns a fake claimable window from endPeriod + 1 up to completedPeriods, even though no rewards exist for those periods.

  • _claimRewards() computes claimableAmount for that window. Because there are no rewards after endPeriod, claimableAmount == 0 and the function returns early without updating lastClaimedPeriod.

  • As completedPeriods grows (validator keeps producing blocks), the phantom window [firstClaimablePeriod, lastClaimablePeriod] = [endPeriod + 1, completedPeriods] grows until lastClaimablePeriod - firstClaimablePeriod >= maxClaimablePeriods.

  • _exceedsMaxClaimablePeriods() then returns true permanently for that NFT.

  • Both unstake() and delegate() check _exceedsMaxClaimablePeriods() and revert with MaxClaimablePeriodsExceeded before calling _claimRewards(). Because claimRewards() cannot advance lastClaimedPeriod (no rewards in the phantom window), the state can never be corrected on-chain by the user.

Relevant code excerpts (links preserved):

  • The ended-delegation branch condition in _claimableDelegationPeriods() (bug origin): https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L911C1-L922C10

  • The active branch that can return a fake window: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L924C8-L930C10

  • _exceedsMaxClaimablePeriods() which becomes permanently true: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L956C3-L973C6

  • unstake() gating on _exceedsMaxClaimablePeriods(): https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L296C4-L304C1

  • delegate() also gating similarly: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L429C6-L439C10

  • _claimRewards() returning early when no rewards are available (and thus not updating lastClaimedPeriod): https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/e9c0bc9b0f24dc0c44de273181d9a99aaf2c31b0/packages/contracts/contracts/Stargate.sol#L736C3-L776C6

Because lastClaimedPeriod never advances beyond endPeriod, the phantom window never closes. _exceedsMaxClaimablePeriods() remains true and the NFT can never successfully call unstake() or delegate() — freezing the staked VET.


Impact Details

  • Severity: Critical/High due to the potential for permanent freeze of staked VET behind an NFT.

  • Once the phantom claimable window after endPeriod grows beyond maxClaimablePeriods, _exceedsMaxClaimablePeriods() becomes permanently true.

  • From that point, every call to unstake() or delegate() for that NFT reverts with MaxClaimablePeriodsExceeded, even though the delegation ended and no rewards accrue for it.

  • The user cannot fix this on-chain: claimRewards() sees a zero-reward window and does not advance lastClaimedPeriod, so the fake window grows as completedPeriods increases and the revert condition never clears.

  • Recovery requires an out-of-band admin/upgrade action via UPGRADER_ROLE.


Proof of Concept

chevron-rightTest demonstrating the permanent freeze (click to expand)hashtag

The test output:


References

All relevant code links are included inline in the Vulnerability Details section (kept as originally referenced).


If you want, I can:

  • Suggest specific code-level fixes (patches) to correctly handle ended delegations and advance lastClaimedPeriod even when claimableAmount==0, or change the ended-delegation condition to treat endPeriod >= nextClaimablePeriod as ended, etc.

  • Provide a minimal unit test that asserts the fixed behavior.

  • Draft a changelog entry / mitigation guidance for operators (e.g., recommend an immediate upgrade or admin intervention).

Was this helpful?