You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Registry events can be silently and permanently lost during historical event sync. FetchHistoricalLogs fetches logs with bloom verification disabled, then advances lastProcessedBlock regardless of whether the execution client actually returned the logs for those blocks. Because the block marker and the events commit in the same transaction and nothing ever revisits a processed block, an incomplete eth_getLogs response becomes a permanent gap in the node's registry state.
We found this in production-like conditions on our hoodi-stage testnet: one node had been running for ~3.5 months missing an operator, and two others are missing 32 operators and 2 validator shares each. All three report healthy: duties execute, consensus works, metrics are green, and lastProcessedBlock is level with their peers. The only way we detected it was by copying the BadgerDB files off the pods and diffing the operator tables.
This is not fork-related and is present on main.
Impact
Silent registry divergence from chain state, undetectable through logs, metrics, or health checks.
Only repairable by a full resync from RegistrySyncOffset — there is no reconciliation or self-heal path.
Historical sync runs on every startup (from the stored lastProcessedBlock, cli/operator/eventsync.go), so this is the routine post-restart catch-up path, not a rare cold-start edge case. Every restart is an opportunity to lose events.
Applies to mainnet, same code path. A node missing operator data for a committee it serves will fail to start those validators (operator/validator/controller.go, "operator data not found, validator will only start if the number of available operators is greater than or equal to the committee quorum"). We got lucky: in our cases the missing operators happened not to be in the affected nodes' own committees, so duties were unaffected. That is chance, not design.
Bloom verification was added in feat(el): bloom filter cross-check for log integrity #2722 and correctly guards the streaming path. The historical path was intentionally left unguarded, so the protection does not cover the path that runs on every boot.
Mechanism
All references at main @ a1bcd468f, eth/executionclient/execution_client.go:
versus :506 for streaming, which enables it (..., true).
:192-194 — the skip is deliberate and documented:
// Verify each block's logs against its bloom filter.// Only enabled for streaming (near chain tip) where the Geth bug is most impactful.// Skipped during historical sync to avoid ~200 extra header RPCs per batch.ifverifyBloom {
eth/executionclient/bloom_test.go documents the same ("FetchHistoricalLogs skips bloom checks"). So the EL returning incomplete logs is a known failure mode — it just isn't guarded here.
:240-243 — progression is emitted whether or not logs arrived:
// Emit an empty BlockLogs to indicate progression to the next block.ifhighestBlock<toBlock {
logCh<-BlockLogs{BlockNumber: toBlock}
}
eth/eventhandler/event_handler.go:172-191 — processBlockEvents writes whatever events arrived andSaveLastProcessedBlock in one atomic transaction and commits. With zero or partial logs, the marker still advances and the transaction still commits successfully.
Nothing re-reads a block below lastProcessedBlock; in fact processBlockEvents treats a lower block as a hard error (ErrInferiorBlock). The gap is therefore permanent.
Net effect: a single bad batch response silently drops every registry event in it, and the node cannot tell that it happened.
Evidence
Canonical chain state for our hoodi-stage registry is 587 operators / 43,304 shares. We verified this independently two ways: a node that did a full clean resync from RegistrySyncOffset produced exactly that, and replaying the event log gives 751 OperatorAdded − 172 OperatorRemoved = 587, set-identical to that node's DB.
We then surveyed DB creation era across all 111 of our stage nodes, identified the 24 whose DBs predate the incident window, and read the operator tables directly out of copies of those BadgerDBs (opened via operatorstorage.NewNodeStorage → ListOperatorsAll, i.e. the node's own code path, validated against known-good counts before trusting any diff).
Node A — DB created 2026-04-21, reports 586 operators / 43,304 shares:
Missing exactly one row: operator 542, registered in block 2,746,590 (2026-05-04), tx 0xfcc6172ff307011100f2e5f179f4dac6c74a5d3bf657543af15a809c40b80dec, never removed.
That block contains exactly 2 SSV logs, both from that tx (OperatorAdded + OperatorPrivacyStatusUpdated) — consistent with the whole block's log set being lost.
Registration-order neighbours 535-541 and 548-549 are all present; of the 62 operators owned by the same address, only 542 is missing. Reverse diff empty; the other 586 rows byte-identical to canonical.
Survived a genuine pod restart still reporting 586, so it is persistent state, not a read artifact.
Nodes B and C — report 555 operators / 43,302 shares, byte-identical to each other:
Missing all 32 canonical operators with IDs 550-629, and nothing outside that range. Since IDs are sequential, this maps to a contiguous block window: operator 549 @ 2,805,409 (present) → 550 @ 2,819,474 (missing) → 629 @ 2,905,681 (missing) → 641 @ 2,936,696 (present). Roughly 86k blocks / ~12 days, mid-May 2026.
Also missing 2 validator shares whose committees they otherwise know, so those are independent event losses rather than knock-on effects.
lastProcessedBlock is 3,397,122 / 3,397,128 — level with healthy nodes. Not sync lag; permanent.
The remaining 21 nodes in the cohort were clean (587 / 43,304). No node anywhere held a row that canonical lacks, and there were no owner/pubkey mismatches — the loss is strictly one-directional.
Why other explanations were excluded
Not a torn/partial commit — events and the block marker share one transaction (point 4 above).
Not the MalformedEventError skip path (event_handler.go:215-218, which does silently drop an event while committing the block). That requires a pre-existing conflicting row per event; 32 operators plus 2 unrelated shares in one contiguous window cannot plausibly be 34 separate ID/pubkey conflicts. Operator 542's pubkey is also unique across all 587, and no conflicting row exists.
Not a global/chain-level miss — all these nodes read from one shared Besu execution client, and 23 of the 24 nodes in the cohort have operator 542. The damage is per-node and per-request, which is what an unverified eth_getLogs batch response looks like.
Not sync lag or image skew — markers are current, and the affected pods were started months after the loss windows, so the gaps live in persistent DB state.
Suggested fix
Enabling verifyBloom unconditionally would cost the ~200 header RPCs per batch the comment is trying to avoid. A cheaper option that still closes the hole: only pay for verification when a batch looks suspicious — e.g. when a block range returns zero logs (or fewer than expected), fetch the headers for that range and check the blooms; re-request if the bloom indicates logs should exist. That confines the extra RPCs to the rare suspicious case rather than the common path.
Whatever the shape, the important properties seem to be:
Do not advance lastProcessedBlock past a range whose log completeness was never established.
Fail loudly (or retry) instead of silently emitting empty progression on a suspicious batch.
Consider a way to detect/repair existing divergence, since affected nodes today have no signal at all and full resync is the only remedy. A periodic or on-demand reconciliation of the operator/share set against chain state would also let operators find out whether they are already affected.
Caveats / what we could not prove
We could not confirm that the affected nodes restarted at the specific moments in question — our log retention does not reach back to early/mid-May 2026. The historical-sync path is therefore a strongly supported inference from the code and the damage pattern, not a directly observed event. It is conceivable the same incomplete-response problem struck via a different path.
We did not confirm that the 2 missing shares' ValidatorAdded blocks fall inside the 86k-block window (the scan was too slow to finish). It is consistent with the window, not proven.
81 of our nodes have DBs created after the incident window and were not censused, so we do not know the true fleet-wide prevalence. Note there is no cheap way to check: ssv_validator_validators_per_status counts only a node's own validators, not the global registry set, so detecting this currently requires reading the DB.
Happy to provide the operator-set dumps or more detail on any of the above.
Summary
Registry events can be silently and permanently lost during historical event sync.
FetchHistoricalLogsfetches logs with bloom verification disabled, then advanceslastProcessedBlockregardless of whether the execution client actually returned the logs for those blocks. Because the block marker and the events commit in the same transaction and nothing ever revisits a processed block, an incompleteeth_getLogsresponse becomes a permanent gap in the node's registry state.We found this in production-like conditions on our hoodi-stage testnet: one node had been running for ~3.5 months missing an operator, and two others are missing 32 operators and 2 validator shares each. All three report healthy: duties execute, consensus works, metrics are green, and
lastProcessedBlockis level with their peers. The only way we detected it was by copying the BadgerDB files off the pods and diffing the operator tables.This is not fork-related and is present on
main.Impact
RegistrySyncOffset— there is no reconciliation or self-heal path.lastProcessedBlock,cli/operator/eventsync.go), so this is the routine post-restart catch-up path, not a rare cold-start edge case. Every restart is an opportunity to lose events.operator/validator/controller.go, "operator data not found, validator will only start if the number of available operators is greater than or equal to the committee quorum"). We got lucky: in our cases the missing operators happened not to be in the affected nodes' own committees, so duties were unaffected. That is chance, not design.Mechanism
All references at
main@a1bcd468f,eth/executionclient/execution_client.go::143 — historical fetch disables the check:
versus :506 for streaming, which enables it (
..., true).:192-194 — the skip is deliberate and documented:
eth/executionclient/bloom_test.godocuments the same ("FetchHistoricalLogsskips bloom checks"). So the EL returning incomplete logs is a known failure mode — it just isn't guarded here.:240-243 — progression is emitted whether or not logs arrived:
eth/eventhandler/event_handler.go:172-191—processBlockEventswrites whatever events arrived andSaveLastProcessedBlockin one atomic transaction and commits. With zero or partial logs, the marker still advances and the transaction still commits successfully.Nothing re-reads a block below
lastProcessedBlock; in factprocessBlockEventstreats a lower block as a hard error (ErrInferiorBlock). The gap is therefore permanent.Net effect: a single bad batch response silently drops every registry event in it, and the node cannot tell that it happened.
Evidence
Canonical chain state for our hoodi-stage registry is 587 operators / 43,304 shares. We verified this independently two ways: a node that did a full clean resync from
RegistrySyncOffsetproduced exactly that, and replaying the event log gives 751OperatorAdded− 172OperatorRemoved= 587, set-identical to that node's DB.We then surveyed DB creation era across all 111 of our stage nodes, identified the 24 whose DBs predate the incident window, and read the operator tables directly out of copies of those BadgerDBs (opened via
operatorstorage.NewNodeStorage→ListOperatorsAll, i.e. the node's own code path, validated against known-good counts before trusting any diff).Node A — DB created 2026-04-21, reports 586 operators / 43,304 shares:
0xfcc6172ff307011100f2e5f179f4dac6c74a5d3bf657543af15a809c40b80dec, never removed.OperatorAdded+OperatorPrivacyStatusUpdated) — consistent with the whole block's log set being lost.Nodes B and C — report 555 operators / 43,302 shares, byte-identical to each other:
lastProcessedBlockis 3,397,122 / 3,397,128 — level with healthy nodes. Not sync lag; permanent.The remaining 21 nodes in the cohort were clean (587 / 43,304). No node anywhere held a row that canonical lacks, and there were no owner/pubkey mismatches — the loss is strictly one-directional.
Why other explanations were excluded
MalformedEventErrorskip path (event_handler.go:215-218, which does silently drop an event while committing the block). That requires a pre-existing conflicting row per event; 32 operators plus 2 unrelated shares in one contiguous window cannot plausibly be 34 separate ID/pubkey conflicts. Operator 542's pubkey is also unique across all 587, and no conflicting row exists.eth_getLogsbatch response looks like.Suggested fix
Enabling
verifyBloomunconditionally would cost the ~200 header RPCs per batch the comment is trying to avoid. A cheaper option that still closes the hole: only pay for verification when a batch looks suspicious — e.g. when a block range returns zero logs (or fewer than expected), fetch the headers for that range and check the blooms; re-request if the bloom indicates logs should exist. That confines the extra RPCs to the rare suspicious case rather than the common path.Whatever the shape, the important properties seem to be:
lastProcessedBlockpast a range whose log completeness was never established.Caveats / what we could not prove
ValidatorAddedblocks fall inside the 86k-block window (the scan was too slow to finish). It is consistent with the window, not proven.ssv_validator_validators_per_statuscounts only a node's own validators, not the global registry set, so detecting this currently requires reading the DB.Happy to provide the operator-set dumps or more detail on any of the above.