VeChain Disclosed Report

Exited delegator continues to drain delegation rewards after exit

Company
Created date
Nov 23 2025

Target

https://github.com/vechain/stargate-contracts/tree/jose/update-contracts-to-hayabusa

Vulnerability Details

Impacted components

  • Contract: Stargatepackages/contracts/contracts/Stargate.sol
  • Core functions:
    • _claimableDelegationPeriods (private view)
    • _claimRewards (internal)
    • claimableRewards (view)
    • claimRewards (external)

description

In Hayabusa Stargate, delegators stake VET and delegate via NFTs. For each validator period, the protocol computes:

  1. Total delegator rewards for that validator and period (in VTHO).
  2. Each delegator’s share, proportional to their effective stake.

Once a delegator exits, their NFT should stop participating in rewards for future periods. Only delegators who are still active in those periods should receive the yield.

However, due to a bug in the reward window calculation (_claimableDelegationPeriods), a delegator who has already exited can continue to have a growing range of “claimable periods” far after their exit. When combined with the reward calculation formula, this allows the exited NFT to keep claiming VTHO from periods where they are no longer part of the delegator set.

Because the total getDelegatorsRewards(validator, period) is fixed per period, any extra rewards granted to an exited NFT necessarily come out of the pool that belongs to active delegators. This is effectively stealing yield from other users, and fits HackenProof’s “Stealing or loss of end‑user funds” / “Theft of unclaimed yield” classification.

Root cause (code‑level)

The bug sits in _claimableDelegationPeriods, which is used by both claimableRewards (view) and _claimRewards (state‑changing).

Relevant code (simplified):

function _claimableDelegationPeriods(
    StargateStorage storage $,
    uint256 _tokenId
) private view returns (uint32, uint32) {
    uint256 delegationId = $.delegationIdByTokenId[_tokenId];
    if (delegationId == 0) return (0, 0);

    (address validator, , , ) = $.protocolStakerContract.getDelegation(delegationId);
    if (validator == address(0)) return (0, 0);

    (uint32 startPeriod, uint32 endPeriod) =
        $.protocolStakerContract.getDelegationPeriodDetails(delegationId);
    (, , , uint32 completedPeriods) =
        $.protocolStakerContract.getValidationPeriodDetails(validator);

    uint32 currentValidatorPeriod = completedPeriods + 1;

    uint32 nextClaimablePeriod = $.lastClaimedPeriod[_tokenId] + 1;
    if (nextClaimablePeriod < startPeriod) {
        nextClaimablePeriod = startPeriod;
    }

    // ended delegations
    if (
        endPeriod != type(uint32).max &&
        endPeriod < currentValidatorPeriod &&
        endPeriod > nextClaimablePeriod
    ) {
        return (nextClaimablePeriod, endPeriod);
    }

    // treat as active/pending
    if (nextClaimablePeriod < currentValidatorPeriod) {
        return (nextClaimablePeriod, completedPeriods);
    }

    return (0, 0);
}

Key observations:

  1. When a delegation exit is signalled in the mock ProtocolStaker, endPeriod is set to completedPeriods + 1, i.e. the first period after the last completed period at the time of exit.
  2. The “ended delegation” branch only triggers when endPeriod > nextClaimablePeriod. The equality case (endPeriod == nextClaimablePeriod) is not treated as ended.

Consider this timeline for a delegator A on a validator:

  • startPeriod = 1
  • Delegation exit is signalled at some point, and endPeriod is set (e.g. endPeriod = 10).
  • A has already called claimRewards enough times so that lastClaimedPeriod = endPeriod, i.e. they are fully caught up until the last active period.
  • Time passes and the validator keeps producing blocks. Now completedPeriods = 20 and currentValidatorPeriod = 21.

At this point:

  • nextClaimablePeriod = lastClaimedPeriod + 1 = endPeriod + 1 = 11.
  • endPeriod < currentValidatorPeriod is true (10 < 21), so the delegation is in the “ended” region.
  • But endPeriod > nextClaimablePeriod is false (10 > 11 is false), so the “ended” branch is not taken.
  • Control falls through to the “active/pending” branch:
if (nextClaimablePeriod < currentValidatorPeriod) {
    // 11 < 21 is true
    return (nextClaimablePeriod, completedPeriods); // returns (11, 20)
}

