Skip to content

build multiple candidate payloads per slot for reorg and payload-miss testing - #152

Open
pk910 wants to merge 12 commits into
mainfrom
pk910/reorg-handling
Open

build multiple candidate payloads per slot for reorg and payload-miss testing#152
pk910 wants to merge 12 commits into
mainfrom
pk910/reorg-handling

Conversation

@pk910

@pk910 pk910 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

A Gloas builder that only ever builds on "the head block and its revealed payload" has nothing to bid when the interesting cases happen: the parent payload is withheld, the head is contested, or we deliberately want to reorg the head block. This PR makes buildoor build several candidate payloads per slot — one per parent tuple — and lets every consumer (p2p bids, Builder API, reveals) pick the right one at request time.

The four candidate keys:

Candidate Beacon parent Execution parent Scenario
parent_full head block head payload normal build
parent_empty head block head's execution parent parent payload missed / withheld
grandparent_full head's parent grandparent payload deliberate reorg of the head block
grandparent_empty head's parent grandparent's execution parent reorg + payload miss

What's in here

Chain view / head tracker (pkg/chain/headtracker.go): buildoor's own canonical-chain oracle — current head, bounded block-by-root ancestry cache, per-block Gloas payload reveal status (payload-available events + child-block evidence + PAYLOAD_DUE fallback), reorg detection with depth/common-ancestor (the beacon chain_reorg SSE topic subscribed as a cross-check), and build-parent candidate resolution. Also refetches epoch stats when a head event's duty dependent root proves a reorg crossed the epoch boundary.

Candidate builds (pkg/payload_builder/): per-candidate policy auto / always / never via build.candidate_* settings — auto builds speculative candidates on chain signals (parent payload not revealed, head-vote participation below build.auto_weak_head_pct). Payload-attributes are cached per (slot, parent_root, parent_hash) variant, sanitized against the chain view (Grandine's wrong miss-case parent is corrected, missing parent numbers backfilled) and synthesized for candidates no client emits attributes for (grandparent candidates re-target the parent slot's attributes; refused across epoch boundaries). Sequential builds run speculative candidates first and canonical last; build.parallel opts into concurrent engine builds, build.speculative_build_time_ms gives speculative builds their own EL build time.

Request-time selection: p2p bids commit to a candidate per epbs.bid_candidate (auto = match the live chain view, sticky per slot unless epbs.bid_candidate_switch; all = deliberate multi-parent gossip). The Builder API serves whichever candidate matches the requested parent (builder_api.serve_candidates policy) and can build a missing legal parent on the fly (builder_api.on_demand_build). Interval bids are throttled per payload so multi-candidate bidding doesn't starve any one candidate.

Reorg-aware wins and reveals: reveals re-bind (rebuilt, re-signed envelope) when a won payload is re-included under a different block root (reveal.rebind_on_reorg); orphaned wins dispute pending payments and clear won markers until the block returns — payment tracking follows the inclusion verdict instead of the first sighting.

Gas-limit enforcement: flag-gated build.enforce_bid_gas_limit adjusts built payloads to the exact bid-gossip-legal gas limit (EL parent gas limit stepped toward the proposer target) for ELs that ignore the target gas limit attribute.

Action plan + WebUI: per-slot build.candidates overrides in the action plan (resolved into the frozen plan), a candidate policy section in the builder config panel, candidate payloads as tabs in the payload-created callout, and the committed candidate + parent tuple shown in slot callouts. Slot results and artifacts store one payload per candidate.

Config surface

New build.* section (candidate_parent_full/empty, candidate_grandparent_full/empty, parallel, speculative_build_time_ms, auto_weak_head_pct, enforce_bid_gas_limit), plus epbs.bid_candidate, epbs.bid_candidate_switch, builder_api.serve_candidates, builder_api.on_demand_build and reveal.rebind_on_reorg — all registered as mutable settings and exposed as CLI flags. Defaults keep today's behaviour (canonical candidate only).

Testing

  • go build ./... / go test ./... pass; new unit tests for the head tracker, attributes variants/synthesis, candidate resolution, scheduler throttling and the action plan candidate overrides
  • Exercised on glamsterdam-devnet-7 against the devnet-7 client matrix (incl. the Grandine miss-case attribute bug this sanitizes around)

pk910 added 10 commits July 30, 2026 19:20
…tatus and build-parent candidates

