Skip to content

computeNodesConfigFromList aborts the epoch with ErrListSizeZero when the elected list is empty, even when eligible validators could fill the consensus group #132

Description

@MathijsBok

Context

Found while re-assessing #121 against the open PRs on 2026-08-13, baseline develop @ e48c2c2. This is not a defect in #121 itself: it is a separate failure mode in the same function, and it is what turns #121's silent drop from cosmetic into dangerous once PR #86 merges.

Problem

computeNodesConfigFromList aborts the entire epoch computation when the elected list is empty (sharding/nodesCoordinator.go:956-958):

if len(electedList) == 0 {
    return nil, fmt.Errorf("%w elected list size is zero. No validators found", ErrListSizeZero)
}

The guard looks at electedList only. A perfectly healthy eligibleList does not save it. Verified with a throwaway test against the real function: 2 validators with List == observer plus 4 healthy eligible validators produce nil, ErrListSizeZero.

The consequence is not a skipped epoch, it is a permanently stuck node:

  1. EpochStartPrepare logs could not compute nodes config from list - do nothing on nodesCoordinator epochStartPrepare and returns (sharding/nodesCoordinator.go:776-780).
  2. SetNodes (:795) is therefore never reached, so nodesConfig[newEpoch] is never created.
  3. ComputeConsensusGroup for the new epoch then returns ErrEpochNodesConfigDoesNotExist (:525-527), so every non-epoch-start header of the new epoch fails validation on all three intake paths.

That is the same end state as #90, reached through a different and worse cause. In #90 the window closes as soon as the epoch-start block commits. Here SetNodes is never called at all, so nothing closes it.

How this becomes reachable

Today the elected list can only be empty if no validator carries List == elected, which does not happen in normal operation. That is why this has never been observed.

PR #86 (validator node version attestation with observer demotion) changes exactly that. resolveVersionEnforcedList returns state.List_observer for validators without a satisfying attested version, and the PR's own tests assert demotion from List_elected as well as from List_eligible. Demoted validators then hit no case in the switch at sharding/nodesCoordinator.go:928-940 and are dropped from every list, including the elected one.

PR #86 does carry two safety guards in computeVersionEnforcement, but neither closes this hole:

  • supermajority: satisfied*3 >= electable*2
  • floor: satisfied >= minElectableNodes, skipped entirely when minElectableNodes == 0

Both count elected + eligible as a single electable population. The abort in the coordinator checks elected on its own. A distribution that passes both guards and still stalls the network:

  • 10 elected validators, all running an outdated version
  • 30 eligible validators, all correctly attested
  • electable = 40, satisfied = 30
  • supermajority: 30*3 = 90 >= 40*2 = 80, holds
  • floor: holds for any minElectableNodes <= 30

Demotion proceeds, all 10 elected validators become observers, the elected list is empty, and epoch preparation fails permanently on every node that computes it.

This is not an exotic distribution. The elected set is the incumbent, longest-running population, which is precisely the group most likely to lag behind a mandatory upgrade.

Task

Decide which side owns the invariant "the elected list is never empty at epoch preparation", then close it there:

  • Coordinator side: either tolerate an empty elected list when eligibleList can still fill consensusGroupSize (the shuffler runs immediately after at :789 and repopulates elected from eligible), or make the failure actionable instead of an early return that leaves the node silently frozen.
  • Enforcement side (feat: validator node version attestation with observer demotion #86): count the elected list separately in the guards, so demotion can never empty it.

Either way the abort branch needs a test, and the interaction needs to be pinned before #86 merges. In its current shape this looks like a merge blocker for #86.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions