eth: verify registry-event completeness after optimistic historical sync (#2990) - #2991
Open
iurii-ssv wants to merge 4 commits into
Open
eth: verify registry-event completeness after optimistic historical sync (#2990)#2991iurii-ssv wants to merge 4 commits into
iurii-ssv wants to merge 4 commits into
Conversation
Contributor
Greptile SummaryThe PR adds deferred completeness verification and guarded automatic repair for large optimistic registry-event syncs while retaining inline verification for normal catch-ups.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains from the previous review thread. The previously reported large-sync verification gap is invalidated by atomic range journaling and the background verifier’s bloom-and-receipts completeness pass.
|
| Filename | Overview |
|---|---|
| eth/eventsyncer/verifier.go | Implements resumable background comparison, receipt-based mismatch resolution, range parking, and rate-limited repair signaling. |
| eth/eventhandler/event_handler.go | Journals optimistic verification state atomically with registry updates and the processed-block marker. |
| eth/executionclient/bloom.go | Extends completeness checks with bounded header/log requests and receipt-derived recovery. |
| cli/operator/eventsync.go | Selects inline versus optimistic startup sync and runs background verification alongside ongoing event synchronization. |
| operator/storage/verification.go | Adds durable verification ranges, block digests, and resumable repair flags. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Historical registry sync] --> B{Catch-up size}
B -->|Small or repair| C[Inline bloom and receipts verification]
B -->|Large| D[Optimistic eth_getLogs sync]
D --> E[Atomically persist marker, range, and digests]
E --> F[Start ongoing sync]
E --> G[Background verifier]
G --> H{Digests agree}
H -->|Yes| I[Retire verified range]
H -->|No| J[Resolve block through receipts]
J -->|Recorded state is complete| I
J -->|Receipts unavailable| K[Park range]
J -->|Confirmed miss| L{Resync cooldown}
L -->|Expired| M[Persist resync-required and terminate]
L -->|Active| K
M --> N[Next start drops registry state]
N --> C
Reviews (2): Last reviewed commit: "eth: harden verify/repair robustness fro..." | Re-trigger Greptile
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
2 times, most recently
from
August 13, 2026 12:00
6c5dc39 to
eda3191
Compare
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
4 times, most recently
from
August 13, 2026 21:03
d75fa32 to
58aad62
Compare
momosh-ssv
reviewed
Aug 14, 2026
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
from
August 14, 2026 09:16
58aad62 to
6a0f437
Compare
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
from
August 14, 2026 11:09
6a0f437 to
23b3dc5
Compare
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
from
August 14, 2026 14:44
23b3dc5 to
a6c6acd
Compare
…ync (#2990) Historical registry-event sync reads logs via eth_getLogs, which the execution client serves from a derived, non-consensus log index. That index can return fewer logs than a block actually holds — while still building (snap sync), pruned, mid-rebuild, or buggy (e.g. the geth log-index bug) — with no error. Because the sync advances its last-processed marker regardless, events dropped this way were silently and permanently lost. Verifying every block inline (header bloom + receipts) is too costly to run across a full cold sync on the startup path, so the work is split: - Optimistic hot path: the historical sync goes optimistic only when large — a cold sync from the registry offset, or a very prolonged restart catch-up. A normal catch-up (up to ~a week of blocks) is bloom-checked inline instead. On the optimistic path, for each non-empty block it records a digest of the logs received and extends an unverified range, both in the same transaction that advances the marker — so a crash can't leave a processed block outside the range the verifier will check. The journal signal is a pointer, so an offset-0 network still journals. - Background verifier: off the startup critical path, each journalled range is re-fetched (VerifyLogs) and compared block-by-block to the recorded digests. On a disagreement it resolves the block against receipts — the index-independent source of truth — and flags a resync only when receipts confirm the sync genuinely missed events; if receipts are unavailable it parks the range (kept visible on pending_ranges) instead of resyncing on unauthoritative evidence or claiming it verified. Progress is persisted per chunk and transient failures are retried in-process. - Repair: registry events are order-dependent and carry per-owner nonces, so a miss can't be patched in place. The verifier flags a resync; the node drops registry state and resyncs from the registry offset with inline verification. The repair is resumable — an interrupted one continues from the marker rather than restarting — and rate-limited, so a common-mode EL fault can't fatal+wipe+resync the operator set in a loop. Near-head streaming stays inline-verified as before. Batched RPC helpers are bounded and remember a provider that rejects batching. Also fixes a uint64 underflow in the event syncer's staleness guard.
iurii-ssv
force-pushed
the
fix/2990-historical-log-verification
branch
from
August 14, 2026 16:05
a6c6acd to
b2623df
Compare
Drives the real ExecutionClient -> EventSyncer -> EventHandler -> BadgerDB pipeline against a go-ethereum simulated backend fronted by an HTTP JSON-RPC proxy that induces the incomplete eth_getLogs of #2990. Covers: a healthy EL verifies clean (no false resync); a persistently-dropped block is silently missed by the optimistic sync, recovered from receipts by the background verifier, and rebuilt by the resync; and an unresolvable drop with receipts unavailable parks the range. Exercises the real bloom/receipts/batching code paths rather than mocks.
- executionclient: MultiClient.BlockContractLogs fails over across clients, reporting receipts unavailable only when every reachable, healthy client lacks eth_getBlockReceipts — a mixed fleet now resolves a disagreeing block instead of parking on one client's gap. - executionclient: subdivideLogFetch also splits on response-size errors (websocket read limit, HTTP 413, "response too large"), not just the -32005 query-limit code, so a wide verify chunk can't wedge VerifyWithRetry in a no-progress loop. - executionclient: rememberBatchingUnsupported no longer latches on a batch timeout or cancellation, which would wrongly downgrade a slow-but-batching- capable provider to sequential for the connection. - eventsyncer: correct the parked-range log and VerifyWithRetry doc — parked ranges are re-checked on the next node start, not retried in-process. Adds unit tests for the failover, the subdivide predicate, and the batch-latch exclusion.
momosh-ssv
previously approved these changes
Aug 21, 2026
Contributor
Author
|
@greptile pls re-review |
- executionclient: a zero-receipt eth_getBlockReceipts response for a block
evidenced to hold logs is self-inconsistent. resolveSuspectBlock now falls
back to timed re-requests instead of recording a bloom false positive, and
BlockContractLogs reports receipts unavailable so the verifier parks (or
fails over under MultiClient) rather than flagging a false miss and
triggering a needless wipe+resync.
- executionclient: recognize -32004 ("method not supported", EIP-1474)
alongside -32601 when detecting a missing eth_getBlockReceipts, so a
non-conforming EL degrades to the retry fallback instead of wedging
streaming and background verification.
- eventsyncer: E2E coverage for the #2990 field report's blast radius — a
dropped block carrying a fee-recipient update, a cluster liquidation and an
owner-nonce bump drifts all three, and the detect+repair loop restores them,
with the verified resync replaying ValidatorAdded over the key manager's
existing share account. Also E2E for empty-receipts parking and for a parked
range resolving cleanly once receipts return.
- executionclient: dedupe the batched-RPC skeleton behind HeadersByNumbers and
SingleBlockLogs into a generic batchedByBlock helper.
- review cleanups: logger argument order in the boot helpers, verify-cursor
gauge semantics documented, unreachable return dropped in resolveMismatch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2990.
TL;DR
eth_getLogsis served from the EL's derived, non-consensus log index, which can silently return fewer logs than a block holds; historical sync trusted it, so registry events could be lost permanently and invisibly (Registry events silently and permanently lost: historical log sync skips bloom verification but advances lastProcessedBlock anyway #2990).ssv.event_syncer.verify.missesand.parkedshould stay 0; a confirmed miss shows up as a loud warning + self-restart into a rebuild.Problem
Historical registry-event sync reads logs via
eth_getLogs. The log index serving it can return fewer logs than a block actually holds — while still building (snap sync), pruned, mid-rebuild, or buggy (e.g. the geth log-index bug) — with no error. Because the sync advanced its last-processed marker regardless, events dropped this way were silently and permanently lost.A field analysis of an affected node (full-keyspace diff of the drifted DB against a canonical resync) confirmed the blast radius: a dropped window loses whatever registry events it contained — operator registrations, fee-recipient updates, owner-nonce bumps, cluster liquidations — not just the
OperatorAddedevents the issue was first noticed by.Verifying every block inline (header bloom + receipts) is too costly to run across a full cold sync on the startup critical path (see Costs), so the work is split into an optimistic hot path plus background verification with an automatic, guarded repair.
How it works
1. Optimistic hot path — the historical sync goes optimistic only when it's large: a cold sync from the registry offset, or a very prolonged catch-up. A normal restart catch-up (up to
maxInlineVerifyCatchUp= 50k blocks ≈ a week) is instead bloom-checked inline, so the node starts with guaranteed-complete state and no window. On the optimistic path,eth_getLogsis used plain (fast), and for each non-empty block the handler records a digest of the logs it received — sha256 over each log's(TxIndex, Index)— and extends an "unverified range", in the same transaction that advances the marker, so a hard crash can't leave a processed block outside the range the verifier will later check.2. Background verifier — off the startup critical path (concurrent with ongoing sync), each journalled range is re-fetched (
VerifyLogs) and compared, block by block, to the recorded digests. On a disagreement it resolves the block against receipts and flags a resync only when receipts confirm the sync genuinely missed events (eth_getLogscan only ever return a subset, so a recorded digest short of receipts is provably a miss). When the verify-time fetch merely blipped (or a different EL underMultiClientreturned less), receipts vindicate the recorded digest and the block is retired clean. If there is no authoritative source for the block, the range is parked — left pending and visible onpending_ranges, re-checked on the next start — rather than resynced on unauthoritative evidence or retired as verified. "No authoritative source" covers an EL withouteth_getBlockReceipts(underMultiClientthe receipts resolution first fails over across clients, so one client's gap doesn't park a mixed fleet) and a self-inconsistent response: zero receipts for a block evidenced to hold logs is a broken receipt store, never proof the block is empty. Progress is persisted per chunk (resumable), and transient failures retry in-process with capped backoff.3. Repair — registry events are order-dependent and carry per-owner nonces, so a detected miss cannot be patched in place. The verifier persists a resync flag and returns a sentinel that terminates the node; the next start drops registry state and resyncs from the registry offset with inline verification. The repair is resumable (a
resync-in-progressmarker means an interrupted repair resumes from the last-processed marker — its progress was inline-verified — instead of re-dropping) and rate-limited (defaultResyncCooldown= 6h: further confirmed misses within the window are parked and logged, not acted on, so a common-mode EL fault can't fatal+wipe+resync a large fraction of the operator set in a loop).Guarantees
Residual blind spot: if the sync and the verify-time re-fetch drop the same logs, the digests agree and receipts are never consulted — an identical double-drop goes undetected. Resolving every block against receipts would close it but is far more expensive, and the partial within-block drop it guards against has not been observed.
Operator impact
operator/prefix (unverified ranges, per-block digests, resync flags), absent on existing databases and created on demand. Downgrade-safe: older versions ignore the new keys; ranges synced by an older version are simply not verified (the pre-fix status quo).maxInlineVerifyCatchUpare verified inline in minutes (see Costs), so there is no unverified window after ordinary restarts.pending_ranges > 0until it finishes. During that window a missedAddedunder-serves (a validator whose data isn't present simply doesn't start, then starts once reconciled), while a missed liquidation/removal briefly over-serves (duties keep running that should have stopped — field-observed, never slashing-relevant) until verification catches it.registry resync required; the next start drops registry state and rebuilds with inline verification — validator downtime for the duration of the rebuild.DropRegistryDatanever touches signer/slashing-protection storage, and the resync's replayedAddShareis idempotent over the key manager's existing accounts and only ever bumps slashing protection. Field-corroborated: the drifted node's post-resync slashing values had strictly advanced.ssv.event_syncer.verify.missesandssv.event_syncer.verify.parkedshould stay at zero.parked > 0→ the EL can't authoritatively verify a block (usually missingeth_getBlockReceipts) — investigate the EL.suppressed > 0→ a repair is being deferred by the rate limit.pending_ranges/cursorshow verification progress. Genuine receipts recoveries count on the existingssv.el.bloom.checksmetric.Costs (why the split)
Measured on real infra (local Besu/hoodi + two mainnet block samples via public RPC), using the same bloom-filter test the fix uses:
getBlockByNumber(false), incl. tx-hash list)getBlockReceiptspayloadTwo cost drivers: an O(N) header/bloom screen (one header per empty block, regardless of FP rate) and O(FP) receipts (bloom-positive-empty blocks). Projected:
So verifying every block inline across a cold sync would add ~1–2 h to the startup critical path. Hence the split: ≤ threshold verifies fully inline (effectively free); > threshold goes optimistic and moves the unavoidable verification off the critical path into the background — startup stays ~pre-fix speed while the gap is still closed (eventually), not skipped.
Review guide
Suggested reading order:
operator/storage/verification.go— journal + flags storageeth/executionclient/logs.go— the per-block digesteth/eventhandler/event_handler.go— same-transaction journalingeth/eventsyncer/verifier.go— digest comparison, receipts resolution, park/rate-limitcli/operator/eventsync.go— boot wiring: inline/optimistic split + resumable repaireth/executionclient/bloom.go— bloom→re-request→receipts completeness check,VerifyLogs,BlockContractLogseth/executionclient/eth_client.go— bounded batched RPC with sequential fallbackThe rest of the diff is mostly mechanical: the two regenerated mock files, and test files updated for the new parameter on
SyncHistory/FetchHistoricalLogs/HandleBlockEventsStream.Also included
*uint64, not a0sentinel, so a network withRegistrySyncOffset == 0(local-testnet, custom YAML) journals instead of silently disabling verification.HeadersByNumbers/SingleBlockLogscap each batch (~100, matching common hosted-provider limits) with per-element sequential fallback, and remember a provider that rejects batching wholesale (latched only when the sequential fallback succeeds — and never on a batch timeout/cancellation — so neither an outage nor a slow provider false-latches).-32004("method not supported", EIP-1474) is recognized alongside-32601when detecting a missingeth_getBlockReceipts.subdivideLogFetchalso splits on response-size failures (websocket read limit, HTTP 413, "response too large"), not just the-32005query-limit code, so a wide verify chunk can't wedge the verifier in a no-progress retry loop.ensureBlockAboveThresholdcompared in unsigned space; a large staleness threshold wrapped the cast and flagged every block as too old. Now compared in signed space.Accepted tradeoffs (feedback welcome)
DropRegistryData+ resync-from-offset (validator downtime for the rebuild). Misses should be rare (EL bug/corruption only), a full rebuild is the only correct remedy given the nonce ordering, and the repair is resumable + rate-limited to bound the cost. A surgical replay-from-affected-block is deliberately left as follow-up.Testing
eth_getLogs) — exercises the realExecutionClient(bloom/receipts/batching), not mocks: (a) healthy EL verifies clean with no false resync; (b) a persistently-dropped block is silently missed by the optimistic sync, recovered from receipts by the background verifier, and repaired by the resync so the DB ends complete — the Registry events silently and permanently lost: historical log sync skips bloom verification but advances lastProcessedBlock anyway #2990 loop; (c) an unresolvable disagreement witheth_getBlockReceiptsunavailable parks the range instead of resyncing; (d) the field report's full blast radius — a dropped block carrying a fee-recipient update, a cluster liquidation and an owner-nonce bump drifts all three and the repair restores them, with the verified resync replayingValidatorAddedover the key manager's existing share account; (e) an empty-receipts response parks (no false resync) and the parked range retires clean once receipts return.eth_getLogs-drop recovery regression test for Registry events silently and permanently lost: historical log sync skips bloom verification but advances lastProcessedBlock anyway #2990 is retained.go build ./..., pinnedgolangci-lint(0 issues), and-raceruns of the affected packages pass.End-to-end validation on real Besu
Beyond the in-process E2E above, the fix was validated end-to-end against a real, external EL exhibiting genuine on-disk log-index corruption — the actual #2990 mechanism, not a simulated drop. This harness is intentionally not committed / CI-run (it needs an external synced node); the methodology is recorded here so it can be reproduced.
Rig. Besu (hoodi, snap sync) + Lighthouse (hoodi, checkpoint sync), synced past block 2,746,590 — where operator 542 registered on the hoodi SSV contract
0xc07B3E9671f884FDa67E1e7D43d952E0e1369fd8.Fault injection. Besu serves
eth_getLogsfrom per-block log-bloom cache files (caches/logBloom-<segment>.cache, 256 bytes/block, no checksums). Zeroing operator 542's slot — segment2746590 / 100000 = 27, offset(2746590 % 100000) * 256 = 11,927,040, 256 bytes — makes Besu silently return incomplete logs for that block while its receipts stay intact. Verified directly against the running node:eth_getLogsat block 2,746,590 dropped from 2 logs to 0 (no error), whileeth_getBlockReceiptsstill returned both.Result. Driving the real
ExecutionClient → EventSyncer → EventHandler → BadgerDBpipeline against the corrupted node reproduced the full loop:SyncHistorysilently missed operator 542 — Registry events silently and permanently lost: historical log sync skips bloom verification but advances lastProcessedBlock anyway #2990;recovered contract logs from block receipts that the execution client's log index did not return … consider resyncing), and flagged a resync;Confirms the detect→repair path works against a real EL with a genuinely corrupt log index, not just a simulated one.
Out of scope / follow-up