- pkg/chain/headtracker.go: shared block-by-root ancestry cache, reorg
  detection with head-change events, Gloas payload reveal status
  (payload-available events + child-block evidence + reveal-deadline
  fallback), and build-parent candidate resolution (parent/grandparent x
  full/empty tuples incl. EL parent gas limit / block number)
- subscribe the beacon chain_reorg SSE topic as a cross-check signal
- BlockInfo carries the committed gas limit (bid or embedded payload) and
  the pre-Gloas execution block number
- parse PAYLOAD_DUE_BPS from the chain spec (default 5000)
- refetch cached epoch state when a head event's duty dependent root
  proves a reorg crossed the epoch boundary; fix unlocked currentEpoch
  read in RefreshBuilders
- inclusion tracker resolves ancestry blocks through the shared cache
…ent changes

- the attributes cache keys events by (parent_block_root, parent_block_hash):
  reorgs and Gloas full/empty parent flips re-emit attributes for the same
  proposal slot with a different parent, and every variant is retained
  (last-writer-wins per variant, newest overall as the slot's latest)
- a later attributes event with a different parent aborts the in-flight
  build for the slot and rebuilds on the new parent instead of being
  silently ignored
- sanitize attributes against the chain view before building: redirect
  references to a known-withheld execution payload to the last actually
  built block, and backfill missing parent block numbers (several clients
  omit them under Gloas)
- missing-attributes fallback resolves the target slot's proposer from
  cached duties instead of copying the source slot's, and warns when a
  synthesized event crosses an epoch boundary (stale prev_randao)
A slot can now produce several payloads, one per build-parent candidate:
parent_full (normal), parent_empty (Gloas payload miss: head block on its
execution parent), grandparent_full (deliberate reorg of the head block)
and grandparent_empty. Which candidates build is configurable per key
(auto/always/never; auto derives from the parent payload's reveal status
and head-vote weakness) via config, CLI flags and the mutable settings
registry.

- the build pass resolves the candidate set at build start from policy +
  chain view + received attribute variants; missing variants are
  synthesized (empty-parent withdrawals from the parent slot's attributes
  per the unchanged expected-withdrawals rule; grandparent candidates
  re-target the parent slot's attributes, refused across epoch
  boundaries); received variants on unknown branches still build
- sequential builds run speculative candidates first and the canonical one
  last so the EL head ends on the canonical chain; build.parallel opts
  into concurrent engine builds, build.speculative_build_time_ms shortens
  speculative builds
- variants arriving after the build pass activate additional candidate
  builds until slot end instead of being dropped
- payload cache, build events and slot results are candidate-aware: the
  cache keys payloads by parent tuple with priority-based primary
  selection, slot results carry a builds list next to the primary build,
  and payload artifacts get per-slot indices with candidate metadata
  (legacy artifacts read as index 0; /payload/{index} endpoint added)
- attribute sanitization re-sources withdrawals when redirecting a
  full-parent reference to a withheld payload's execution parent
- flag-gated bid gas-limit enforcement (build.enforce_bid_gas_limit,
  default off): adjust the built payload to the exact gossip-legal gas
  limit derived from the actual EL parent payload
- engine fcU carries the resolved proposer fee recipient instead of the
  builder default; fix mislabeled parent_hash log field
- p2p bidding picks its candidate payload at bid time from the live
  epbs.bid_candidate setting: auto (match the chain view's head and
  payload status; sticky per slot unless epbs.bid_candidate_switch), a
  forced candidate key, or all (gossip one bid per built candidate —
  multi-parent gossip testing). Single-bid dedup is per payload, so a
  candidate switch or rebuild bids again
- bidding reopens for a slot when the block that closed it is reorged
  out; per-slot bid state and the competitor tracker prune on epoch
  transitions
- competitor-high comparison only considers bids committing to the same
  execution parent
- builder-api bid requests are answered from whichever candidate payload
  matches the requested (parent_hash, parent_root) — governed by
  builder_api.serve_candidates (all / canonical_only / key list) — with
  optional on-demand building (builder_api.on_demand_build) for legal
  parent tuples no candidate covers; requests for a chain-view-illegal
  tuple still fail with 400, everything else degrades to 204
- legacy getHeader serves the candidate payload matching the requested
  parent hash
- a reveal request for an already scheduled slot re-binds the reveal when
  our payload is re-included under a different beacon block after a reorg:
  the envelope is rebuilt and re-signed for the new root (the signature
  covers the block root, so the old envelope cannot be reused; gossip
  seen-caches are per builder + block root). Gated by
  reveal.rebind_on_reorg (default on) and verified against the chain view
- an orphaned winning block clears the payload's won marker (so a
  re-inclusion is detected and counted again) and flags the pending
  payment as disputed, excluding it from the pending total; the block
  returning to the canonical chain restores it. Locally settled payments
  (already revealed) are only logged — the on-chain payment quorum owns
  the final outcome
