Skip to content

fix(electra): recompute deposit counters when the state object was replaced - #289

Open
ander-deran-arteaga wants to merge 3 commits into
devfrom
fix/deposit-counters-lost-on-state-replace
Open

fix(electra): recompute deposit counters when the state object was replaced#289
ander-deran-arteaga wants to merge 3 commits into
devfrom
fix/deposit-counters-lost-on-state-replace

Conversation

@ander-deran-arteaga

Copy link
Copy Markdown
Collaborator

f_deposits_num is written as exactly 0 for some epochs while t_deposits holds the corresponding rows. Never slightly wrong, always exactly zero.

The counters are accumulated on one state and read from another, an epoch later:

processPendingDeposits()   state := p.baseMetrics.NextState
                           state.DepositsNum += 1

ExportToEpoch()            DepositsNum: int(s.CurrentState.DepositsNum)

The value written into the epoch row for E-1 was accumulated while that object was NextState, during the processing of E-1. It survives only if the same object is still cached when E is processed. Three things replace or reset it in between:

  • AdvanceFinalized deletes and re-downloads a state whose root changed
  • ensureDependencyStates re-downloads states evicted by CleanUpTo
  • RefreshBlocks resets the counters outright, and post-Electra AddBlocks does not refill them, since deposits arrive through the pending queue rather than block bodies

A replaced object carries zeros and nothing recomputes them, so the epoch row reports no deposits while the detail rows exist.

Measured on mainnet: 252 epochs over 450421-470670, 1,653 deposits and roughly 113,000 ETH missing from the summaries, against 10,029 epochs written correctly. 97.5% right, and wrong in exactly the way a lost accumulator is wrong.

The same reading explains why the consolidation counters fail on a different set of epochs, overlapping the deposit failures in only 18 of 37 cases: RefreshBlocks resets the deposit counters and not the consolidation ones, so the two have different wipe conditions.

Fix: record on the state whether its deposit counters were ever accumulated, and recompute from the state itself when they were not. What processPendingDepositsFor derives depends only on the state handed to it, so a re-downloaded state reaches the numbers the original run reached. It must run at most once per object, since the counters accumulate with += rather than being assigned, and the new flag enforces that.

The recomputation runs before the existing map clears, so its contribution to DepositedAmounts is discarded exactly as the surrounding code expects and the reward path repopulates it afterwards; only the two counters survive. Deposit rows are persisted from NextState.DepositsProcessed, so recomputing for CurrentState cannot duplicate them. The common case costs a boolean check.

Seven tests. One found a hole in the first version of this fix: RefreshBlocks zeroed the counters without clearing the flag, so a refreshed state still claimed it had been accumulated and the recomputation never fired.

Closes #287

…placed

f_deposits_num is written as exactly 0 for some epochs while t_deposits holds
the corresponding rows. Never slightly wrong, always exactly zero.

The counters are accumulated on one state and read from another, an epoch
later:

    processPendingDeposits()   state := p.baseMetrics.NextState
                               state.DepositsNum += 1

    ExportToEpoch()            DepositsNum: int(s.CurrentState.DepositsNum)

The value written into the epoch row for E-1 was accumulated while that object
was NextState, during the processing of E-1. It survives only if the same object
is still in the cache when E is processed. Three things replace or reset it in
between:

  - AdvanceFinalized deletes and re-downloads a state whose root changed
  - ensureDependencyStates re-downloads states evicted by CleanUpTo
  - RefreshBlocks resets the counters outright, and post-Electra AddBlocks does
    not refill them, since deposits arrive through the pending queue rather than
    block bodies

A replaced object carries zeros and nothing recomputes them, so the epoch row
reports no deposits while the detail rows exist. Measured on mainnet: 252 epochs
over 450421-470670, 1,653 deposits and roughly 113,000 ETH absent from the
summaries, against 10,029 epochs written correctly. 97.5% right, and wrong in
exactly the way a lost accumulator is wrong.

The same reading explains why the consolidation counters fail on a different set
of epochs, overlapping the deposit failures in only 18 of 37 cases: RefreshBlocks
resets the deposit counters and not the consolidation ones, so the two have
different wipe conditions.

Fix: record on the state whether its deposit counters were ever accumulated, and
recompute them from the state itself when they were not. What
processPendingDepositsFor derives depends only on the state handed to it, so a
state that came back from the beacon node reaches the same numbers the original
run reached. It must run at most once per object, since the counters accumulate
with += rather than being assigned, and the new flag is what enforces that.

The recomputation runs before the existing map clears, so its contribution to
DepositedAmounts is discarded exactly as the surrounding code expects and the
reward path repopulates that map afterwards; only the two counters survive.
Deposit rows are persisted from NextState.DepositsProcessed, so recomputing for
CurrentState cannot duplicate them. The common case costs a boolean check.

