fix builder withdrawal type detection#697
Merged
Merged
Conversation
barnabasbusa
approved these changes
May 18, 2026
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.
Fix builder withdrawal type detection at epoch boundaries
Summary
Fixes two bugs in the state simulator (
indexer/beacon/state_sim.go) that causedwithdrawals to be classified incorrectly on Gloas chains:
in descending order instead of slot-ascending order.
sweep (
BuilderFullWithdrawal) instead of a builder payment, mainlyaffecting the first block of each epoch.
Both bugs are visible at e.g. https://dora.glamsterdam-devnet-3.ethpandaops.io/slot/65184#withdrawals
(slot 0 of an epoch): indices 9629–9632 are delayed payments with reversed
ref slots, and index 9633 is the parent block's direct payment mis-tagged as
a builder sweep.
Root cause
Bug 1 — reversed delayed-payment ref slots
resolveDelayedPaymentRefSlotsandresolveInitialDirectRefsboth have a DBfallback path (used once the source epoch falls out of the in-memory cache).
That path called
db.GetSlotsRange, which returns rows orderedslot DESC,and iterated the result forward. The matching loop then paired the first
delayed entry (appended in slot-ascending order by
process_builder_pending_payments)with the highest-slot missed block, producing reverse ordering.
The cache path was already iterating ascending, so the bug only showed up on
finalized/pruned epochs.
Bug 2 — parent's direct payment mis-classified
Per the Gloas spec, the queue at the time of
process_withdrawalsis:The trailing
parent_directis appended byprocess_parent_execution_payload(spec: gloas/beacon-chain.md#new-process_parent_execution_payload), which runs
immediately before
process_withdrawals. In the simulator that step was modelledimplicitly by
applyBlock(parent)appending the parent's UID after draining —fine for non-first-of-epoch blocks.
But
getParentBlocksfilters parents to the current epoch only. For:slots at an epoch boundary, multi-slot gaps),
parentBlocksis empty,applyBlocknever runs for the parent, and theparent's direct payment is missing from the simulator's queue. The
classifier's
BuilderPaymentCountis therefore off by 1, and the extrabuilder withdrawal in the block's payload falls through
classifyWithdrawalType'sisBuilder → WithdrawalTypeBuilderFullWithdrawalbranch.
Fix
DB iteration order — iterate
dbSlotsin reverse in bothresolveInitialDirectRefsandresolveDelayedPaymentRefSlots, so slotsare walked ascending regardless of the SQL order.
Per-entry type tracking — add a
Typefield ontrackedBuilderWithdrawal(WithdrawalTypeBuilderPayment/WithdrawalTypeBuilderDelayedPayment). Initial entries are tagged inresetStatebased onDelayedBuilderPaymentCount; entries appended duringreplay are tagged direct. This is necessary because the post-fix queue can
have three sections —
[direct, delayed, direct]— which position-basedclassification cannot express.
Cross-epoch parent's direct payment — in
replayWithdrawalState, whenlen(parentBlocks) == 0, look up the immediate parent (cache → DB) via thenew helper
resolveParentDirectEntryand append its direct payment to thequeue if it had a delivered, canonical, builder-produced payload. This
mirrors
process_parent_execution_payloadfor the spec cases thatapplyBlockdoesn't cover.Classifier rewrite —
classifyBuilderPaymentsnow reads the per-entryTypefield instead of inferring direct/delayed from position. Thedelayed-ref-slot resolution iterates the queue once to collect delayed
entries in queue order, matching against missed-payload blocks in
slot-ascending order.
builderDelayedCountremoved — no longer needed now that each entrycarries its own type.
applyBlock's drain simplifies to truncating thequeue (no delayed-count accounting).
Compatibility with other withdrawal types
The fix preserves the section order required by
get_expected_withdrawals(spec: gloas/beacon-chain.md#modified-get_expected_withdrawals):get_builder_withdrawalsidx < BuilderPaymentCount→ from simget_pending_partial_withdrawalsidx < BuilderPaymentCount + PartialCountget_builders_sweep_withdrawalsidx ≥ …+isBuilder→BuilderFullWithdrawalget_validators_sweep_withdrawalsThe new queue entry I append lives strictly inside section 1 (it's in the
pending-withdrawals queue, where the spec puts it). Builder sweep withdrawals
in section 3 are produced from a different source (
state.builderssweepcursor) and are still discriminated purely by index position relative to
BuilderPaymentCount + PartialCount. No regression for blocks that have botha parent direct payment and a builder sweep in the same slot.