…re WebUI

- the plan's build category gains a candidates map (candidate key ->
  auto/always/never) merged with the global policy into the frozen
  snapshot's complete candidate_modes; bid plans gain bid_candidate,
  builder-api plans gain serve_candidates — all consumers read the frozen
  per-slot values
- SSE build events carry the candidate key; the slot timeline tracks every
  candidate build: the primary payload keeps the classic build line
  (colored by candidate), other candidates render as thin stacked
  mini-bars, and a badge popover lists each candidate's parent, timing and
  outcome; the legend shows the candidate palette
- slot results carry the per-candidate builds list next to the primary
  build, each with its payload artifact index
- the slot edit modal adds per-candidate build policy selects (single-slot
  editing), a bid-candidate select and a serve-candidates select; plan
  cells show a candidate-override chip
…starved

The per-slot LastBidTime gate let the first candidate payload's bid
throttle every other candidate in the same tick: with bid_candidate=all
and interval bidding only one candidate ever gossiped. Interval
throttling and single-bid dedup now key on the payload (BidPayloads maps
payload hash -> last bid time), so each candidate bids on its own
schedule. Found live on a glamsterdam-devnet-7 devnet run.
…ilder UI

Devnet feedback on the multi-candidate build pass:

- candidates now build concurrently by default, so all of them complete
  within the slot's designated build window instead of serializing into
  it. Serialized builds remain available (build.parallel=false) and run
  the canonical candidate first so it keeps its scheduled start; the
  speculative build time only applies there
- fix candidate builds cancelling each other: parent_empty and
  grandparent_full extend the same execution parent and differ only in
  the beacon parent, so the active-build key needs the full parent tuple.
  Retiring a build now also only clears its own entry
- resolve the forkchoice safe/finalized hashes once per head change in
  the head tracker instead of per build (each build paid four beacon-API
  round trips), and keep candidate resolution cache-only: the execution
  block number is backfilled from cached envelope metadata, prefetched
  when a payload-available event arrives, never fetched on the hot path
- the build-candidate policy is now editable in the UI, as a section of
  the Payload Builder card (per-candidate auto/always/never, weak-head
  threshold, parallel toggle, speculative build time, gas-limit
  enforcement)
- slot graph: the primary build line spans the primary candidate's own
  build instead of stretching across every candidate as they complete;
  the other candidates' mini-bars are clickable and sit inside the row,
  and the candidate badge renders below the build line so it is not
  clipped when building starts before the slot
The stacked per-candidate mini-bars cluttered the slot row for little
value. The graph now draws a single build line again (the primary
candidate's, colored by candidate), and the payload-created dot opens a
tabbed callout instead: one tab per built candidate, ordered
parent → grandparent, each showing that payload's own hash, value, block
number, gas and contents, with its own JSON/SSZ download.

The payload artifact endpoint takes an optional ?candidate= selector for
this, resolving the slot's payload artifact index from the stored
candidate metadata (404 when the slot built no payload for it).
Which payload an event actually used was invisible in the UI, and the
payload callouts showed no parent context.

- bid, getExecutionPayloadBid, getHeader and won-block callouts now name
  the candidate payload they committed to, matched by block hash (shown
  only when the slot built more than one candidate)
- payload callouts show the parent tuple the payload extends: the beacon
  parent root with its slot and the execution parent hash with its block
  number, per candidate tab
- payload_ready carries the parent block root, its slot and the execution
  parent's block number; the beacon parent's slot is resolved from the
  head tracker's cache so event assembly never blocks
pk910 added 2 commits August 3, 2026 15:58
CI runs gofmt with -s (simplify); these files only satisfied plain gofmt.
Alignment of const and struct-field blocks, one import ordering, and three
doubled blank lines. No behaviour change.
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.

1 participant