60125 sc high moving delegations from one validator to another validator will not be possible in exit case for validator 1
Submitted on Nov 19th 2025 at 00:07:57 UTC by @oxrex for Audit Comp | Vechain | Stargate Hayabusa
Report ID: #60125
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: Temporary freezing of funds for at least 24 hour
Description
Brief/Intro
The protocol allows moving delegations from one validator to another after first signaling exit so users avoid the maturity wait every time. This is currently implemented incorrectly and fails in an edge case described below, causing a user to be unable to switch validators without unstaking and re-staking.
Vulnerability Details
Step
Inside _undelegate, since the current validator (validator 1) has status EXITED, the following if block runs:
if (
currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
status == DelegationStatus.PENDING
) {
// get the completed periods of the previous validator
(, , , uint32 oldCompletedPeriods) = $
.protocolStakerContract
.getValidationPeriodDetails(currentValidator);
// decrease the effective stake of the previous validator
_updatePeriodEffectiveStake(
$,
currentValidator,
_tokenId,
oldCompletedPeriods + 2,
false // decrease
);
}This attempts to subtract 100k VET again from validator 1's effective stake at period oldCompletedPeriods + 2 (e.g. period 6).
Relevant code excerpt from the _delegate implementation:
function _delegate(StargateStorage storage $, uint256 _tokenId, address _validator) private {
// ensure token is not already delegated
DelegationStatus status = _getDelegationStatus($, _tokenId);
if (status == DelegationStatus.ACTIVE) {
revert TokenAlreadyDelegated(_tokenId);
}
// ensure validator is in valid state
(, , , , uint8 validatorStatus, ) = $.protocolStakerContract.getValidation(_validator); // e.g 2 for active
(, , uint32 validatorExitBlock, ) = $.protocolStakerContract.getValidationPeriodDetails(
_validator
); // e.g type(uint256).max for no exit
if (
(validatorStatus != VALIDATOR_STATUS_ACTIVE &&
validatorStatus != VALIDATOR_STATUS_QUEUED) ||
// if the validator has requested to exit, we cannot delegate to it
validatorExitBlock != type(uint32).max
) {
revert ValidatorNotActiveOrQueued(_validator);
}
// Tokens under matutiry period cannot be delegated
if ($.stargateNFTContract.isUnderMaturityPeriod(_tokenId)) {
revert TokenUnderMaturityPeriod(_tokenId);
}
// get the token details
DataTypes.Token memory token = $.stargateNFTContract.getToken(_tokenId);
if (token.levelId == 0) {
revert InvalidToken(_tokenId);
}
uint256 currentDelegationId = $.delegationIdByTokenId[_tokenId];
// If the token was previously exited or pending it means that the VET is still held in the protocol,
// so we need to withdraw it and deposit again for the new delegation
if (status == DelegationStatus.EXITED || status == DelegationStatus.PENDING) {
// get the current validator
(address currentValidator, , , ) = $.protocolStakerContract.getDelegation(
currentDelegationId
);
// withdraw the delegation
$.protocolStakerContract.withdrawDelegation(currentDelegationId);
// emit the event to signal that the delegation was withdrawn
emit DelegationWithdrawn(
_tokenId,
currentValidator,
currentDelegationId,
token.vetAmountStaked,
token.levelId
);
// get the validator status
(, , , , uint8 currentValidatorStatus, ) = $.protocolStakerContract.getValidation(
currentValidator
);
// if the delegation is pending or the validator is exited or unknown
// decrease the effective stake of the previous validator
if (
currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
status == DelegationStatus.PENDING
) {
// get the completed periods of the previous validator
(, , , uint32 oldCompletedPeriods) = $
.protocolStakerContract
.getValidationPeriodDetails(currentValidator);
// decrease the effective stake of the previous validator
_updatePeriodEffectiveStake(
$,
currentValidator,
_tokenId,
oldCompletedPeriods + 2,
false // decrease
);
}
if (status == DelegationStatus.PENDING) {
// If the current delegation is pending, it means that the owner is changing the validator,
// without requesting to exit first (which is allowed since the exit is not active yet)
// so we emit an event to signal this action to the indexers
emit DelegationExitRequested(
_tokenId,
currentValidator,
currentDelegationId,
Clock.clock()
);
}
}
// ... rest of code omitted for brevity ...
}Impact Details
User will be unable to switch from validator A to validator B after having signaled exit from validator A and will be forced to unstake and restake (and wait the maturity period) instead of using the intended one-stop delegation switch flow. This temporarily freezes the user's funds and prevents the intended smoother UX.
References
Vulnerable source region: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/main/packages/contracts/contracts/Stargate.sol#L407-L414
Proof of Concept
Paste the test case below into the Delegation.test.ts test file after the it("should delegate a token that was previously delegated and now is exited", async () test case:
Expected test output shows an arithmetic underflow/overflow revert:
Was this helpful?