Skip to content

registry/storage: pubkey→index mapping persists a 0 index for shares without beacon metadata #2966

Description

@iurii-ssv

Summary

validatorStore's in-memory pubkeyToIndex / indexToPubkey maps are written only for shares that have beacon metadata, but the persisted pubkey→index mapping that seeds them at startup is written unconditionally. Shares created by a ValidatorAdded contract event carry ValidatorIndex == 0 until beacon metadata sync fills them in, so pubkey -> 0 rows land in the DB and are loaded back as though they were real indices.

The visible consequence: ValidatorStore.ValidatorIndex(pubkey) returns (0, true) — "found", with a sentinel — for any validator in the metadata-sync window, instead of reporting not-found.

Found while reviewing #2941. Entirely pre-existing and unrelated to that PR.

Not the issue: "no deletion"

Worth stating up front, because it's the first thing that looks wrong: neither map is ever pruned on share removal, and that is deliberate and documented on the struct. pubkey↔index is immutable in Ethereum, and the exporter needs to resolve indices for validators whose shares are long gone. That part is fine.

The chain

  1. ValidatorAdded builds an SSVShare{} with zero-valued ValidatorIndex/Status and saves it — handlers.go#L266.
  2. saveToDB writes pubkey -> share.ValidatorIndex for every share with no gate, so pubkey -> 0 is persisted — shares.go#L329-L337.
  3. migration_8 populates the same mapping the same way — and it already knows this happens: it counts if shareSSZ.ValidatorIndex == 0 { shares0++ } and logs "shares with 0 index", then writes them anyway — migration_8#L46.
  4. loadPubkeyToIndexMappings loads every row without filtering — shares.go#L168.
  5. newValidatorStore inverts the map with for pk, idx := range pubkeyToIndex { indexToPubkey[idx] = pk }validatorstore.go#L106-L107. Every zero-index pubkey collapses onto indexToPubkey[0], and which one wins is Go map iteration order — nondeterministic per process start.
  6. Nothing is ever deleted, so indexToPubkey[0] retains that pubkey even after the validator's real index is learned: handleSharesUpdated adds indexToPubkey[realIndex] but never removes the 0 entry.

Contrast with the in-memory path, which does gate on HasBeaconMetadata() at L324 and L477. The persisted path is simply missing the same condition.

Impact

Real, user-visible (exporter API). ValidatorIndex(pk) returns (0, true), and the exporter's indicesFromDecidedsQuery / extractIndices treat ok == true as success (exporter.go#L77-L79, #L100-L102). Querying a freshly-registered validator by pubkey therefore silently resolves to index 0 and returns results for the wrong index, instead of the "validator not found for pubkey" error already implemented in the !ok branch. Window: contract event → metadata sync.

GetValidatorIndicesByPubkeys (shares.go#L302) reads the same persisted prefix directly and has the same problem.

Latent, effectively unreachable. ValidatorPubkey(0) handing back a stale, arbitrarily-chosen pubkey. Requires something to look up index 0, which requires validator index 0 to genuinely be an SSV validator.

Nothing here affects consensus — both are read/query surface.

Suggested fix

Apply on the persisted path the same gate the in-memory path already applies:

  • skip zero-index entries when writing in saveToDB,
  • filter them in loadPubkeyToIndexMappings and in the startup inversion,
  • and give GetValidatorIndicesByPubkeys the same treatment, since it bypasses both.

Treating 0 as "absent" is consistent with the rest of the codebase, which already uses it as the unknown-index sentinel — eviction.go#L110 logs "got trace with missing validator index" for Validator == 0.

Filtering on load should be sufficient to handle existing DBs without a new migration, provided every reader of the mapping prefix is covered.

Affected

Present on both main and stage. Line references above are against 670cfb448 (current stage tip at time of writing).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingexporterRelated to exporter

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions