59997 sc medium claimrewards fails to update state for zero value periods causing permanent fund freeze in unstake

  • Submitted on Nov 17th 2025 at 13:07:33 UTC by @hunraj for Audit Comp | Vechain | Stargate Hayabusa

  • Report ID: #59997

  • Report Type: Smart Contract

  • Report severity: Medium

  • 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

A logic flaw exists in the _claimRewards function. When a user has a backlog of claimable periods with a total reward value of zero, the function returns early without updating the user's lastClaimedPeriod state. This creates a "trap" state: the user cannot call unstake because unstake checks _exceedsMaxClaimablePeriods and relies on claimRewards to advance the checkpoint. Since claimRewards returns early, the backlog never shrinks and the user's principal becomes permanently frozen in the contract.

Vulnerability Details

The protocol uses two safety mechanisms that interact incorrectly:

  1. unstake Gas Safety: unstake reverts if the user has more claimable periods than maxClaimablePeriods (e.g., 832) to avoid out-of-gas errors during automatic reward claims. This forces manual clearing of the backlog.

  2. claimRewards Batching: claimRewards is intended to clear the backlog in batches, advancing lastClaimedPeriod per batch.

Root cause: _claimRewards returns early if the batch's claimableAmount is zero, and therefore never updates lastClaimedPeriod.

Code excerpt showing the root cause:

The Catch-22 Execution Path

1

Step: Pre-condition

A user (Alice) has an EXITED delegation with > maxClaimablePeriods (e.g., 832) claimable periods, all with value 0 VTHO (e.g., delegating to an offline validator).

2

Step: Alice calls unstake()

unstake() reverts with MaxClaimablePeriodsExceeded. This is the intended gas safety mechanism.

3

Step: Alice calls claimRewards()

_claimRewards computes claimableAmount for the first batch (e.g., first 832 periods). Because rewards are zero, claimableAmount == 0 and the function executes return. lastClaimedPeriod is not updated; no backlog progress is made.

4

Step: Alice calls unstake() again

unstake() reverts with MaxClaimablePeriodsExceeded for the same reason. Alice cannot progress and is permanently locked out from withdrawing her principal.

Impact

This is a Critical outcome: permanent freezing of user funds. A user can be permanently blocked from unstaking if they accumulate many zero-value claimable periods.

Always update lastClaimedPeriod when processing a batch, even when the batch's reward is zero. The function's responsibility is both to pay rewards and to advance the checkpoint for processed periods. Move the state update before returning for zero-value batches.

Proposed fix (move the state update before the zero-amount return):

This ensures progress is always made on clearing claimable periods and prevents permanent lockouts.

Proof of Concept

A runnable PoC test was provided. The ProtocolStakerMock was slightly modified to allow setting delegator rewards to zero via a helper function so the test can simulate the "unlucky delegator" scenario.

Modification to ProtocolStakerMock.sol (added state variable + helper setter):

Runnable Hardhat Test (excerpt):

Test output from the PoC run:


If you want, I can:

  • Produce a patch/PR diff applying the proposed fix in the repository structure referenced.

  • Run through alternative mitigations (e.g., special-case unstake to allow manual exception flows).

Was this helpful?