Seven tests. One found a hole in the first version of this fix: RefreshBlocks
zeroed the counters without clearing the flag, so a refreshed state still
claimed it had been accumulated and the recomputation never fired. Others cover
the claim the fix rests on, that a re-downloaded state recomputes to the numbers
the original reached, keep "no deposits this epoch" distinguishable from "never
counted", and pin the doubling that makes the flag necessary.

Pre-existing failures in pkg/analyzer are unchanged, 13 before and 13 after;
they need a live beacon endpoint.

Closes #287

@leobago leobago left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ander-deran-arteaga here's my review of this PR.

The diagnosis is correct and well-evidenced, and the shape of the fix is right. processPendingDepositsFor is a clean extraction: what it derives depends only on the state passed in, so recomputing from a re-downloaded state genuinely reproduces the original numbers. Resetting the flag inside RefreshBlocks (caught by their own test) is the right call. Common-path cost is one boolean check.

But the fix is incomplete in two ways and leaves one adjacent bug untouched.

  1. The NextState path still isn't guarded, so reprocessing can double the counters

processPendingDepositsFor sets PendingDepositsProcessed = true at the end, but only the new CurrentState callsite checks it. The original path, processPendingDeposits() -> processPendingDepositsFor(NextState), still runs unconditionally on every ProcessStateTransitionMetrics call.

Failure case: epoch X is reprocessed while state X is retained in cache (not re-downloaded, not refreshed). This happens on a dependency-triggered reprocess in AdvanceFinalized (epochsWithChangedBlocks[X-1]), on a PR #288 carried reprocess, and on a RefreshStateBlocks transient failure where the flag never got reset. processPendingDeposits() runs state.DepositsNum += ... a second time and the counters double. The epoch row for X, written when X+1 is later processed off that same state object, then reports double.

Part of this predates the PR, but the PR introduces a new variant: the recompute-as-CurrentState now sets the flag and counters during processing of X+1, and a subsequent reprocess-as-epoch-X doubles them. Since processPendingDepositsFor is right there being touched, the clean fix is to make it idempotent: early-return when state.PendingDepositsProcessed is already set, and drop the caller-side condition. TestRunningTwiceOnOneStateDoublesTheCounters currently pins the fragile behavior as intended and should be inverted to assert idempotence.

Addresses the review on #289.

The previous version guarded one call site: PreProcessBundle checked
PendingDepositsProcessed before recomputing from CurrentState. The other
path, processPendingDeposits -> processPendingDepositsFor(NextState),
still ran unconditionally on every ProcessStateTransitionMetrics call, so
an epoch reprocessed against a state still held in cache accumulated its
counters a second time and doubled them. A dependency-triggered pass in
AdvanceFinalized, a carried reprocess, or a RefreshStateBlocks failure
that left the flag unset all reach that path.

The guard now lives in processPendingDepositsFor, where every path meets,
and the caller-side condition is gone. Guarding at a call site only ever
protects that call site.

TestRunningTwiceOnOneStateDoublesTheCounters asserted the doubling as
intended behaviour. It pinned the bug. Replaced with tests that assert
idempotence for the scalar counters, for the per-validator amounts, and
for the NextState path specifically.

Found while writing those: RefreshBlocks zeroes DepositsNum and
TotalDepositsAmount but left DepositedAmounts populated, so a refreshed
state recomputed to correct scalars and doubled per-validator amounts.
Its own doc says it "resets the accumulators first so that calling it on
a state whose blocks were already set does not double-count values", and
DepositedAmounts is an accumulator it missed. Reset it there, with a test
that fails without the line.

Five mutations, all caught.
Copilot AI lite review requested due to automatic review settings September 1, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an Electra-era metrics correctness bug where epoch-level deposit (and related) counters could be written as exactly zero when the state object that previously accumulated them was replaced (e.g., re-download after reorg/eviction) or reset (via RefreshBlocks). The fix makes the deposit counters recomputable from the state itself, ensuring epoch summaries stay consistent with the persisted detail rows.

Changes:

  • Add a per-state flag (PendingDepositsProcessed) to track whether pending-deposit counters have been accumulated for that specific state object, and reset it when RefreshBlocks clears counters.
  • Refactor pending-deposit processing into an idempotent processPendingDepositsFor(state) and invoke it for both NextState (normal accumulation) and CurrentState (recompute on replaced objects before exporting epoch metrics).
  • Add focused unit tests covering recomputation, idempotence, and refresh/reset interactions (including the RefreshBlocks-flag reset regression).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
pkg/spec/state.go Adds PendingDepositsProcessed flag and ensures RefreshBlocks resets the flag and DepositedAmounts alongside deposit counters.
pkg/spec/metrics/state_electra.go Introduces processPendingDepositsFor (idempotent), uses it for NextState and recomputation on CurrentState prior to epoch export.
pkg/spec/metrics/deposit_recompute_test.go Adds unit tests validating recomputation on replaced states, idempotence, and refresh behavior.
pkg/spec/deposit_counters_test.go Adds spec-level tests ensuring fresh states are unmarked and RefreshBlocks clears the accumulation flag.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ander-deran-arteaga

