60298 sc high duplicate effectivestake decrement path bricks unstake re delegate
Submitted on Nov 21st 2025 at 05:14:34 UTC by @Rhaydden for Audit Comp | Vechain | Stargate Hayabusa
Report ID: #60298
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
Finding description and impact
Stargate tracks each validator’s total delegators’ effective stake per period using OZ checkpoints. When a user:
delegates, it schedules an increase at validator’s next period (completedPeriods + 2),
requests exit, it schedules a decrease at (completedPeriods + 2),
and later unstakes while the validator is EXITED or pending, it schedules another decrease at (completedPeriods + 2).
So now the issue here is that requestDelegationExit() unconditionally schedules a decrease for the upcoming period.
unstake() schedules another decrease if the validator is EXITED (or if the delegation is PENDING), without checking whether a decrease was already scheduled by a prior exit request. The checkpointing uses upperLookup, so once the first decrease writes a 0 from period N+2 onward, a second decrease at period M+2 (M ≥ N) tries to do 0 - effectiveStake which reverts due to arithmetic underflow.
Here is the code to reference what were talking about above:
In requestDelegationExit() (decrease scheduled):
Then in unstake() (second decrease scheduled when validator EXITED or delegation PENDING):
In _updatePeriodEffectiveStake() (unsafe subtract on duplicate decrement):
Impact
Users who requested exit and whose validator later becomes EXITED cannot call unstake() (reverts), effectively freezing their staked VET until an upgrade. Also the same duplicate-decrement condition exists in the redelegation path (when switching validators), risking reverts or incorrect accounting if not underflowing.
Impact- Critical: Permanent freezing of funds.
Consider a simple scenario:
User delegates NFT T to validator V.
User calls
requestDelegationExit(T)while ACTIVE. Contract schedules a decrease at period R+2.Validator V later becomes
EXITED.User calls
unstake(T). Contract schedules another decrease at period F+2 (F ≥ R).Checkpoint at F+2 already reflects the first decrease (0), so second subtraction underflows and reverts. User can’t unstake or redelegate.
Recommended mitigation steps
In unstake() (and in the mirrored block inside _delegate()), do not schedule a second decrease if the user has already requested exit. We could:
Compute
bool userAlreadySignaledExit = delegation.endPeriod != type(uint32).max;Replace:
if (currentValidatorStatus == EXITED || delegation.status == PENDING) { ... decrease }
With:
if (delegation.status == PENDING || (currentValidatorStatus == EXITED && !userAlreadySignaledExit)) { ... decrease }
Apply the same guard in the redelegation path (_delegate), where a previous delegation on an EXITED validator also triggers a decrease.
It'd be good to also add a defensive check in _updatePeriodEffectiveStake before decreasing:
If
!_isIncrease, ensurecurrentValue >= effectiveStake, otherwise revert with a descriptive custom error (prevents silent state corruption and turns it into a clear failure).
Proof of Concept
Proof of concept
Created a test file called DoubleDecrementUnderflow.test.ts:
The poc:
Stakes and delegates a token to a validator.
Advances periods so delegation is ACTIVE.
Calls
requestDelegationExit()(first scheduled decrease).Sets validator status to
EXITED.Calls
unstake()and expects revert due to the second scheduled decrease hitting 0 total.
Run the poc with VITE_APP_ENV=local npx hardhat test --network hardhat test/unit/Stargate/DoubleDecrementUnderflow.test.ts
Logs
Was this helpful?