This effectively treats the delegation as if it were still active up to completedPeriods, even though the user exited at endPeriod. As completedPeriods grows over time, the lastClaimablePeriod for this exited NFT also keeps growing.

Later, _claimRewards uses this window:

(uint32 firstClaimablePeriod, uint32 lastClaimablePeriod) =
    _claimableDelegationPeriods($, _tokenId);

uint256 claimableAmount = _claimableRewards($, _tokenId, 0);
...
$.lastClaimedPeriod[_tokenId] = lastClaimablePeriod;
VTHO_TOKEN.safeTransfer(tokenOwner, claimableAmount);

The actual per‑period reward for a token is computed as:

uint256 delegationPeriodRewards =
    $.protocolStakerContract.getDelegatorsRewards(validator, _period);

uint256 effectiveStake = _calculateEffectiveStake($, _tokenId);
uint256 delegatorsEffectiveStake =
    $.delegatorsEffectiveStake[validator].upperLookup(_period);

if (delegatorsEffectiveStake == 0) return 0;

return (effectiveStake * delegationPeriodRewards) / delegatorsEffectiveStake;

Important detail:

  • When the delegation is ended, the token’s effective stake is removed from delegatorsEffectiveStake[validator] for future periods (via _updatePeriodEffectiveStake(..., false) in requestDelegationExit).
  • However, _calculateEffectiveStake($, _tokenId) still returns a non‑zero effective stake for the NFT (based on its level and staked VET) even when it has exited.

As a result:

  • For a period after the exit, delegatorsEffectiveStake only includes the stake of active delegators.
  • When the exited NFT calls claimRewards, it computes a numerator using its full effective stake, divided by the sum of active delegators’ stake.
  • This yields a positive reward for the exited NFT from periods where it is no longer part of the delegator set.

Since the underlying delegatorsRewards(validator, period) pool is fixed, these extra rewards must be funded by diluting the rewards that active delegators should receive.


Validation steps

The following steps can be reproduced using the existing Hardhat test setup and mocks.

  1. Environment

    • Use Hardhat in‑memory network.
    • Deploy contracts via getOrDeployContracts({ forceDeploy: true, config }), with config coming from createLocalConfig().
    • Use ProtocolStakerMock as the staking backend, where:
      • getDelegatorsRewards(validator, period) returns a fixed non‑zero amount for each period up to completedPeriods + 1.
  2. Actors

    • User A (“exiting delegator”).
    • User B (“remaining active delegator”).
    • A single validator address, configured as ACTIVE.
  3. Initial staking & delegation

    • Both A and B stake the same level (same vetAmountRequiredToStake and scaledRewardFactor) and delegate to the same validator.
    • Their effective stakes are identical.
  4. Advance validator periods and claim up to a checkpoint

    • Advance completedPeriods in ProtocolStakerMock to a value C via helper__setValidationCompletedPeriods.
    • Have both A and B call claimRewards once, so lastClaimedPeriod for both NFTs is approximately C (they are fully caught up).
  5. User A requests exit

    • A calls requestDelegationExit(tokenIdA).
    • In the mock, this sets endPeriod for A’s delegation and marks it as exiting. After enough periods, A’s status becomes EXITED.
  6. Advance additional periods with B still active

    • Advance completedPeriods several more steps while B remains delegated to the validator.
    • At this point, B is the only active delegator on that validator for new periods.
  7. Observe claimable rewards for A (exited) and B (active)

    • Query claimableRewards(tokenIdA) and claimableRewards(tokenIdB) after the extra periods.
    • Because of the bug:
      • A (who has exited) still has a strictly positive claimableRewards.
      • B (still active) also has strictly positive claimableRewards.
  8. Interpretation

    • For each post‑exit period, the total pool of delegator rewards is fixed (e.g. R VTHO in the mock).
    • Yet over these periods, both:
      • A claims a non‑zero amount, and
      • B also claims a non‑zero amount.
    • Since A should have no stake during these periods, any rewards paid to A are effectively taken from the pool that should belong exclusively to active delegators like B.

This behaviour is captured and asserted in the PoC test below.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$1,530
Visibilitypartially
VulnerabilityBlockchain
Participants (3)
company admin
triage team
author