Skip to content

rework builder handling to render builders with re-used indexes properly#783

Open
pk910 wants to merge 9 commits into
masterfrom
pk910/builder-fixes
Open

rework builder handling to render builders with re-used indexes properly#783
pk910 wants to merge 9 commits into
masterfrom
pk910/builder-fixes

Conversation

@pk910

@pk910 pk910 commented Jul 1, 2026

Copy link
Copy Markdown
Member

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:

  • The index only ever addresses the current occupant (/builder/{index}), and every
    older builder that used to hold the index stays reachable by pubkey
    (/builder/0x{pubkey}) with its own, correctly-separated data.
  • Per-builder data (blocks, bids, deposits, withdrawals, status timeline) belongs to the
    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_epoch across the rows that share an index.

Background — the bug

Reproduced on glamsterdam-devnet-6, where builder index 2 was reused by a new builder.

Builders overview showed the old builder as index 2 and dropped the new one
entirely. GetFilteredBuilderSet was built on a "one builder per index" invariant:
GetBuilderIndexesByFilter returned indexes non-distinct ([2, 2] for a reused index),
then StreamBuildersByIndexes collapsed both rows into the same indexMap slot
(last-scanned wins, the other silently dropped to nil), and the "cache is newer" skip
could drop the survivor too — so which builder appeared was essentially arbitrary.

Builder detail page was a mix of two identities:

  • After parsing the route it discarded the pubkey, collapsed to the numeric index, and
    reloaded the active occupant (GetActiveBuilderByIndex, page cache key
    builder:{index}). So the old and new pubkey URLs rendered the same page.
  • Status timeline / deposits / withdrawal-exit-reason were keyed by the active
    builder's pubkey.
  • Recent blocks (slots.builder_index), recent bids (block_bids.builder_index) and
    withdrawals (index | BuilderIndexFlag) were queried by the bare index with no time
    window
    — 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 the
successor 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 must
never 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 and
excludes 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 an
    index was reused
    : updateBuilderSet only registered the mapping when a cache slot was first
    created (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:

  • Bids — added blockBidCache.GetBidsByBuilderIndex (cache) + Indexer.GetCachedBidsByBuilderIndex
    • ChainService.GetBuilderBids, which merges the not-yet-flushed cached bids with the DB (deduped
      by parent/block hash, newest first). The detail page previously called db.GetBidsByBuilderIndex
      directly and so missed the most recent ~10–15 slots of bids entirely.
  • Withdrawals — the slot window (MinSlot/MaxSlot) was only applied in the DB query
    (GetWithdrawalsFiltered); the chainservice cache path (GetWithdrawalsByFilter) ignored it. It
    now applies the same window to the cached blocks it scans.
  • Blocks — already routed through GetDbBlocksByFilter, whose cache path honors both
    BuilderIndex and 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 now
    attributed to its builder via its pubkey (its stable identity) using the new
    ChainService.GetBuildersByPubkeys (cache + DB), instead of the persisted builder_index snapshot
    on the request row. That snapshot is unreliable — it can be NULL (never resolved when the index
    was 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 — add MinSlot/MaxSlot (*uint64) to WithdrawalFilter.
  • db/withdrawals.go — apply the slot window via block_uid >> 16.
  • db/block_bids.goGetBidsByBuilderIndex gains 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 — new GetBuildersByFilter, 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, whose
    index-only, dedup-by-index behaviour was the source of the overview collision.

Service layer

  • services/chainservice_builder.go
    • Rewrote GetFilteredBuilderSet to merge cache current-occupants with
      GetBuildersByFilter on pubkey identity (no index collision). An empty status
      filter defaults to the current occupants (Active + Exited); superseded rows are
      only 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).
    • New GetBuilderTenureEndEpoch(ctx, index, depositEpoch) returning the successor's
      deposit epoch (or nil for the current occupant).

Detail handler

  • handlers/builder.go
    • Pubkey routes now load the exact builder for that pubkey (even if superseded)
      instead of reloading the current occupant by index.
    • The resolved *gloas.Builder is threaded through, and the page cache key is now by
      pubkey
      (builder:0x{pubkey}:{tab}) so old and new builders don't collide.
    • The live balance override is only applied when the builder still owns its index.
    • Computes the tenure slot window and scopes buildBuilderRecentBlocks,
      buildBuilderRecentBids and the withdrawals filter to it.

Models & templates

  • types/models/builders.goBuilderPageData.RouteKey (the index for the current
    occupant, or 0x{pubkey} for a superseded builder).
  • templates/builder/builder.html — superseded builders show a "superseded" badge and
    an "index reused → now belongs to builder N" note; the tab-navigation
    history.replaceState uses .RouteKey instead 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.
  • Both edited template sets validated as parseable.
  • Not yet verified against a live devnet. On glamsterdam-devnet-6 the checks are:
    /builder/2 shows only the current builder's blocks/bids/timeline; the old builder is
    reachable by its pubkey URL with its own data; and the overview reveals the old builder
    (index-less) under the "Superseded" filter.

@pk910 pk910 added the build-docker-image Automatically build docker image for PR branch label Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build-docker-image Automatically build docker image for PR branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant