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
ValidatorAdded builds an SSVShare{} with zero-valued ValidatorIndex/Status and saves it — handlers.go#L266.
saveToDB writes pubkey -> share.ValidatorIndex for every share with no gate, so pubkey -> 0 is persisted — shares.go#L329-L337.
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.
loadPubkeyToIndexMappings loads every row without filtering — shares.go#L168.
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.
- 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).
Summary
validatorStore's in-memorypubkeyToIndex/indexToPubkeymaps 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 aValidatorAddedcontract event carryValidatorIndex == 0until beacon metadata sync fills them in, sopubkey -> 0rows 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
ValidatorAddedbuilds anSSVShare{}with zero-valuedValidatorIndex/Statusand saves it — handlers.go#L266.saveToDBwritespubkey -> share.ValidatorIndexfor every share with no gate, sopubkey -> 0is persisted — shares.go#L329-L337.migration_8populates the same mapping the same way — and it already knows this happens: it countsif shareSSZ.ValidatorIndex == 0 { shares0++ }and logs"shares with 0 index", then writes them anyway — migration_8#L46.loadPubkeyToIndexMappingsloads every row without filtering — shares.go#L168.newValidatorStoreinverts the map withfor pk, idx := range pubkeyToIndex { indexToPubkey[idx] = pk }— validatorstore.go#L106-L107. Every zero-index pubkey collapses ontoindexToPubkey[0], and which one wins is Go map iteration order — nondeterministic per process start.indexToPubkey[0]retains that pubkey even after the validator's real index is learned:handleSharesUpdatedaddsindexToPubkey[realIndex]but never removes the0entry.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'sindicesFromDecidedsQuery/extractIndicestreatok == trueas 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!okbranch. 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:
saveToDB,loadPubkeyToIndexMappingsand in the startup inversion,GetValidatorIndicesByPubkeysthe same treatment, since it bypasses both.Treating
0as "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"forValidator == 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
mainandstage. Line references above are against670cfb448(currentstagetip at time of writing).