Copy link
Copy Markdown
Collaborator Author

Thanks @leobago.

The guard now lives inside processPendingDepositsFor as an early return when PendingDepositsProcessed is already set. The caller-side condition in PreProcessBundle has been removed, so both the normal path and the NextState path now go through the same check.

I also replaced TestRunningTwiceOnOneStateDoublesTheCounters, since it was asserting the buggy behavior. The new tests cover:

While adding those tests, I found another issue in RefreshBlocks: it reset DepositsNum and TotalDepositsAmount, but left DepositedAmounts populated. Since that map is accumulated with +=, refreshing an already-processed state produced correct scalar counters but doubled per-validator amounts (32 → 64 Gwei in the fixture).

RefreshBlocks now resets DepositedAmounts as well, with a regression test covering that case.

I checked the blast radius before making the change: PreProcessBundle already clears DepositedAmounts and rebuilds it through processDepositsForRewardCalculation, so the reward path is unaffected. RefreshBlocks currently has a single caller in chain_cache.go:104.

@ander-deran-arteaga

Copy link
Copy Markdown
Collaborator Author

One thing I could not action, @leobago: the review says the fix is "incomplete in two ways and leaves one adjacent bug untouched", but the text ends after point 1. There are no inline comments on the PR and no follow-up comment carrying points 2 or the adjacent bug, so I have only the NextState guard to work from — which is what 8d40fd2 addresses.

Could you paste the rest when you get a chance?

The RefreshBlocks / DepositedAmounts bug described above may be the adjacent one you had in mind, since it sits directly beside this code and has the same shape, but I would rather not assume and leave a real finding unfixed.

@leobago

leobago commented Sep 3, 2026

Copy link
Copy Markdown
Member

Thank you @ander-deran-arteaga. The deposit fix is complete, correct and well tested, so it can merge.

The adjacent bug of the exact same shape, is in the consolidation counters, and is not touched by this PR and needs the same pattern here or as a fast follow.

The deposit fix

Previously, the diagnosis was right but the fix was incomplete, because processPendingDepositsFor set PendingDepositsProcessed at the end while only the new CurrentState call site checked it. The original NextState path still ran unconditionally, so an epoch reprocessed against a cached state would double the counters.

Fixed exactly as recommended. processPendingDepositsFor now returns early when the flag is set, the caller side condition in PreProcessBundle was removed so both paths share the guard, and the test that pinned the doubling was inverted to assert idempotence, with a new test calling processPendingDeposits twice. So this is good.

Also nice that you caught the related bug in RefreshBlocks, which reset DepositsNum and TotalDepositsAmount but left DepositedAmounts populated, doubling per validator amounts on refresh. It now clears that map and the flag too, with a regression test. The reward path rebuilds the map in PreProcessBundle so nothing downstream breaks. All ten new tests pass which is good.

The adjacent bug, consolidation counters

Sorry I left that part out in the previous review. The epoch row reads ConsolidationsProcessedNum and ConsolidationsProcessedAmount from CurrentState in standard.go. Both come from processPendingConsolidations, which is only ever called with NextState, appends and does += with no flag and no CurrentState recompute. So it has both failure modes deposits had: it doubles when an epoch is reprocessed against a retained NextState, and it reads zero when the CurrentState object was re downloaded in between while t_consolidations still holds the rows. RefreshBlocks also does not reset either field.

The fix is the pattern this PR already established. Add a PendingConsolidationsProcessed flag, give processPendingConsolidations an early return that sets it, add a processPendingConsolidationsFor(CurrentState) call next to the deposit recompute in PreProcessBundle, and reset the flag and both counters in RefreshBlocks. Let's add this and we are good to go.

ConsolidationsProcessed and ConsolidationsProcessedAmount have the same
shape as the deposit counters fixed in the previous commit: appended to
and accumulated with += while the state is NextState, read from
CurrentState an epoch later by standard.go. They had both of the same
failure modes.

They double when an epoch is reprocessed against a retained NextState,
because processPendingConsolidations ran unconditionally, and they read
zero when the CurrentState object was re-downloaded in between, while
t_consolidations still holds the rows. RefreshBlocks reset neither.

Adds PendingConsolidationsProcessed, an early return in
processPendingConsolidationsFor, a CurrentState recompute in
PreProcessBundle next to the deposit one, and the resets in
RefreshBlocks.

The recompute touches CurrentState only, which feeds the epoch row;
t_consolidations is persisted from NextState, so no row is written twice.

Eight tests, each verified to fail without the part of the fix it
covers, including one at the PreProcessBundle level that pins the call
site rather than the function.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants