60506 sc high double delegatorseffectivestake decrease permanently prevents single nft from unstaking
Submitted on Nov 23rd 2025 at 14:38:45 UTC by @cmds for Audit Comp | Vechain | Stargate Hayabusa
Report ID: #60506
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 the current Stargate.sol, both requestDelegationExit and unstake independently call _updatePeriodEffectiveStake(..., false) on the same delegation, without using endPeriod or any other flag to distinguish between “already decreased” and “not yet decreased”. Along the legitimate ACTIVE → requestDelegationExit → EXITED → unstake state machine path, this causes a double decrease of the same effective stake; in single-delegator or low-liquidity validator scenarios, the second decrease is applied in a period where the aggregated effective stake is already 0, triggering a Solidity 0.8.20 arithmetic underflow revert. As a result, unstake() for that NFT will permanently fail, and the corresponding VET principal and future rewards cannot be withdrawn through any normal protocol entrypoint.
Vulnerability Details
(1) Conflicting state machine: requestDelegationExit and unstake both decrease the same delegation
The core bug is that two different flows both subtract effective stake for the same delegation, with no guard to ensure it only happens once.
// ✅ First decrease of this delegation's effective stake
_updatePeriodEffectiveStake(
$,
delegation.validator,
_tokenId,
completedPeriods + 2,
false // decrease
);Conceptually, the protocol should either:
Rule A: decrease effective stake once in
requestDelegationExitand never again inunstakeRule B: only decrease in
unstakewhilerequestDelegationExitmerely signals toProtocolStaker.
Currently both branches call _updatePeriodEffectiveStake(..., false) on the same delegation and there is no “already decreased” flag, which is the root cause of the double-decrease.
(2) Existing endPeriod flag is not used as an “already decreased” guard
The contract already has a natural flag to indicate that the user requested an exit:
_getDelegationStatus also relies on endPeriod to determine whether the user requested an exit.
However, unstake does not use this information to avoid a second decrease. A minimal missing guard would look like:
In other words, the contract already exposes a signal for “this delegation’s effective stake has been removed at exit time” (endPeriod), but unstake ignores it and still performs another _updatePeriodEffectiveStake(..., false) on the same stake, allowing the double-decrease to happen.
(3) _updatePeriodEffectiveStake uses bare subtraction with no lower-bound protection
Once the state machine allows double-decrease, the arithmetic in _updatePeriodEffectiveStake turns it into an underflow revert:
Under the assumption that Thor’s period counter is monotonically increasing, there is a fully legitimate, non-privileged user flow:
1.The user delegates to some validator V. _delegate performs an increase at P_add = C0 + 2, so: delegatorsEffectiveStake[V] = s > 0 becomes effective starting from P_add.
2.While the validator is still ACTIVE, the user calls requestDelegationExit(tokenId): this performs the first decrease at P_exit1 = C1 + 2 (with C1 > C0), removing this s from all future periods ⇒ from P_exit1 onward, the aggregated value becomes 0.
3.Later, the validator becomes EXITED on the ProtocolStaker side, and _getDelegationStatus returns DelegationStatus.EXITED.
4.At this point, the user calls unstake(tokenId):
a:The call passes the check that “unstake cannot be called while status is ACTIVE”;
b:currentValidatorStatus == VALIDATOR_STATUS_EXITED holds;
c:A second decrease is executed at P_exit2 = C2 + 2 ≥ P_exit1. At this time upperLookup(P_exit2) == 0, so subtracting s again results in 0 - s underflow ⇒ the entire unstake reverts.
Impact Details
Once this bug is triggered, the funds have already been withdrawn from Thor’s ProtocolStaker and are logically ready to be returned, but the double-decrease underflow in unstake prevents the NFT from being burned and the VET from being transferred back; every subsequent unstake (or related) call follows the same failing path and keeps reverting, leaving the user’s VET and all future rewards effectively permanently locked by protocol logic unless recovered through governance or emergency actions.
Fix
A minimal viable fix is to use delegation.endPeriod != type(uint32).max inside unstake to determine whether this delegation has already had its effective stake removed via requestDelegationExit. If so, unstake must not call _updatePeriodEffectiveStake(..., false) again. This ensures that each delegation’s effective stake is decreased at most once, and eliminates the double-decrease underflow path.
References
https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/main/packages/contracts/contracts/Stargate.sol#L261-L283
https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/main/packages/contracts/contracts/Stargate.sol#L398-L414
Proof of Concept
Proof of Concept
MinimalStargateBug.sol (contracts/MinimalStargateBug.sol – _updatePeriodEffectiveStake copied 1:1 from Stargate.sol, with _calculateEffectiveStake simplified to a direct mapping)
poc.doubleDecrease.spec.ts (test/unit/Stargate/poc.doubleDecrease.spec.ts)
run:
Step-by-step
1.In a local test setup, deploy Stargate + ProtocolStakerMock and choose a validator V with no existing delegations (single delegator).
2.From a user account, delegate tokenId to validator V via the normal entrypoint (delegate / stakeAndDelegate).
3.While validator V is still ACTIVE, call requestDelegationExit(tokenId) from the same user.
4.Using the mock, change validator V’s status from ACTIVE to EXITED.
5.Call unstake(tokenId) from the user and observe that the transaction reverts with an arithmetic underflow panic.
Was this helpful?