https://github.com/vechain/stargate-contracts/tree/jose/update-contracts-to-hayabusa
Stargate – packages/contracts/contracts/Stargate.sol_claimableDelegationPeriods (private view)_claimRewards (internal)claimableRewards (view)claimRewards (external)In Hayabusa Stargate, delegators stake VET and delegate via NFTs. For each validator period, the protocol computes:
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.
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:
ProtocolStaker, endPeriod is set to completedPeriods + 1, i.e. the first period after the last completed period at the time of exit.endPeriod > nextClaimablePeriod. The equality case (endPeriod == nextClaimablePeriod) is not treated as ended.Consider this timeline for a delegator A on a validator:
startPeriod = 1endPeriod is set (e.g. endPeriod = 10).claimRewards enough times so that lastClaimedPeriod = endPeriod, i.e. they are fully caught up until the last active period.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.endPeriod > nextClaimablePeriod is false (10 > 11 is false), so the “ended” branch is not taken.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:
delegatorsEffectiveStake[validator] for future periods (via _updatePeriodEffectiveStake(..., false) in requestDelegationExit)._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:
claimRewards, it computes a numerator using its full effective stake, divided by the sum of active delegators’ stake.Since the underlying delegatorsRewards(validator, period) pool is fixed, these extra rewards must be funded by diluting the rewards that active delegators should receive.
The following steps can be reproduced using the existing Hardhat test setup and mocks.
Environment
getOrDeployContracts({ forceDeploy: true, config }), with config coming from createLocalConfig().ProtocolStakerMock as the staking backend, where:
getDelegatorsRewards(validator, period) returns a fixed non‑zero amount for each period up to completedPeriods + 1.Actors
Initial staking & delegation
vetAmountRequiredToStake and scaledRewardFactor) and delegate to the same validator.Advance validator periods and claim up to a checkpoint
completedPeriods in ProtocolStakerMock to a value C via helper__setValidationCompletedPeriods.claimRewards once, so lastClaimedPeriod for both NFTs is approximately C (they are fully caught up).User A requests exit
requestDelegationExit(tokenIdA).endPeriod for A’s delegation and marks it as exiting. After enough periods, A’s status becomes EXITED.Advance additional periods with B still active
completedPeriods several more steps while B remains delegated to the validator.Observe claimable rewards for A (exited) and B (active)
claimableRewards(tokenIdA) and claimableRewards(tokenIdB) after the extra periods.claimableRewards.claimableRewards.Interpretation
R VTHO in the mock).This behaviour is captured and asserted in the PoC test below.