55632 bc critical delegation submitted in the same period before a validator exit will be permanently frozen

Submitted on Oct 3rd 2025 at 06:29:50 UTC by @Haxatron for Attackathon | VeChain Hayabusa Upgrade

  • Report ID: #55632

  • Report Type: Blockchain/DLT

  • Report severity: Critical

  • Target: https://github.com/vechain/thor/tree/release/hayabusa

  • Impacts:

    • Permanent freezing of funds (fix requires hardfork)

Description

Brief/Intro

Any delegation that is submitted in the same period before a validator exit will be permanently frozen.

Vulnerability Details

Any delegation that is submitted in the same period of a validator exit will be permanently frozen due to the following edge case. Consider the following scenario:

1

Delegation added in Period 5

AddDelegation is called by a delegator in Period 5; its FirstIteration will be CurrentIteration + 1 so, Period 6.

https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/staker.go#L534-L543

	current, err := val.CurrentIteration(currentBlock)
	if err != nil {
		return nil, err
	}
	delegationID, err := s.delegationService.Add(validator, current+1, stake, multiplier)
	if err != nil {
		logger.Info("failed to add delegation", "validator", validator, "error", err)
		return nil, err
	}
2

Validator signals exit in Period 5

SignalExit is called by the validator in Period 5; its CompletedPeriods will be set to 5 periods.

https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/validation/service.go#L165-L185

func (s *Service) SignalExit(validator thor.Address, currentBlock uint32, minBlock uint32, maxTry int) error {
	...

	current, err := validation.CurrentIteration(currentBlock)
	if err != nil {
		return err
	}
	validation.ExitBlock = &exitBlock
	// validator is going to exit after current iteration
	validation.CompletedPeriods = current

	return s.repo.updateValidation(validator, validation)
}
3

Exit processed at start of Period 6

On Period 6, the validator's exit is processed. This causes the aggregation.Pending to be set to 0 during the start of Period 6 when housekeeping is performed.

https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/aggregation/aggregation.go#L82-L95

func (a *Aggregation) exit() *globalstats.Exit {
	// Return these values to modify contract totals
	exit := globalstats.Exit{
		ExitedTVL:      a.Locked.Clone(),
		QueuedDecrease: a.Pending.VET,
	}

	// Reset the aggregation
	a.Exiting = &stakes.WeightedStake{}
	a.Locked = &stakes.WeightedStake{}
	a.Pending = &stakes.WeightedStake{}

	return &exit
}
4

Delegator withdraws after exit — stuck due to underflow

When the delegator tries to withdraw via WithdrawDelegation after the exit, since the CompletedPeriods is set to 5 then CurrentIteration will return 5. Because the delegation's FirstIteration is set to 6, started will be false.

https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/delegation/delegation.go#L28-L38

func (d *Delegation) Started(val *validation.Validation, currentBlock uint32) (bool, error) {
	if val.Status == validation.StatusQueued || val.Status == validation.StatusUnknown {
		return false, nil // Delegation cannot start if the validation is not active
	}
	currentStakingPeriod, err := val.CurrentIteration(currentBlock)
	if err != nil {
		return false, err
	}
	return currentStakingPeriod >= d.FirstIteration, nil
}

Because started is false, the withdraw path attempts to decrement aggregation.Pending via aggregationService.SubPendingVet, but aggregation.Pending was already reset to 0 during exit. This causes an underflow and the transaction reverts.

https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/staker.go#L679-L688

func (s *Staker) WithdrawDelegation(
	delegationID *big.Int,
	currentBlock uint32,
) (uint64, error) {
	...
	if !started {
		weightedStake := stakes.NewWeightedStakeWithMultiplier(withdrawableStake, del.Multiplier)
		if err = s.aggregationService.SubPendingVet(del.Validation, weightedStake); err != nil {
			return 0, err
		}

		if err = s.globalStatsService.RemoveQueued(withdrawableStake); err != nil {
			return 0, err
		}
	} else {
	...
}

Impact Details

Any delegation that is submitted in the same period before a validator exit will be permanently frozen (any fix will require a hardfork).

Proof of Concept

Show PoC test (add to hayabusa-e2e tests)

Was this helpful?