Fix Pectra/EIP-7549 attestation over-counting, add Erigon (Caplin) compatibility, repo & test rework - #28
Merged
stakepeter merged 222 commits intoMay 16, 2026
Conversation
- **Extract metrics into testable `MonitorMetrics` struct** — Moved all 14 Prometheus metric declarations from inline locals in `MonitorAttestationsAndProposals()` into a new `MonitorMetrics` struct with a `NewMonitorMetrics(reg)` factory in `pkg/metrics.go`. This enables test isolation by accepting a `prometheus.Registerer` instead of hardcoding `prometheus.DefaultRegisterer`. - **Fix duplicate attestation counting (H11)** — Extracted attestation processing into `processAttestations()`, which iterates blocks in sorted slot order and deduplicates `(validator, slot)` pairs. Adds a `DuplicateAttestationsSkipped` counter to track how many duplicates are filtered. This was the primary cause of false `ValidatorsDelayedOverTolerance` alerts. - **Fix cross-epoch attestation blindness (H09)** — `ListEpochBlocks()` now fetches 4 extra slots past the epoch boundary, catching attestations from the last epoch slots that are routinely included in the next epoch's first blocks. Adds a `CrossEpochAttestations` counter. - **Fix silent error logging (H07)** — Two `log.Error().Err(err)` calls were missing `.Msg()` terminators, causing errors to be silently swallowed. Added descriptive messages at `ListEpochBlocks` (block fetch) and `ListBestBids` (MEV bid traces). - **Add new observability metrics** — `RawAttestationDistances` histogram (pre-dedup), `MissedSlotsInEpoch` gauge, `CrossEpochAttestations` counter, `DuplicateAttestationsSkipped` counter. - **Bump `testify` to v1.9.0** — Added as a direct dependency for new unit/integration tests.
The SSE handler dropped every epoch in (lastEpoch, thisEpoch) when a head event jumped by more than one. This fires every startup — `lastEpoch` is seeded from the justified epoch (~head-2), so the first head event always skips at least one epoch. Attestation tracking requires consecutive epoch processing: an attestation for the last slot of epoch N can only be included in epoch N+1's blocks, so skipping N+1 produces false "did not attest" reports for late-slot duties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile: golang:1.25.10-alpine3.23, alpine:3.23 - go-eth2-client v0.28.0 -> v0.28.1 - testify v1.9.0 -> v1.11.1 - demote bitfield/client_model/testify/yaml.v3 to indirect Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stakepeter
marked this pull request as ready for review
May 12, 2026 07:21
processAttestations had a within-call dedup but the seenAttestations map was rebuilt every iteration, so an attestation observed inside epoch N's lookahead window and again in epoch N+1's main scan double-counted TotalCanonicalAttestations, CanonicalAttestationDistances, CrossEpochAttestations, and TotalDelayedOverTolerance. Lift the map into MonitorAttestationsAndProposals so it persists across epochs, and prune entries older than EpochLowestSlot(epoch-1) each iteration to bound memory. Cross-epoch duplicates now flip DuplicateAttestationsSkipped instead of inflating the canonical counters. Add two tests pinning the across-call and within-call dedup paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
golangci-lint v1.60 (built with Go 1.23) cannot lint a Go 1.25 target, so CI failed before running any checks. Bump the action to @v8 and pin the linter to v2.12.2 (built with Go 1.25). Drop the deprecated skip-pkg-cache input. v2 has fewer default exclusions than v1.60, which surfaces 11 pre-existing issues. Fix them in place rather than re-introducing the old exclusions: - errcheck on best-effort cleanup paths: wrap defer Close/Remove in func() { _ = X() }(); drop ignored Register error to _. - staticcheck S1005: drop redundant blank identifiers in range loops. - staticcheck ST1005: lowercase error-string prefixes in mev.go. - staticcheck QF1012: fmt.Fprint(&sb, v) instead of WriteString+Sprint. Verified locally with golangci-lint v2.12.2: 0 issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues showed up running against Erigon Caplin on Hoodi staging: 1. Caplin emits Gwei amounts and the deposit index in execution_requests as bare JSON numbers, instead of the spec-required quoted strings. go-eth2-client's strict unmarshal then fails the whole block parse. Add an HTTP RoundTripper for the beacon-block endpoint that quotes "amount" and "index" before the parser sees them; pass-through for SSZ responses and non-block paths. 2. After the AttesterDuties-only committee lookup landed, attestations that aggregated across committees containing no tracked validators (EIP-7549) walked AggregationBits with the wrong offset, since we skipped untracked committees without advancing past their bits. Caplin aggregates across many committees per attestation, so this surfaced as a steady stream of false "did not attest" warnings. Fetch full committee lengths via BeaconCommittees for the epoch window and seed BuildCommitteeLookup with them; processAttestations now advances the offset by every committee's real length and skips any attestation referencing a committee we still don't know about instead of corrupting state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListEpochBlocks scans blocks in [low(E), high(E)+4]; the 4-slot lookahead routinely sees blocks in epoch E+1 that include attestations for E+1's first few slots. processAttestations would: 1. Walk the attestation, find a tracked validator's bit set, add to the in-call attesters set. 2. Record the (slot, validator) pair in seenAttestations. 3. Call unfulfilledAttesterDuties[attestedSlot].Remove(...) — but during the previous epoch's iteration that slot's duties weren't yet in the map, so Remove on the nil set was a silent no-op. When epoch E+1's iteration ran, it populated unfulfilledAttesterDuties with the new epoch's duties, rescanned the same blocks, and on the second observation the seenAttestations dedup hit and skipped the Remove via early-continue. The validator stayed unfulfilled and was reported as missed one iteration later. Move the unfulfilled-Remove above the dedup check so it always runs. Dedup now only gates metric increments, which is its intended role. Set.Remove and IsEmpty are nil-safe (delete on a nil map is a no-op), so the unconditional call is safe regardless of whether the slot's duties have been populated yet. Verified on staging Caplin (Hoodi): - Before: 50-65 false misses per epoch concentrated on slots 0/1/2 - After: 4 consecutive epoch boundaries with map[] (zero unfulfilled), 100% success rate (5585/5585), 248 cross-epoch attestations correctly attributed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
processAttestations fetches AttesterDuties for [epoch-1, epoch, epoch+1] and builds committeeLookup for all three. When a block in the current epoch's scan window contains an attestation referencing a prev-epoch slot, my code correctly identifies the tracked validator's bit but then tries to compute the inclusion distance against a block far later than the actual first inclusion (which was in prev-epoch blocks we never fetched). The missed-slot adjustment loop further compounds this by treating every slot outside the scan as "missed", under-counting the real distance. Example from the live run: validator 1083751 had a duty at slot 3030716 (epoch 94709) and actually attested via block 3030717. A fresh container processing only epoch 94710 saw the cross-epoch attestation in block 3030724 (a later inclusion) and reported "attested slot 3030716 at slot 3030724, attestation distance is 3" — while the chain truth is distance 0. Fix: when the earliest possible inclusion slot is below EpochLowestSlot(epoch), skip the distance/canonical/delayed metrics for this attestation. seenAttestations is still updated so dedup remains correct on subsequent observations. For long-running containers the prev epoch's own iteration records the metric correctly; for fresh starts the metric is genuinely unknown and we choose silence over a misleading value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two service-layer fixes surfaced by the monitoring.go audit:
1. GetValidatorIndexes used a hand-rolled filter that included
ValidatorStateActiveSlashed. The spec's get_unslashed_attesting_indices
excludes slashed validators, and go-eth2-client already ships
ValidatorState.IsAttesting() with the right semantics. Switch to it so
slashed validators stop producing false "did not attest" reports until
they exit.
2. GetBlock returned (nil, err) for missed slots and then checked
`if resp == nil` after the err branch — dead code, since go-eth2-client
returns (nil, *api.Error{StatusCode:404}) on a missed slot, never
(nil, nil). Detect 404 explicitly and return (nil, nil) only in that
case; other errors keep propagating. Callers' behavior is unchanged
(ListEpochBlocks already treats both as missed), but the contract is
now honest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
requestEpochBidTraces wrapped the parent context in WithTimeout once, then ran every relay in an errgroup sharing that deadline. A single slow relay starved the rest of their retry budget: with eight relays and a 4s budget, one 3s relay left 1s for the other seven combined. Move WithTimeout inside each goroutine so the 4s applies per-relay. The parent ctx still propagates cancellation on shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Findings from a top-down review of pkg/monitoring.go assumptions. Each item is independent; they're bundled because they share the same audit. - Validator-index cache TTL was effectively infinite. The check used time.Until(at) which returns a negative duration for past timestamps, so "< 8h" was always true. Switch to time.Since(at) and shorten the TTL to 30m so an exited or rotated validator falls out of cache within a bounded window. - Empty validatorPubkeyFromIndex no longer panics. Mass exit, key rotation gaps, and beacon-node desync are all real ways to reach zero active validators temporarily; log a warning and continue instead of crashing the monitor. - Skip the genesis epoch at the top of the per-epoch loop. epoch - 1 appears in three load-bearing places (duty/committee window fetch and missedAttestationEpoch) that would underflow uint64 if a user passed --replay-epoch 0. The inner `if epoch > 0` guard around the seenAttestations prune cutoff is subsumed by the new outer guard. - Sort the two unsorted map iterations (proposal/empty/vanilla per block, and the missed-proposal reporter) so Slack reports come out in ascending slot order. - Extend the empty-block check beyond ExecutionPayload.Transactions to include BlobKZGCommitments and ExecutionRequests (deposits, withdrawals, consolidations). A new isBlockEmpty helper handles all three. A block that carries blobs or execution_requests isn't "empty" in the validator-economic sense even with zero transactions. - Split the vanilla-block metric. totalVanillaBlocks now counts only confirmed hash mismatches; a new totalMissingBidTraces counter covers the case where no bid trace was found at all (which could be a relay-side failure rather than a real vanilla block). Dashboards summing totalVanillaBlocks for "any non-MEV-tracked block" should now sum both counters. - Persist the last processed epoch in LocalCache. SubscribeToEpochs anchors at max(persisted, justified) on startup, and each iteration writes the just-finished epoch via SaveCache. A crash/restart no longer re-processes already-counted epochs, which previously spiked every cumulative counter. - Drop the 250-validator chunking in ListProposerDuties. The endpoint returns at most SLOTS_PER_EPOCH duties regardless of input size and go-eth2-client filters indices client-side, so the loop was decorative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix five factual errors that would mislead future sessions: the cache had no TTL (only an unused timestamp), golangci-lint runs as action@v8 with no pinned tool version, the Go version line hid a real go.mod/.tool-versions/CI mismatch, the committees endpoint was omitted, and totalMissingBidTraces was missing from the metrics table. Surface non-obvious behavior worth knowing before touching the code: the --mev-relays file is JSON despite the flag help text, the Caplin amount/index fixer only patches block responses, slashed validators silently drop from monitoring, and attestation dedup breaks if epochs aren't processed consecutively. Also catch up the codebase structure with the new beaconchain files and pkg/monitoring_test.go, and point at docs/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The release workflow built arm64 twice and skipped amd64, so amd64 binaries never shipped from tags. Tests only ran locally, so a regression could merge unnoticed. The CLI carried a --beacon-node flag that was wired but whose value nothing reads, a Slashings struct and a pair of Monitor fields with zero references, and an --mev-relays help text that described a one-per-line format while the loader expects a JSON array. None of these were doing useful work and several were actively misleading. Bumps the build job's Go to 1.25.x to match go.mod and the lint workflow. Adds a test job that the build now depends on. Removes the dead flag, struct, and fields. Corrects the --mev-relays help text. Updates CLAUDE.md to reflect the new CI shape, drop the now-fixed gotchas, and correct the golangci-lint version claim that I'd previously written wrong. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkg/monitoring.go shrinks from 669 to 232 lines and becomes a slim orchestrator. Each issue class (per-epoch state fetch, attestation detection, proposal detection) now owns its own file and tests so a single concern can be reasoned about and exercised in isolation. - epoch_context.go: EpochContext + BuildEpochContext bundle the validator-resolution, duty/committee/block/bid fetches into one call. Move ResolveValidatorKeys, ListProposerDuties, ListAttesterDuties, ListEpochBlocks, VALIDATOR_INDEX_INVALID. - attestations.go: processAttestations, BuildCommitteeLookup, CommitteeInfo (verbatim) plus new PruneSeenAttestations and FinalizeMissedAttestations extracted from the old loop. - proposals.go: isBlockEmpty (verbatim) plus new CheckProposal and FinalizeMissedProposals. CheckProposal returns bool so the orchestrator can preserve the existing proposer-mismatch path (do not delete the duty so it ends up reported as missed). - monitoring.go now only holds the orchestrator loop, SubscribeToEpochs, LoadKeys, LoadMEVRelays. Opportunistic fixes carried in the new code, not the old: - Empty-block report swapped validatorIndex/pubkey argument order; now matches the (index, pubkey) convention used by every other validator report. - Missed-proposal report gains the pubkey for parity with the missed-attestation report. Tests split alongside the code. monitoring_test.go is gone: - attestations_test.go inherits the 7 prior processAttestations / BuildCommitteeLookup tests and adds TestFinalizeMissedAttestations and TestPruneSeenAttestations. - proposals_test.go adds TestIsBlockEmpty (table-driven), TestCheckProposal_* covering empty / missing-bid / vanilla / optimal / mismatch / empty+missing-bid fall-through, and TestFinalizeMissedProposals. - test_helpers_test.go centralises counterValue, gaugeValue, histogramSampleCount, buildSingleValidatorAttestation. processAttestations and the persistent seenAttestations / unfulfilledAttesterDuties maps are unchanged byte-for-byte; the refactor only wraps them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
http.Post returns (nil, err) on dial-level failures (DNS, connection refused, transport context cancel). The previous code logged the error but did not return, so the deferred resp.Body.Close() ran against a nil response and panicked the goroutine. Reachable any time the configured Slack URL is briefly unreachable. Also bail after a json.Marshal failure instead of POSTing an empty body (the marshal of an int8-keyed map is the only realistic trigger, but sending "" to Slack on the error path is just noise). Add reporting_test.go covering: transport failure (no panic), empty-URL fast path (no request), happy path (one POST observed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous loop deferred file.Close() inside the for body, so every opened pubkey file stayed open until LoadKeys returned. A multi-file config could exhaust file descriptors on tight ulimits. Extract a per-file helper (readPubkeysFile) so the defer runs at the end of each iteration. Behaviour is otherwise identical: blank/whitespace lines are skipped, scanner errors are surfaced. Add LoadKeys / LoadMEVRelays tests covering merge with CLI keys, missing files, malformed JSON, and a best-effort fd-leak guard via /proc/self/fd (skipped on platforms without it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, any GetBlock error (transport blip, JSON quirk, etc.) was silently treated as a missed slot. For a slot owned by a tracked validator that meant a false missed-proposal report. Add a bounded retry loop (3 attempts, 200ms→400ms→800ms backoff) before falling back to the existing "treat as missed" behaviour. Ctx cancellation short-circuits the backoff so shutdown stays prompt. Persistent failures still log at ERROR so operators can alert on them. Introduce a narrow blockFetcher interface so the retry logic is testable without spinning up a fake beacon node. *beaconchain.BeaconChain satisfies it implicitly. Tests cover: transient error recovery, persistent error fallback, 404 short-circuit, ctx cancel during backoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously SaveCache wrote rawCache via a partial-write loop and renamed the tmpfile without calling Sync or Close. A crash between Write and Rename could leave torn JSON on disk; on next start LoadCache logged a json.Unmarshal error and returned an empty cache, forcing the monitor to re-resolve every validator index (slow on large key sets). Replace the partial-write loop with a single Write (os.File.Write does not short-write for in-memory buffers), then explicitly Sync + Close before Rename. Defer Remove(tmpPath) covers every error path; once Rename succeeds the path no longer exists and Remove is a harmless no-op. Add cache_test.go with seven cases: round-trip, multi-call merge, forward-only LastEpoch invariant, corrupt-file graceful fallback, missing-file first-run, tmpfile cleanup, and VALIDATOR_INDEX_INVALID sentinel preservation. A withTempCachePath helper redirects cacheFilePath so tests never touch the developer's real cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per spec attestation.data.slot < block.slot is invariant, so earliestInclusionSlot (= data.slot + 1) ≤ block.slot. If a malformed attestation surfaces with data.slot ≥ block.slot the uint64 subtraction underflows to ~1e19 and is recorded as a "delayed attestation" sample, spamming Slack and skewing the inclusion-distance histogram. Add a defensive check that logs at WARN and skips the attestation. Regression test asserts no metrics fire on a same-slot attestation. Also add set_test.go covering the Set generic helper: constructor, Add/Contains/Remove/IsEmpty, Elems iterator, String form, and the nil-set contract (Remove no-ops, Add panics — matching Go's nil-map semantics so refactors don't silently change behaviour). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Panel 4 (API Errors) was conflating two different signals: real
infrastructure errors (5xx, transport failures, beacon-side 4xx for
genuinely bad requests) AND the steady stream of 404s on
/eth/v2/beacon/blocks/{block_id} that the monitor uses as the
canonical missed-proposal detector. With even modest missed-slot
activity (Hoodi staging routinely produces several missed slots per
hour) the tile read a non-zero error share despite the underlying
infrastructure being healthy, defeating its stated intent of
"infrastructure health, not a validator metric".
Split the numerator: 5xx and transport errors count for every
endpoint, but 4xx only counts on endpoints OTHER than
/eth/v2/beacon/blocks/{block_id}. Denominator (total request volume)
is unchanged — the excluded 404s still appear in the Request Rate by
Endpoint timeseries inside the Beacon API row, just not as errors
here. 5xx / error responses on the block endpoint still count (those
DO signal real infrastructure trouble).
Updated the panel description to call out the exclusion.
…d count
Added 4 columns to panel 502 (Validator Clusters — top 50 by issue
score) and reordered the column set for readability:
Container | Validators | Success % | Missed | Missed rate | Delayed >tol | Delayed >tol rate | Delayed | Delayed rate | Served
- Missed rate = Missed / (Served + Missed)
- Delayed >tol rate = Delayed >tol / (Served + Missed)
- Delayed = count of canonical attestations included 2+ slots
later than optimal (spec distance ≥ 3, computed
from the canonical-distance histogram as
count − bucket{le=1.0} — the same expression the
issue-score formula already uses internally).
- Delayed rate = Delayed / (Served + Missed)
The column order groups Success % up front as the headline indicator,
then walks issue counts in descending severity (Missed → Delayed >tol
→ Delayed), each count immediately followed by its rate. Served sits
at the right as the least-actionable column.
Rate columns share thresholds (green / yellow @ 1% / red @ 5%) with
the API Errors stat. Count columns reuse the existing 1 / 10 threshold
band. Delayed = ≥2 slots late from optimal is the closest "any-delay"
definition the linear-1..32 histogram permits (the smallest bucket
le=1.0 conflates spec=1 with spec=2). Issue-score formula is unchanged.
The Attestation deficit (fleet) panel (id 10) was plotting three lines:
Missed, Delayed >tol, and a Distance loss line (Attestant's
1 − 1/avg_distance, computed as sum/(sum+count) over the canonical
distance histogram) pinned to a separate right y-axis.
Removed the Distance loss target, its byName field override, and the
matching description paragraph. The right axis goes with it, leaving a
two-series chart of just Missed and Delayed >tol on a single left axis.
The underlying canonical-distance histograms stay — the Distance
distribution row still renders them and the cluster table's "Delayed"
column still derives from count − bucket{le=1.0}.
…altime Panel 12 (Attester effectiveness) was pinned to a fixed [7d] window on every rate() call. The 7-day smoothing was originally added because Attestant's docs caution that single-attestation values are too noisy to act on as an SLO — but operators looking at this dashboard mid- incident need to see what the fleet is doing NOW, not what the average was over the previous week. Switched all 6 rate() windows from [7d] to [$__rate_interval] so the line auto-sizes per viewport pixel. Renamed the title to drop the "— 7d rolling" suffix and rewrote the description to explain the new behaviour and the noise tradeoff for short ranges.
…lation
After switching panel 12 from [7d] to [\$__rate_interval], the line
oscillated wildly between 100% and 0%/NaN gaps. Root cause: the
underlying counters (ETH2_totalMissedAttestations, ETH2_totalServed-
Attestations, ETH2_canonicalAttestationDistances_*) only step at epoch
boundaries — every 32 slots × 12s ≈ 6.4 min. Any rate() window
narrower than ~13 minutes can span zero epoch transitions, in which
case rate() returns NaN (only 1 sample within window) or 0 (samples
present but counter unchanged). The formula then either:
- clamps to 100% (both rates 0, 0/clamp_min(0,1e-9) = 0 → 1-0 = 1),
visible as the high plateaus, or
- propagates NaN, visible as gaps with spanNulls:false.
Pinned the window to [15m] in all 6 rate() calls — always spans ≥2
epoch boundaries on Hoodi cadence, so the rate is well-defined and the
line tracks recent fleet behaviour cleanly. Updated legendFormat from
"Effectiveness (7d)" to "Effectiveness (15m)" and explained the
choice in the panel description.
…anels Audited every \$__rate_interval usage against the underlying counter's emission cadence. The monitor's attestation counters step only at epoch boundaries (32 slots × 12s ≈ 6.4 min), so any rate window narrower than ~13 min oscillates between high spikes (at the boundary) and NaN/zero gaps (between boundaries). Same root cause as b7b997d (Attester effectiveness). Pinned to [15m] — always spans ≥2 epoch boundaries: - Panel 10 (Attestation deficit fleet) Missed / Delayed >tol - Panel 205 (Cross-Epoch Attestations rate) - Panel 203 (Duplicate Attestations Skipped rate) Kept \$__rate_interval where the counter is request-cadence rather than epoch-cadence: - Panel 401 (Request Rate by Endpoint) — ETH2_beaconAPIRequestsTotal increments per beacon-API request; many increments per minute, so \$__rate_interval is well-defined at any viewport width. The panel's existing description already documents this choice. Out of scope: panels 601/602 (per-cluster 1m timeseries) use [1m] which has the same root-cause problem against epoch-stepped counters; their titles announce the "1m" cadence explicitly, so left alone for a separate decision.
…tol / Delayed %
Surface the four primary attestation-quality KPIs as prominent stat
tiles in a new row directly under the existing top stat strip, so an
operator opening the dashboard immediately sees the fleet headline
numbers without having to sum the cluster table or pick a container:
- Success % — sum(served) / (sum(served)+sum(missed))
- Missed % — sum(missed) / (sum(served)+sum(missed))
- Delayed >tol % — sum(delayed_over_tol) / (sum(served)+sum(missed))
- Delayed % — (sum(canonical_count) − sum(bucket{le=1.0}))
/ (sum(served)+sum(missed))
These are the fleet-wide counterparts of the rate columns in the
Validator Clusters table, using the same shared denominator.
Layout: 4 tiles at y=6, x=0/6/12/18 with w=6 each, h=4. The
Attestation deficit / Attester effectiveness charts and everything
below shift down by +4 rows; the panels array order matches the new
y-ordering.
Success % uses high-is-good thresholds (red below 99%, green ≥ 99.9%);
the three issue rates use high-is-bad thresholds (green / yellow @ 1%
/ red @ 5%) matching the API Errors band. All four use [\$__range]
for the rate windows (the rates cancel out, giving an exact ratio
over the dashboard time selection), consistent with the other stat
tiles.
The existing "Attester Eff." (different formula — multiplicative
availability×inclusion) and "Delayed Rate" (different denominator —
served only) tiles are kept; they overlap conceptually but are not
duplicates.
After 773324f split the API Errors numerator into two sum() terms (5xx|error + filtered 4xx), the tile started rendering "No data" on healthy fleets where neither selector matches anything. Root cause is the Prometheus idiom that sum() over zero matching series returns the empty vector, NOT zero — and vector addition propagates emptiness, so empty + empty = empty, the division yields empty, and Grafana shows "No data". Wrapped each numerator term in (sum(...) or vector(0)) so an empty sum() falls back to a labelless scalar 0. Addition is then always well-defined and the tile renders 0.00% on a clean fleet, climbing when a real 5xx or non-missed-block 4xx actually fires. The denominator still uses clamp_min; in practice it's never empty because the monitor is always making beacon-API requests. The semantics of the exclusion are unchanged — missed-block 404s still don't count as errors.
Deleted the collapsed "About this dashboard" row at y=0 (panel 800) along with its nested markdown content panels (900-907): What this dashboard is for, Quick glossary, How to read this dashboard, Variables, Effectiveness explained, Severity bands, When effectiveness drops, References. Also stripped the dashboard-level description's pointer to the About row, which is now stale. The visible content (Global health, Clusters, Validators, collapsed trend rows) is unchanged. The 1-row gap left at y=0 is invisible at render time since nothing else lives there.
Removed panel 203 (timeseries inside the Beacon API row) — it tracked the rate of (validator, slot) dedup-skip events, which is an internal eth2-monitor counter rather than a validator-performance metric. The Beacon API row keeps the four endpoint-rate / error-rate / duration panels that actually inform infrastructure health.
The Distance distribution / Proposals / Beacon API rows were collapsed:true with their child panels packed inside the row's nested panels[] array (Grafana's collapsed-row storage format). On dashboard load, the operator had to manually expand each row to see anything. Flipped all three rows to collapsed:false, lifted every nested child to the top-level panels[] array, and laid out their gridPos.y sequentially below each row header: y=50 row 200 Distance distribution y=51 201, 202 Canonical / Raw distance distributions (h=8) y=59 204, 205 Missed Slots In Epoch / Cross-Epoch rate (h=8) y=67 row 300 Proposals (was y=51 collapsed) y=68 301, 302, 303 Served / Missed / Empty proposals (h=8) y=76 304-308 Last-event stat tiles (h=4) y=80 row 400 Beacon API (was y=52 collapsed) y=81 401, 402 Request rate / Error rate (h=8) y=89 403, 404 Request duration p50 / p99 (h=8) Other rows (Global health, Clusters, Validators) were already non-collapsed; no change to their content.
Replaced every short fixed window ([1m], [5m], [10m]) with [15m]
across query expressions. Total: 9 occurrences:
- Panels 601, 602 (per-cluster missed / delayed-over-tolerance):
3 [1m] each. Their titles and descriptions also said "1m" /
"1 minute" — updated to "15m" / "15 minutes" to match.
- Panel 402 (Error Rate by Endpoint + status_class): 1 [5m].
- Panels 403, 404 (Request Duration p50 / p99): 1 [10m] each.
Same root cause as the earlier $__rate_interval audit: the eth2-monitor
attestation counters step at epoch boundaries (~6.4 min), so [1m] /
[5m] windows can span zero epoch transitions and yield NaN gaps or
zero-plateau oscillation. [10m] borderline cases get smoothed by the
upgrade. [15m] always spans ≥ 2 epoch boundaries on Hoodi / mainnet.
The beacon-API metrics (panels 402-404) are per-request so they
technically tolerate shorter windows, but unifying to [15m] keeps the
dashboard's smoothing behavior consistent and the operator's reading
of rate panels predictable. The only [$__rate_interval] usage left
(panel 401 Request Rate by Endpoint) is intentional — its description
already documents the auto-sizing choice for cold-start avoidance.
The dashboard carried several academic terms inherited from Attestant
/ Rated / BeaconScore methodology pages ("Attester effectiveness",
"RAVER-style proposer effectiveness", "Tier-3 indicator (Attestant
model)", "Attestant cause stakefish#1") and two opaque multiplicative
formulas. With the recently-added Success / Missed / Delayed >tol /
Delayed % stat row, the academic-jargon tiles are mostly redundant.
Changes:
- Delete panel 2 "Attester Eff." stat tile — its
(1 − missed/total) × (count/(sum+count)) formula multiplied
availability by an inclusion-distance ratio; both factors now
live as separate plain tiles in the new bottom stat row.
- Delete panel 12 "Attester effectiveness" timeseries — same
multiplicative formula in chart form. The Attestation deficit
panel beside it already plots Missed % and Delayed >tol %
directly, which is what an operator actually wants to track.
- Top stat row: shift the 5 remaining tiles left to pack from x=0
(Current Epoch / Proposer Eff / Delayed Rate / Distance p99 /
API Errors at x=0/4/8/12/16).
- Attestation deficit panel: widen w=12 → w=24 to fill the row
now that the effectiveness chart is gone.
- Rewrite Proposer Eff. (panel 6):
title: "Proposer Eff. (24h)" → "Proposer Success % (24h)"
formula: dropped the RAVER 0.75×empty subtraction; now plain
served / (served+missed) where served = non_empty + empty
description: rewritten without RAVER reference.
- Description sweep on panels 3, 205, 501 to drop "Tier-3
indicator (Attestant model)", "Attestant cause stakefish#1: generation
delays", and "Per-validator effectiveness snapshot".
Zero remaining occurrences of effective/RAVER/BeaconScore/Attestant
in the dashboard JSON. histogram_quantile() in the API duration
panels stays — p50/p99 is universal Prometheus idiom, not jargon.
The 9 stat tiles under Global health already had graphMode:"area" configured, but each target was instant:true range:false — so the query returned a single scalar with no time series for Grafana to plot behind the big number. Switched every target to instant:false range:true so Prometheus returns a series across the dashboard's selected time range; the lastNotNull reducer keeps using the latest value as the big number, and the rest of the series fills in as the soft area sparkline behind it. Rate-using tiles also had their [\$__range] windows replaced with [15m]. With range:true, [\$__range] at every step evaluates rate() over the full dashboard time range — at any step in the middle of the series, that window reaches backwards by hours/days and smears the sparkline into a flat line. [15m] gives a proper trailing window matching the rest of the rate-using panels in this dashboard. Panel 6 (Proposer Success %) keeps [24h] for its sparse-duties semantic; panel 1 (Current Epoch) uses the gauge directly with no window. The big-number semantics shift from "average over the dashboard time range" to "last 15m" (or "last 24h" for Proposer Success %). For at-a-glance fleet health that's more useful — long ranges previously dragged everything toward neutrality. The dashboard's other rate-using charts already use [15m], so the framing is now consistent end-to-end.
The Proposer Success % tile was producing a single sparse signal that spent most of its time reading "No data" (proposer duties are rare — even a small fleet on a long tail of inactive validators rarely satisfies the 5-duty floor at any given moment). Removed it. The 3 remaining top-row stat tiles shift left by 4 columns each (Delayed Rate / Distance p99 / API Errors at x=4/8/12) so the Current Epoch + 3 others pack against the left edge. Right half of the top row (x=16..23) is now empty. Proposer-side metrics are still surfaced in the Proposals row (panels 301-308) which separates served / missed / empty / vanilla explicitly — more useful than a single bundled "% success" tile.
After removing Attester Eff. (b16e239) and Proposer Success % (b053db2), the top stat row had only 4 tiles packed into the left half (x=0..15) with x=16..23 empty, while the second row was full width. Also Delayed Rate (panel 5) was a near-duplicate of Delayed >tol % (panel 32) — same numerator, only the missed-rate term distinguishes the denominators. Dropped Delayed Rate (panel 5) and reorganised the remaining 7 tiles into two balanced rows that each fill the full 24-col grid: Row 1 (y=2, h=4): Success % | Missed % | Delayed >tol % | Delayed % health KPIs first — operator's incident-time glance w=6 each (4 × 6 = 24) Row 2 (y=6, h=4): Current Epoch | Distance p99 | API Errors monitor liveness + supporting context / infra w=8 each (3 × 8 = 24) All retained tiles keep their existing queries, sparklines, units, and threshold bands.
Added the healthy-baseline counterpart to the issue-side metrics that
currently dominate the Global health and Clusters sections. Two new
timeseries panels:
- Panel 11 "Attestation served rate (15m)" — fleet-wide single line,
sits at y=10 x=0 w=12 next to the Attestation deficit chart which
shrinks from full-width to the right half (x=12 w=12). The two
panels now mirror "throughput" + "failure rate" side by side.
- Panel 605 "Attestation served rate (per cluster, 15m)" — one line
per container, sits at y=26 x=0 w=8 as the leftmost panel in the
Clusters trio. Panels 601 (% missed) and 602 (% delayed-over-tol)
shift right to x=8/w=8 and x=16/w=8 respectively. The three panels
now fill the 24-col grid evenly: served | missed | delayed-over-tol.
Both new panels use sum(rate(ETH2_totalServedAttestations{…}[15m])),
unit "ops", min 0. The fleet panel uses fixed green; per-cluster uses
palette-classic for multi-line distinction.
Hid the Served column in the Validator Clusters table (panel 502) by
adding "Served": true to the organize step's excludeByName. The
column's underlying value (Value #E) is still computed and fed into
Total / Success % / Missed rate / Delayed rate / Delayed >tol rate —
those rate columns continue to render correctly because exclude-by-name
runs AFTER the calculateField transforms.
Reordered the Validators table (panel 501) metric columns from "Missed → Success % → Delayed >tol → Served" to "Success % → Missed → Delayed >tol → Served" by updating the organize transformation's indexByName map. Identifier columns (Index, Container, Pubkey) keep their position at the front. The new order matches the Cluster table's grouping (headline health metric first, then severity-descending issue counts, served count last as least-actionable context).
The view_mode template variable's two options ("Problematic only" = 0
default, "All" = -1) toggled the topk score threshold in the Cluster
and Validator tables. Operators never selected "All" in practice — a
healthy dashboard means the tables stay empty, and "All" produced a
wall of 99.99% rows that buried any actual issue. Removing the
toggle makes the dashboard always behave as the useful default.
Changes:
- Replaced all 10 \$view_mode references (6 in panel 502, 4 in
panel 501) with the literal 0. Both tables now always filter to
rows with non-zero issue score (top 50 / top-\$top respectively).
- Removed the view_mode entry from .templating.list — the "View"
dropdown is gone from the top variable bar.
- Updated both table descriptions to drop the View-toggle clause,
explaining the always-on filter behaviour instead.
- While in the Validators description, also dropped the stale
"drilldown row below" wording — those state-timeline panels
(603/604) were deleted in 6e08b71; the Index link now filters
the dashboard rather than pinning a panel.
Mixed terminology in the dashboard UI — some places said "container"
(the underlying Prometheus label name), others said "cluster" (the
operator-facing concept). Renamed every display string to "cluster"
for consistency:
- Title: "Clusters (by container)" → "Clusters"
- Distance p99 description: "Per container, …" → "Per cluster, …"
- Validator Clusters table description: "Per-cluster (container)
rollup …; Click a Container cell …" → "Per-cluster rollup …;
Click a Cluster cell …"
- Per-cluster panel descriptions: "One line per scrape container"
× 2 → "One line per scrape cluster"; "One line per container —"
→ "One line per cluster —"
- Missed Slots In Epoch description: "per container" / "one
container" → "per cluster" / "one cluster"
- Variable description: dropped the "(Prometheus label: container)"
implementation-detail leak
- Both tables' column header: "Container" → "Cluster" (organize
renameByName) plus the dependent byName field override matcher
The underlying Prometheus label name stays as "container" — all
queries, label selectors, aggregation keys, variable internal name,
URL params, legendFormat substitutions, and prometheus.yml scrape
config are untouched. This is a pure display-string change.
The rate-based Global health tiles answer "how bad per-unit-time" but not "how many events happened over the selected time range". Added three absolute-count stat tiles for the latter: - Total Served = sum(increase(totalServedAttestations[\$__range])) - Total Missed = sum(increase(totalMissedAttestations[\$__range])) - Total Delayed >tol = sum(increase(totalDelayed…[\$__range])) New stat row at y=10, h=4, three tiles at x=0/8/16, w=8 each (full 24-col grid). Big-number-only (graphMode: "none") since totals are headline figures, not trends — operators already have the rate-based sparklines two rows above. Thresholds: Total Served uses low-is-bad (red < 1, green ≥ 1 — zero served means nothing's working); Total Missed and Total Delayed >tol use high-is-bad (green / yellow @ 1 / red @ 10) to mirror the rate tiles' colour band convention. Shifted every panel at y≥10 down by +4 to make room — uniform shift preserves relative spacing for the chart row (now y=14), Clusters row (y=21), per-cluster timeseries (y=30), Validators row+table (y=39/40), and all expanded inner rows below.
The 4 rate-based health stat tiles (Success % / Missed % / Delayed >tol % / Delayed %) used lastNotNull as the reducer, so the big number showed the most recent 15-minute value. Operators want the headline number to be the average across the dashboard's selected time range — "how did the fleet do overall in this window" — not "how is it doing right now". The sparkline tail is still visible for the latter. Switched calcs from "lastNotNull" to "mean" on panel ids 30/31/32/33. The other Global-health tiles (Current Epoch — gauge; Distance p99 — quantile; API Errors — rate ratio; Totals — single-point increases) keep lastNotNull since their semantics work better as snapshots.
The Total Served / Total Missed / Total Delayed >tol stat tiles (panels 34/35/36) were rendered as bare big-numbers (graphMode: "none") with instant queries. Switched all three to graphMode "area" with instant:false range:true so Prometheus returns a series across the dashboard time range and Grafana plots a soft area sparkline behind the big number. Query unchanged: sum(increase(ETH2_total*Attestations[\$__range])). With range:true, at each step t the expression evaluates over the trailing window [t − \$__range, t], so the series shows how the "trailing-range total" evolves across the dashboard window. The lastNotNull reducer keeps the big number = value at the right edge = total over the currently-selected range (semantic unchanged). A spike in the sparkline visually indicates a burst entered the trailing window; the line returns to baseline once the burst exits.
…axis Panels 201 (Canonical Distance Distribution) and 202 (Raw Distance Distribution) plotted raw bucket counts on the y-axis. Switched them to normalised fractions so each bar shows the share of attestations that fell in that bucket, summing to 100% across all buckets. Query change: divide cumulative bucket counts by the histogram total (sum(increase(_count[\$__range]))) — Grafana's histogram panel internally de-cumulates the cumulative fractions, so the rendered bars show per-bucket shares. Field config: unit "short" → "percentunit". Description updated to say "share of attestations" instead of "sample count". This makes the histogram shape comparable across time windows and fleet sizes: the peak height (typically near 0–1 shifted slots, i.e. spec distance 1–2) is now a percentage rather than an absolute count, so the curve reads the same whether you're looking at one cluster of a hundred validators or the whole fleet.
Added options.xAxisLabel = "Distance (shifted slots, 0 = optimal)" to panels 201 (Canonical Distance Distribution) and 202 (Raw Distance Distribution). The bucket boundaries already shown on the x-axis (le=1..32) ARE the distances, but without an axis label the operator had to read the description to figure that out. Now it's self-explanatory. Pairs with f7dc374 (percentage y-axis) — the histograms now read "share of attestations" along Y and "distance from optimal" along X without needing the description.
…able
Clicking a validator's Index in the Validators table sets the
\$validator_index variable, but until now nothing beneath the table
actually surfaced that validator's behaviour over time. Added three
timeseries panels in the Validators row, sitting directly below the
table at y=54, each w=8 (full 24-col grid):
- Panel 700 "Served attestations (per validator, stacked, 15m)"
Stacked area chart, rate(totalServed[15m]) by validator_index.
Useful with the default "All" filter to see total fleet throughput
split per validator, or after drilling down to compare a few
selected validators.
- Panel 701 "% missed (per validator, 15m)"
Per-validator miss share over a 15-minute trailing window.
- Panel 702 "% delayed-over-tolerance (per validator, 15m)"
Per-validator delayed-over-tol share.
All three queries respect the dashboard filters (\$service /
\$container / \$validator_index), so the operator's typical workflow
— pick a cluster, click a validator's Index — narrows the three
panels to a single validator's trend.
Shifted Distance distribution / Proposals / Beacon API rows and their
expanded content down by +8 to make room.
… count
Panel 700 plotted the 15-minute rate of served attestations per
validator. Switched it to the raw cumulative counter so the chart
shows the actual accumulated attestation count over time — a
monotonically rising stacked area that visualises how each validator
contributes to the fleet total.
Changes:
- Query: sum by (...)(rate(totalServed[15m])) → sum by (...)
(totalServedAttestations) (no rate, raw counter value)
- Unit: ops → short (event count, not events/sec)
- Title: "Served attestations (per validator, stacked, 15m)" →
"Served attestations (per validator, stacked, accumulated)"
- Description: rewritten to clarify the cumulative semantics
% missed and % delayed-over-tolerance panels (701, 702) are unaffected
— they remain 15m rate-based since the per-second share is what
operators want to track for those.
…m legends
Flipped the order within the Validators row:
- 3 per-validator diagrams (700 Served accumulated, 701 % missed,
702 % delayed-over-tolerance) moved from y=54 to y=40 — now they
sit directly below the row header
- Validators table (501) moved from y=40 to y=48 — sits below the
diagrams as the legend / lookup reference
Hid legends on all three diagrams (showLegend: false). The table
below already lists every validator with Index / Container / Pubkey /
Success % / Missed / Delayed >tol — that's a more compact and more
informative legend than a multi-line bottom-table in each panel.
Operators can hover a line for the tooltip, then cross-reference
against the table.
Section height (y=39..61) is unchanged so nothing below shifts.
Reconciles three sections with the supervisor work landed in 1efd600: - Codebase Structure: add supervisor.go / supervisor_test.go; note monitoring.go's SubscribeToEpochs is wrapped in subscribeWithRetry. - Architecture: rewrite "two goroutines" paragraph to introduce the outer monitoring.Supervise restart loop (cli/root.go:137) and the inner subscribeWithRetry wrapper (monitoring.go:180). - Test Fixtures table: soften the empty_block row to match reality — the bundle is opportunistically captured, may be absent on a fresh clone, and scenario_empty_block_test.go t.Skip's accordingly. Doc-only change; verified via three parallel audit passes that every new factual claim (function names, file paths, line numbers, error strings, metric labels, CLI flags) matches the code on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
TL;DR
What this project does (for reviewers unfamiliar with it)
eth2-monitoris a small Go CLI that connects to an Ethereum beacon-chain node (a "consensus layer" RPC endpoint), watches a configured list of validator public keys, and reports:Reports go to a Slack webhook; structured metrics go to Prometheus on port
1337. It runs as a long-lived daemon and is typically deployed alongside a beacon node operated by the same team.Why this PR exists
The branch began as triage of staging alerts firing on real, healthy validators. Root-causing the alerts revealed two things:
Fixing those two things confidently required better test coverage — which made it worth restructuring the monolithic monitoring package and capturing real beacon-chain responses as test fixtures. From there the branch picked up ~70 small reliability and ergonomics fixes. End result: ~165 commits / +10,889 / −1,557 lines across 121 files.
Glossary
One-line definitions of the jargon used below
0is optimal (next slot), higher = worse.Must()— Internal panic-on-error helper. We're converting "should-never-happen" call sites to detectctx.Canceledexplicitly so a clean shutdown doesn't panic through it.What changes for operators of this service
Visible in dashboards / alerts (deploy will change graphs):
ETH2_beaconAPIRequestsTotal/…DurationSeconds— every call to the beacon-chain RPC is now counted by endpoint, HTTP method, and status class. Use this to spot upstream-RPC issues.ETH2_duplicateAttestationsSkipped(the dedup counter — confirms the fix is filtering anything),ETH2_crossEpochAttestations(votes recorded in a later epoch than they were emitted),ETH2_missedSlotsInEpoch(correlates directly with distance spikes),ETH2_rawAttestationDistances(raw distance before correcting for missed slots — compare against the corrected histogram to separate network delay from validator delay),ETH2_totalProposedEmptyBlocks,ETH2_totalMissingBidTraces,ETH2_lastProposedEmptyBlockSlot.Not visible in dashboards (but matter):
Bigger picture, drill-down
Erigon (Caplin) support
go.modredirected to a stakefish fork ofgo-eth2-clientviareplacedirective:github.com/stakefish/go-eth2-client@feat/erigon-caplin-support(commit781f0c7f). The fork natively tolerates a few JSON shapes that Caplin emits and the upstream library rejected. Application imports are unchanged. Revertible by removing thereplacedirective once upstream merges the fix.GetValidatorIndexesnow probes forward through the slots of an epoch when Caplin returns 404 on the first slot — Caplin resolves "validators at slot N" by walking to the block at that exact slot, which doesn't exist if slot N was missed. Walking forward to the next existing block in the same epoch gives the same answer (validator set is stable within an epoch).TestGetValidatorIndexes_ProbeForwardOnMissedFirstSlotplus the live-beacon E2E testget_validator_indexes_roundtripcover this.Repo restructure and monitoring decomposition
golang-standards/project-layout. Entrypoint iscmd/eth2-monitor/main.go; library code lives underinternal/{beaconchain,monitoring,cli,opts,spec}/. The oldpkg/directory is removed.pkg/monitoring.go(553 lines, all concerns mixed) is split into per-concern files ininternal/monitoring/:monitoring.go— orchestrator loop + SSE head-stream subscription.epoch_context.go— per-epoch state fetch (validator keys, attester duties, proposer duties, committee lookup, blocks, MEV bids).attestations.go— attestation-issue detection (dedup, distance computation, delayed-over-tolerance).proposals.go— proposal-issue detection (missed, empty, vanilla, MEV mismatch).metrics.go—MonitorMetricsstruct + factory accepting aprometheus.Registerer(production uses default; tests inject isolated registries).reporting.go— Slack + log fanout helpers.mev.go— MEV relay bid-trace fetching with concurrent paginated retrieval.cache.go— disk-backed JSON cache for validator-index lookups.set.go,profiling.go,utilities.go,doc.go.Test infrastructure rewrite
tools/fixturegen/records real beacon-chain JSON by subscribing to/eth/v1/events?topics=headon the configured RPC endpoint, then tailing new heads and recording every block until a "scenario predicate" matches.internal/beaconchain/testdata/beacon/<chain>/<scenario>/(currentlyhoodi; new chains = new sub-tree, no code changes needed).happy_path,missed_proposal,empty_block,delayed_attestation,cross_epoch_attestation, plus a_shared/directory of chain-invariant startup probes.meta.jsonrecording the anchor slot/epoch + scenario-specific fields (e.g.missed_slot,max_distance,prev_canonical_slot). Tests anchor assertions to these fields instead of hard-coded slot numbers so they stay green across refresh runs. No upstream endpoint identifier is recorded in the committed fixtures.BeaconChainclient against anhttptestserver that replays captured JSON / SSE / MEV-relay responses. Synthetic unit tests are retained only for narrow classification edge cases (cross-epoch dedup, AggregationBits offset drift).e2e) and excluded from CI; run locally withmake test-e2e.CI rework
golangci-lintbumped tov2.12.2(was v1) viagolangci/golangci-lint-action@v8.testjob (go test ./...) gates the release build.coveragejob prints per-package coverage to the job log (no artifact, no third-party service).amd64+arm64;linux/darwin/freebsd/windows).1.25.10(.tool-versions,go.mod, both workflows).Reliability / correctness fixes (the long tail)
ctx.Canceled/context.DeadlineExceededdetected explicitly inSubscribeToEpochs, startup finality fetch,BuildEpochContext, and MEV requests — returned cleanly rather than panicking throughMust.EXDEV. 30-minute TTL on cached validator indices, including theVALIDATOR_INDEX_INVALIDsentinel.null-body cache file no longer panics on next save.Uint64("slot", ...).Msg(...)) for greppability.test-env/grafana/dashboards/eth2-monitor.json(+1890 lines), new panels for the new metrics and a general restyle.CLAUDE.mdexpanded by ~275 lines (architecture, fixtures, gotchas, debugging workflows). Per-package doc comments throughoutinternal/monitoring/.Full metric reference (before/after table)
New metrics:
ETH2_duplicateAttestationsSkipped(validator, slot)dedup hits — confirms the EIP-7549 fix is filtering re-aggregated inclusions.ETH2_crossEpochAttestationsETH2_rawAttestationDistancesETH2_canonicalAttestationDistancesto isolate network-vs-validator delay).ETH2_missedSlotsInEpochETH2_totalProposedEmptyBlocksETH2_totalMissingBidTracesETH2_lastProposedEmptyBlockSlotETH2_beaconAPIRequestsTotalendpoint,method,status_class.ETH2_beaconAPIRequestDurationSecondsendpoint,method.Behavior-corrected metrics (same name, more accurate values):
ETH2_totalServedAttestationsETH2_totalDelayedAttestationsOverToleranceETH2_canonicalAttestationDistancesRemoved API calls (no longer needed):
GET /eth/v1/beacon/states/{state}/committeesAttesterDutyresponses. Saves ~30s/epoch on networks with large validator sets.GET /eth/v1/beacon/headers/{block_id}GetBlockHeaderwas dead code.Dedup deep-dive (EIP-7549)
This is the headline fix. Worth understanding even if you skip the rest.
How EIP-7549 changed attestations
Before Pectra (May 2025), each attestation was locked to one committee:
EIP-7549 moved the committee selection outside the signature for ~64× faster BLS verification:
The problem
Since committee selection is no longer signed, the same vote can be re-packaged and included in multiple blocks:
sequenceDiagram participant V as Validator 42 participant S100 as Slot 100 participant B101 as Block 101 participant B105 as Block 105 participant B108 as Block 108 V->>S100: Attests (committee 5) Note over B101: Attestation A<br/>committees: [5] S100-->>B101: val 42 included (d=0 ✅) Note over B105: Attestation B (re-aggregated)<br/>committees: [2, 5, 7] S100-->>B105: val 42 included again (d=4 ⚠️) Note over B108: Attestation C (re-aggregated)<br/>committees: [5] S100-->>B108: val 42 included again (d=7 🚨)The Ethereum protocol allows this — re-including an attestation is a no-op for rewards (
has_flag()is idempotent in the consensus spec). But this service counted every inclusion as if it were a separate event:flowchart LR subgraph OLD ["Old code (no dedup)"] direction TB O1["Block 101: d=0"] --> OC["Counted: 3 inclusions"] O2["Block 105: d=4"] --> OC O3["Block 108: d=7"] --> OC OC --> OA["served=3, max d=7<br/>🚨 ALERT fires"] end subgraph NEW ["Fixed code (with dedup)"] direction TB N1["Block 101: d=0 ✅ first seen"] --> NC["Counted: 1 inclusion"] N2["Block 105: SKIP ↩️ already seen"] -.-> NC N3["Block 108: SKIP ↩️ already seen"] -.-> NC NC --> NA["served=1, d=0<br/>✅ No alert"] endThe fix
Track which
(validator, slot)pairs have been seen. Process blocks in slot order so the earliest (canonical) inclusion wins:flowchart TD START["For each block (sorted by slot)"] --> ATT["For each attestation"] ATT --> VAL["For each validator in attestation"] VAL --> CHECK{"(validator, slot)<br/>already seen?"} CHECK -->|Yes| SKIP["Skip duplicate<br/>DuplicateAttestationsSkipped++"] CHECK -->|No| RECORD["Record distance<br/>Mark as seen"] RECORD --> DIST{"distance > 2?"} DIST -->|Yes| ALERT["DelayedOverTolerance++"] DIST -->|No| OK["CanonicalAttestations++"] SKIP --> VAL ALERT --> VAL OK --> VAL