rework builder handling to render builders with re-used indexes properly#783
Open
pk910 wants to merge 9 commits into
Open
rework builder handling to render builders with re-used indexes properly#783pk910 wants to merge 9 commits into
pk910 wants to merge 9 commits into
Conversation
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 index reuse across the Builders overview & detail pages
Summary
Builder indexes are reusable slots: under EIP-8282 a builder index is handed to a
new builder once its previous occupant is fully withdrawn (
get_index_for_new_builder).A builder's real identity is its pubkey; the index only points at whoever owns it
now. The read paths didn't respect this and mixed data from every builder that ever
held an index.
This PR reworks the Builders overview and the builder detail page so that:
/builder/{index}), and everyolder builder that used to hold the index stays reachable by pubkey
(
/builder/0x{pubkey}) with its own, correctly-separated data.specific builder it's shown for, not to whoever else reused the index.
No database migration is required — a builder's ownership window is derived from the
existing
deposit_epochacross the rows that share an index.Background — the bug
Reproduced on
glamsterdam-devnet-6, where builder index2was reused by a new builder.Builders overview showed the old builder as index
2and dropped the new oneentirely.
GetFilteredBuilderSetwas built on a "one builder per index" invariant:GetBuilderIndexesByFilterreturned indexes non-distinct ([2, 2]for a reused index),then
StreamBuildersByIndexescollapsed both rows into the sameindexMapslot(last-scanned wins, the other silently dropped to
nil), and the "cache is newer" skipcould drop the survivor too — so which builder appeared was essentially arbitrary.
Builder detail page was a mix of two identities:
reloaded the active occupant (
GetActiveBuilderByIndex, page cache keybuilder:{index}). So the old and new pubkey URLs rendered the same page.builder's pubkey.
slots.builder_index), recent bids (block_bids.builder_index) andwithdrawals (
index | BuilderIndexFlag) were queried by the bare index with no timewindow — pulling in every builder that ever held that index.
That mismatch is exactly the "old status timeline + new blocks/bids" that was reported.
Approach
Treat the pubkey as the identity and the index as a time-windowed attribute.
A builder's tenure on an index is
[DepositEpoch, successorDepositEpoch), where thesuccessor is the next builder (by deposit epoch) that took over the same index. The
current occupant's tenure is open-ended — it still owns the index up to now, so only a
superseded builder ever gets an upper (
maxSlot) bound; a non-superseded builder mustnever be clipped at the top or its own recent blocks/bids/withdrawals disappear. Converting
that to a slot window scopes all index-keyed queries to the lifetime of the specific builder
being viewed. Deposits/exits are already keyed by pubkey and need no window.
Because an index is only reused after its previous occupant is fully withdrawn, the
window
[deposit, successorDeposit)cleanly captures a builder's whole active life andexcludes both its predecessors and its successors.
Product decision — superseded builders in the overview
A long-running network can accumulate many old builders per reused index. The overview
therefore shows current occupants by default, and surfaces superseded builders via
the existing "Superseded" status filter (rendered without an index and linked by
pubkey). This keeps the default list index-aligned and readable.
Changes
Indexer — the real root cause of index reuse
indexer/beacon/buildercache.go— the builder pubkey→index cache was never updated when anindex was reused:
updateBuilderSetonly registered the mapping when a cache slot was firstcreated (
cachedBuilder == nil). So a new builder taking over an existing index was never added,and every pubkey→index consumer missed it: the new builder's deposits/exits were persisted with
builder_index = NULL, its balance was never credited to the index, and/builder/0x{pubkey}lookups fell through. Now the mapping is (re-)registered whenever the builder at an index changes,
so reuse is tracked live (previously only a process restart, via
prepopulateFromDB, fixed it).Reads must merge cache + DB (never DB-only for cached data)
Recent/unfinalized data lives in the indexer's in-memory caches and is only later flushed to the DB.
Every per-builder read now goes through a chainservice accessor that merges both, and any filter
field is applied in both the cache path and the DB query:
blockBidCache.GetBidsByBuilderIndex(cache) +Indexer.GetCachedBidsByBuilderIndexChainService.GetBuilderBids, which merges the not-yet-flushed cached bids with the DB (dedupedby parent/block hash, newest first). The detail page previously called
db.GetBidsByBuilderIndexdirectly and so missed the most recent ~10–15 slots of bids entirely.
MinSlot/MaxSlot) was only applied in the DB query(
GetWithdrawalsFiltered); the chainservice cache path (GetWithdrawalsByFilter) ignored it. Itnow applies the same window to the cached blocks it scans.
GetDbBlocksByFilter, whose cache path honors bothBuilderIndexand the slot window (verified).Deposits & exits lists — attribute by pubkey, not the persisted index
handlers/builder_deposits.go,handlers/builder_exits.go— a builder deposit/exit is nowattributed to its builder via its pubkey (its stable identity) using the new
ChainService.GetBuildersByPubkeys(cache + DB), instead of the persistedbuilder_indexsnapshoton the request row. That snapshot is unreliable — it can be
NULL(never resolved when the indexwas reused) or point at an index now owned by someone else — which is why the current builder's
deposit rendered as
—while the superseded predecessor correctly rendered as(inactive).DB layer (no schema migration)
dbtypes/other.go— addMinSlot/MaxSlot(*uint64) toWithdrawalFilter.db/withdrawals.go— apply the slot window viablock_uid >> 16.db/block_bids.go—GetBidsByBuilderIndexgains an optional[minSlot, maxSlot]window (applied to the query and the count) so bids can be scoped to a builder's tenure.
db/builders.go— newGetBuildersByFilter, returning full builder rows(preserving pubkey identity, so a reused index can yield the current occupant plus its
superseded predecessors). Removes the now-unused
GetBuilderIndexesByFilter, whoseindex-only, dedup-by-index behaviour was the source of the overview collision.
Service layer
services/chainservice_builder.goGetFilteredBuilderSetto merge cache current-occupants withGetBuildersByFilteron pubkey identity (no index collision). An empty statusfilter defaults to the current occupants (
Active+Exited); superseded rows areonly returned when explicitly requested. Balances are never attributed to a superseded
builder (its index now belongs to someone else). Sorting/pagination happen in memory
over the merged identity set (
builderSortFn).GetBuilderTenureEndEpoch(ctx, index, depositEpoch)returning the successor'sdeposit epoch (or
nilfor the current occupant).Detail handler
handlers/builder.goinstead of reloading the current occupant by index.
*gloas.Builderis threaded through, and the page cache key is now bypubkey (
builder:0x{pubkey}:{tab}) so old and new builders don't collide.buildBuilderRecentBlocks,buildBuilderRecentBidsand the withdrawals filter to it.Models & templates
types/models/builders.go—BuilderPageData.RouteKey(the index for the currentoccupant, or
0x{pubkey}for a superseded builder).templates/builder/builder.html— superseded builders show a "superseded" badge andan "index reused → now belongs to builder N" note; the tab-navigation
history.replaceStateuses.RouteKeyinstead of.Index.templates/builders/builders.html— superseded rows render—in the index column(no index link); the pubkey column already routes by pubkey.
Testing
go build ./...,go vet,gofmt— clean.go test ./db/... ./services/...— pass.glamsterdam-devnet-6the checks are:/builder/2shows only the current builder's blocks/bids/timeline; the old builder isreachable by its pubkey URL with its own data; and the overview reveals the old builder
(index-less) under the "Superseded" filter.