diff --git a/CLAUDE.md b/CLAUDE.md index d216920..42ca59e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,43 @@ npm run clean still-open slots (`> currentSlot`, not yet frozen) — past slots keep their recorded history instead of being described by today's rules. +0b. **Chain view / head tracker** (`pkg/chain/headtracker.go`) — buildoor's own + canonical-chain oracle: current head, bounded block-by-root ancestry cache + (shared with the inclusion tracker), per-block Gloas payload reveal status + (payload-available events + child-block evidence + PAYLOAD_DUE fallback), + reorg detection (head-change events with depth/common-ancestor; the beacon + `chain_reorg` SSE topic is subscribed as a cross-check), and build-parent + candidate resolution. Also: epoch-stats refetch when a head event's duty + dependent root proves a reorg crossed the epoch boundary. + +0c. **Build candidates** — a slot may build SEVERAL payloads, one per parent + tuple (candidate keys: `parent_full`, `parent_empty` = Gloas payload miss, + `grandparent_full` = deliberate reorg of the head block, + `grandparent_empty`). Policy per key: `auto` (chain signals: parent payload + reveal status, head-vote weakness below `build.auto_weak_head_pct`) / + `always` / `never` via `build.candidate_*` settings, per-slot overridable + through the action plan's `build.candidates` map (resolved into + `FrozenPlan.Build.CandidateModes`). Attributes are cached per + (slot, parent_root, parent_hash) variant — last-writer-wins per variant — + sanitized against the chain view (Grandine's wrong miss-case parent is + corrected, missing parent numbers backfilled) and synthesized for uncovered + candidates (empty-parent withdrawals from the parent slot's attributes; + grandparent candidates re-target the parent slot's attributes, refused + across epoch boundaries). Sequential builds run speculative candidates + first, canonical last (`build.parallel` opts into concurrent engine + builds). Consumers select at request time: p2p bids per + `epbs.bid_candidate` (auto = match chain view, sticky per slot unless + `epbs.bid_candidate_switch`; `all` = deliberate multi-parent gossip), + builder-api serves whichever candidate matches the requested parent + (`builder_api.serve_candidates` policy, optional + `builder_api.on_demand_build`). 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. 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). + 1. **Builder Service** (`pkg/payload_builder/`) - Main orchestrator for payload building - Subscribes to beacon node's `payload_attributes` events diff --git a/cmd/root.go b/cmd/root.go index 31ff11b..ef4eb36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,8 @@ func init() { rootCmd.PersistentFlags().Bool("builder-api-enabled", defaults.BuilderAPIEnabled, "Enable traditional Builder API at startup (served on --api-port)") rootCmd.PersistentFlags().Uint64("builder-api-subsidy", defaults.BuilderAPI.BlockValueSubsidyGwei, "Gwei added to the bid value in both Fulu (getHeader) and Gloas (ExecutionPayment) Builder API bids") rootCmd.PersistentFlags().Uint64("builder-api-value-override", defaults.BuilderAPI.ValueOverrideGwei, "Absolute total value in gwei served in Builder API bids, replacing block value + subsidy (0 = disabled)") + rootCmd.PersistentFlags().String("builder-api-serve-candidates", defaults.BuilderAPI.ServeCandidates, "Which built candidate payloads bid requests are answered from: all, canonical_only, or a comma-separated candidate key list") + rootCmd.PersistentFlags().Bool("builder-api-on-demand-build", defaults.BuilderAPI.OnDemandBuild, "Build a payload on the fly when a bid request asks for a legal parent no candidate covers yet") rootCmd.PersistentFlags().String("builder-api-url", defaults.BuilderAPI.BuilderURL, "Publicly reachable URL of this builder (e.g. https://builder.example.com); used to validate builder_url in SignedRequestAuthV1") rootCmd.PersistentFlags().Bool("builder-api-require-auth", defaults.BuilderAPI.RequireRequestAuth, "Require SignedRequestAuthV1 on getExecutionPayloadBid requests; reject unauthenticated requests with 401") rootCmd.PersistentFlags().Uint64("deposit-amount", defaults.DepositAmount, "Builder deposit amount in Gwei") @@ -89,6 +91,18 @@ func init() { rootCmd.PersistentFlags().Uint64("epbs-bid-subsidy", defaults.EPBS.BidSubsidy, "Gwei added to every bid so it clears the proposer's local-EL threshold") rootCmd.PersistentFlags().Uint64("epbs-bid-value-override", defaults.EPBS.BidValueOverride, "Absolute p2p bid base value in gwei, replacing max(blockValue, bid-min) + subsidy (0 = disabled); allows underbidding the block value for testing") rootCmd.PersistentFlags().Uint64("epbs-vote-threshold", defaults.EPBS.HeadVoteThresholdPct, "Head-vote participation threshold in percent; crossing it fires an immediate threshold_met update (0 = disabled)") + rootCmd.PersistentFlags().String("epbs-bid-candidate", defaults.EPBS.BidCandidate, "Which built candidate payload p2p bids commit to: auto, parent_full, parent_empty, grandparent_full, grandparent_empty or all") + rootCmd.PersistentFlags().Bool("epbs-bid-candidate-switch", defaults.EPBS.BidCandidateSwitch, "Allow the auto bid candidate selection to switch mid-slot when the chain view changes") + + // Payload build candidates (reorg / payload-miss preparedness) + rootCmd.PersistentFlags().String("build-candidate-parent-full", defaults.Build.CandidateParentFull, "Build the normal candidate on the head block and its payload: auto, always or never") + rootCmd.PersistentFlags().String("build-candidate-parent-empty", defaults.Build.CandidateParentEmpty, "Build the payload-miss candidate on the head block but its execution parent: auto, always or never") + rootCmd.PersistentFlags().String("build-candidate-grandparent-full", defaults.Build.CandidateGrandparentFull, "Build the reorg candidate on the head block's parent: auto, always or never") + rootCmd.PersistentFlags().String("build-candidate-grandparent-empty", defaults.Build.CandidateGrandparentEmpty, "Build the reorg + payload-miss candidate: auto, always or never") + rootCmd.PersistentFlags().Bool("build-parallel", defaults.Build.Parallel, "Build selected candidates concurrently against the EL instead of sequentially (canonical first, speculative after)") + rootCmd.PersistentFlags().Uint64("build-speculative-build-time", defaults.Build.SpeculativeBuildTimeMs, "EL build time in ms for speculative (non parent_full) candidates (0 = use payload-build-time)") + rootCmd.PersistentFlags().Uint64("build-auto-weak-head-pct", defaults.Build.AutoWeakHeadPct, "Head-vote participation in percent below which the head counts as contested and auto-mode reorg candidates build (0 = disabled)") + rootCmd.PersistentFlags().Bool("build-enforce-bid-gas-limit", defaults.Build.EnforceBidGasLimit, "Adjust the built payload's gas limit to the exact bid-gossip-legal value when the EL ignored the proposer's target") // Payload reveal (shared by the p2p bidder and Builder API flows) rootCmd.PersistentFlags().Bool("reveal-enabled", defaults.Reveal.Enabled, "Globally enable payload reveals (per-slot action plans can still force/suppress)") @@ -98,6 +112,7 @@ func init() { rootCmd.PersistentFlags().String("reveal-broadcast-validation", defaults.Reveal.BroadcastValidation, "Envelope broadcast validation: gossip, consensus or consensus_and_equivocation") rootCmd.PersistentFlags().Uint64("reveal-max-attempts", defaults.Reveal.MaxAttempts, "Total publish attempts per reveal") rootCmd.PersistentFlags().Int64("reveal-retry-interval", defaults.Reveal.RetryIntervalMs, "Wait between failed reveal attempts in ms") + rootCmd.PersistentFlags().Bool("reveal-rebind-on-reorg", defaults.Reveal.RebindOnReorg, "Re-bind a slot's reveal (rebuilt, re-signed envelope) when our payload is re-included under a different block root after a reorg") // Payload Build Time (0 = auto from slot time, scaled from the 12s value) rootCmd.PersistentFlags().Uint64("payload-build-time", 0, "Time to allow the EL to build the payload in ms (0 = auto: 2100ms @12s, scaled to slot time)") @@ -182,6 +197,8 @@ func initConfig() error { RequireRequestAuth: v.GetBool("builder-api-require-auth"), BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), ValueOverrideGwei: v.GetUint64("builder-api-value-override"), + ServeCandidates: v.GetString("builder-api-serve-candidates"), + OnDemandBuild: v.GetBool("builder-api-on-demand-build"), }, DepositMaxFeeGwei: v.GetUint64("deposit-max-fee"), DepositAmount: v.GetUint64("deposit-amount"), @@ -204,6 +221,8 @@ func initConfig() error { BidSubsidy: v.GetUint64("epbs-bid-subsidy"), BidValueOverride: v.GetUint64("epbs-bid-value-override"), HeadVoteThresholdPct: v.GetUint64("epbs-vote-threshold"), + BidCandidate: v.GetString("epbs-bid-candidate"), + BidCandidateSwitch: v.GetBool("epbs-bid-candidate-switch"), }, Reveal: config.RevealConfig{ Enabled: v.GetBool("reveal-enabled"), @@ -213,6 +232,17 @@ func initConfig() error { BroadcastValidation: v.GetString("reveal-broadcast-validation"), MaxAttempts: v.GetUint64("reveal-max-attempts"), RetryIntervalMs: v.GetInt64("reveal-retry-interval"), + RebindOnReorg: v.GetBool("reveal-rebind-on-reorg"), + }, + Build: config.BuildConfig{ + CandidateParentFull: v.GetString("build-candidate-parent-full"), + CandidateParentEmpty: v.GetString("build-candidate-parent-empty"), + CandidateGrandparentFull: v.GetString("build-candidate-grandparent-full"), + CandidateGrandparentEmpty: v.GetString("build-candidate-grandparent-empty"), + Parallel: v.GetBool("build-parallel"), + SpeculativeBuildTimeMs: v.GetUint64("build-speculative-build-time"), + AutoWeakHeadPct: v.GetUint64("build-auto-weak-head-pct"), + EnforceBidGasLimit: v.GetBool("build-enforce-bid-gas-limit"), }, PayloadBuildTime: v.GetUint64("payload-build-time"), SlotResultRetentionEpochs: v.GetUint64("slot-result-retention-epochs"), @@ -229,6 +259,17 @@ func initConfig() error { return fmt.Errorf("provide only one of --builder-privkey or --builder-mnemonic, not both") } + for flag, mode := range map[string]string{ + "--build-candidate-parent-full": cfg.Build.CandidateParentFull, + "--build-candidate-parent-empty": cfg.Build.CandidateParentEmpty, + "--build-candidate-grandparent-full": cfg.Build.CandidateGrandparentFull, + "--build-candidate-grandparent-empty": cfg.Build.CandidateGrandparentEmpty, + } { + if mode != config.NormalizedCandidateMode(mode, "") { + return fmt.Errorf("invalid %s %q: must be auto, always or never", flag, mode) + } + } + if cfg.Reveal.GateMode != cfg.Reveal.NormalizedGateMode() { return fmt.Errorf("invalid --reveal-gate-mode %q: must be time, vote, vote_or_time or vote_and_time", cfg.Reveal.GateMode) diff --git a/cmd/run.go b/cmd/run.go index b8b3faa..0542d89 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -379,6 +379,7 @@ and begins building blocks according to configuration.`, if revealSvc != nil { builderAPISrv.SetRevealService(revealSvc) + builderAPISrv.SetOnDemandBuilder(builderSvc) } if propPrefSvc != nil { diff --git a/pkg/action_plan/frozen.go b/pkg/action_plan/frozen.go index 2a384fb..a3e2d74 100644 --- a/pkg/action_plan/frozen.go +++ b/pkg/action_plan/frozen.go @@ -6,6 +6,7 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/ethpandaops/go-eth2-client/spec/version" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" ) @@ -87,6 +88,11 @@ type ResolvedBuildSettings struct { // execution payload instead of the immediate parent — a deliberate // parent-payload reorg attempt (see BuildPlan.ReorgParentPayload). ReorgParentPayload bool `json:"reorg_parent_payload,omitempty"` + + // CandidateModes is the effective build-candidate policy for the slot: + // candidate key -> auto/always/never, merged from the global config and + // the plan's build.candidates overrides. + CandidateModes map[string]string `json:"candidate_modes,omitempty"` } // ResolvedBidSettings are the effective p2p bidding parameters for the slot. @@ -105,6 +111,10 @@ type ResolvedBidSettings struct { // IgnoreMissingPrefs bids without gossip proposer preferences. IgnoreMissingPrefs bool `json:"ignore_missing_prefs,omitempty"` + // BidCandidate is the effective bid candidate selection for the slot: + // auto, all, or a specific candidate key. + BidCandidate string `json:"bid_candidate,omitempty"` + // Forced marks that the plan activated bidding although the module is // globally disabled. Forced bool `json:"forced,omitempty"` @@ -121,6 +131,10 @@ type ResolvedBuilderAPISettings struct { DelayMs int64 `json:"delay_ms,omitempty"` + // ServeCandidates is the effective serve-candidates policy for the slot + // (all, canonical_only, or a comma-separated candidate key list). + ServeCandidates string `json:"serve_candidates,omitempty"` + // Forced marks that the plan activated serving although the module is // globally disabled. Forced bool `json:"forced,omitempty"` @@ -208,6 +222,8 @@ func resolveBuild(frozen *FrozenPlan, cfg *config.Config, slotsBuilt uint64) *Re build.ReorgParentPayload = frozen.Plan.Build.ReorgParentPayload } + build.CandidateModes = resolveCandidateModes(frozen.Plan, cfg) + // A plan that explicitly activates (mode custom) an available consumer // forces the build past the schedule. A merely-inherited active consumer // never forces, and a custom category whose consumer is unavailable @@ -275,6 +291,27 @@ func resolveBuild(frozen *FrozenPlan, cfg *config.Config, slotsBuilt uint64) *Re return build } +// resolveCandidateModes merges the global build-candidate policy with the +// plan's per-slot overrides into the complete effective mode map. +func resolveCandidateModes(plan *SlotPlan, cfg *config.Config) map[string]string { + modes := make(map[string]string, len(chain.AllCandidateKeys)) + + for _, key := range chain.AllCandidateKeys { + modes[string(key)] = cfg.Build.CandidateMode(string(key)) + } + + if plan != nil && plan.Build != nil { + for key, mode := range plan.Build.Candidates { + if normalized := config.NormalizedCandidateMode(mode, ""); normalized != "" && + chain.IsValidCandidateKey(key) { + modes[key] = normalized + } + } + } + + return modes +} + // planForcesConsumer reports whether the plan explicitly activates (mode // custom) a consumer that is actually available for the slot. func planForcesConsumer(frozen *FrozenPlan) bool { @@ -342,6 +379,7 @@ func resolveBid(plan *SlotPlan, cfg *config.Config, fork version.DataVersion) *R MinGwei: cfg.EPBS.BidMinAmount, IncreaseGwei: cfg.EPBS.BidIncrease, SubsidyGwei: cfg.EPBS.BidSubsidy, + BidCandidate: cfg.EPBS.BidCandidate, Forced: forced, } @@ -364,6 +402,10 @@ func resolveBid(plan *SlotPlan, cfg *config.Config, fork version.DataVersion) *R } resolved.IgnoreMissingPrefs = bid.IgnoreMissingPrefs + + if bid.BidCandidate != nil { + resolved.BidCandidate = *bid.BidCandidate + } } return resolved @@ -389,8 +431,9 @@ func resolveBuilderAPI(plan *SlotPlan, cfg *config.Config) *ResolvedBuilderAPISe } resolved := &ResolvedBuilderAPISettings{ - SubsidyGwei: cfg.BuilderAPI.BlockValueSubsidyGwei, - Forced: forced, + SubsidyGwei: cfg.BuilderAPI.BlockValueSubsidyGwei, + ServeCandidates: cfg.BuilderAPI.ServeCandidates, + Forced: forced, } if cfg.BuilderAPI.ValueOverrideGwei > 0 { @@ -406,6 +449,10 @@ func resolveBuilderAPI(plan *SlotPlan, cfg *config.Config) *ResolvedBuilderAPISe if api.TotalValueOverrideGwei != nil { resolved.TotalValueGwei = cloneScalar(api.TotalValueOverrideGwei) } + + if api.ServeCandidates != nil { + resolved.ServeCandidates = *api.ServeCandidates + } } return resolved diff --git a/pkg/action_plan/service_test.go b/pkg/action_plan/service_test.go index 567b900..2fc9447 100644 --- a/pkg/action_plan/service_test.go +++ b/pkg/action_plan/service_test.go @@ -691,3 +691,58 @@ func TestResolveRevealSettings(t *testing.T) { } }) } + +func TestFrozenCandidateSettings(t *testing.T) { + cfg := config.DefaultConfig() + cfg.EPBSEnabled = true + cfg.APIPort = 8080 + cfg.BuilderAPIEnabled = true + + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + svc := NewPlanService(cfg, newStubChain(), log) + + // Without a plan: the frozen snapshot carries the complete global + // candidate policy and the global bid/serve selections. + frozen := svc.Freeze(2000) + require.NotNil(t, frozen.Build.CandidateModes) + assert.Equal(t, config.CandidateModeAlways, frozen.Build.CandidateModes["parent_full"]) + assert.Equal(t, config.CandidateModeAuto, frozen.Build.CandidateModes["parent_empty"]) + assert.Equal(t, config.CandidateModeNever, frozen.Build.CandidateModes["grandparent_empty"]) + require.NotNil(t, frozen.Bid) + assert.Equal(t, "auto", frozen.Bid.BidCandidate) + require.NotNil(t, frozen.BuilderAPI) + assert.Equal(t, "all", frozen.BuilderAPI.ServeCandidates) + + // Plan overrides merge into the frozen snapshot. + _, err := svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2100}, + Build: json.RawMessage(`{"candidates":{"grandparent_full":"always","parent_empty":"never"}}`), + Bid: json.RawMessage(`{"mode":"custom","bid_candidate":"parent_empty"}`), + BuilderAPI: json.RawMessage( + `{"mode":"custom","serve_candidates":"canonical_only"}`), + }}, "test") + require.NoError(t, err) + + frozen = svc.Freeze(2100) + assert.Equal(t, config.CandidateModeAlways, frozen.Build.CandidateModes["grandparent_full"]) + assert.Equal(t, config.CandidateModeNever, frozen.Build.CandidateModes["parent_empty"]) + assert.Equal(t, config.CandidateModeAlways, frozen.Build.CandidateModes["parent_full"], + "unoverridden keys inherit the global policy") + assert.Equal(t, "parent_empty", frozen.Bid.BidCandidate) + assert.Equal(t, "canonical_only", frozen.BuilderAPI.ServeCandidates) + + // Invalid candidate values are rejected. + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2200}, + Build: json.RawMessage(`{"candidates":{"parent_full":"sometimes"}}`), + }}, "test") + require.Error(t, err) + + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2200}, + Bid: json.RawMessage(`{"mode":"custom","bid_candidate":"bogus"}`), + }}, "test") + require.Error(t, err) +} diff --git a/pkg/action_plan/types.go b/pkg/action_plan/types.go index 22c1218..24ee188 100644 --- a/pkg/action_plan/types.go +++ b/pkg/action_plan/types.go @@ -15,6 +15,7 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/jqtransform" ) @@ -56,6 +57,10 @@ type BidPlan struct { BidInterval *int64 `json:"bid_interval,omitempty"` // ms, >= 0, 0 = single bid BidSubsidy *uint64 `json:"bid_subsidy,omitempty"` // gwei + // BidCandidate overrides which built candidate payload this slot's p2p + // bids commit to: auto, all, or a specific candidate key. + BidCandidate *string `json:"bid_candidate,omitempty"` + // BidValueGwei is an absolute bid base value replacing // max(blockValue, min) + subsidy; BidIncrease still applies per re-bid. // Allows underbidding the block value for testing. @@ -86,7 +91,7 @@ func (p *BidPlan) clone() *BidPlan { func (p *BidPlan) hasOverrides() bool { return p.BidStartTime != nil || p.BidEndTime != nil || p.BidMinAmount != nil || p.BidIncrease != nil || p.BidInterval != nil || p.BidSubsidy != nil || - p.BidValueGwei != nil || p.IgnoreMissingPrefs + p.BidValueGwei != nil || p.IgnoreMissingPrefs || p.BidCandidate != nil } func (p *BidPlan) validate(slotMs int64) error { @@ -115,6 +120,13 @@ func (p *BidPlan) validate(slotMs int64) error { return fmt.Errorf("bid: bid_interval must be >= 0, got %d", *p.BidInterval) } + if p.BidCandidate != nil { + if candidate := *p.BidCandidate; candidate != "auto" && candidate != "all" && + !chain.IsValidCandidateKey(candidate) { + return fmt.Errorf("bid: bid_candidate must be auto, all or a candidate key, got %q", candidate) + } + } + return nil } @@ -134,6 +146,11 @@ type BuilderAPIPlan struct { // ResponseDelayMs delays the bid response by this many milliseconds // (context-cancellable, capped at one slot). ResponseDelayMs *int64 `json:"response_delay_ms,omitempty"` + + // ServeCandidates overrides which built candidate payloads bid requests + // for this slot may be answered from: all, canonical_only, or a + // comma-separated candidate key list. + ServeCandidates *string `json:"serve_candidates,omitempty"` } func (p *BuilderAPIPlan) clone() *BuilderAPIPlan { @@ -150,7 +167,8 @@ func (p *BuilderAPIPlan) clone() *BuilderAPIPlan { } func (p *BuilderAPIPlan) hasOverrides() bool { - return p.ValueSubsidyGwei != nil || p.TotalValueOverrideGwei != nil || p.ResponseDelayMs != nil + return p.ValueSubsidyGwei != nil || p.TotalValueOverrideGwei != nil || p.ResponseDelayMs != nil || + p.ServeCandidates != nil } func (p *BuilderAPIPlan) validate(slotMs int64) error { @@ -167,6 +185,30 @@ func (p *BuilderAPIPlan) validate(slotMs int64) error { slotMs, *p.ResponseDelayMs) } + if p.ServeCandidates != nil { + if err := validateServeCandidates(*p.ServeCandidates); err != nil { + return err + } + } + + return nil +} + +// validateServeCandidates checks a serve-candidates policy string: all, +// canonical_only, or a comma-separated list of candidate keys. +func validateServeCandidates(policy string) error { + switch policy { + case "all", "canonical_only": + return nil + } + + for _, key := range strings.Split(policy, ",") { + if !chain.IsValidCandidateKey(strings.TrimSpace(key)) { + return fmt.Errorf("builder_api: serve_candidates must be all, canonical_only "+ + "or a comma-separated candidate key list, got %q", policy) + } + } + return nil } @@ -253,6 +295,12 @@ type BuildPlan struct { // rejected by mainnet forkchoice, but useful for exercising the reveal / // inclusion path against a withheld parent. ReorgParentPayload bool `json:"reorg_parent_payload,omitempty"` + + // Candidates overrides the global build-candidate policy for this slot: + // candidate key (parent_full, parent_empty, grandparent_full, + // grandparent_empty) -> mode (auto, always, never). Absent keys inherit + // the global policy. + Candidates map[string]string `json:"candidates,omitempty"` } func (p *BuildPlan) clone() *BuildPlan { @@ -262,17 +310,33 @@ func (p *BuildPlan) clone() *BuildPlan { c := *p + if p.Candidates != nil { + c.Candidates = make(map[string]string, len(p.Candidates)) + for key, mode := range p.Candidates { + c.Candidates[key] = mode + } + } + return &c } // isZero reports whether the build plan carries no active instruction; such a // plan is dropped rather than persisted. func (p *BuildPlan) isZero() bool { - return p == nil || !p.ReorgParentPayload + return p == nil || (!p.ReorgParentPayload && len(p.Candidates) == 0) } func (p *BuildPlan) validate() error { - // No mode and no bounded fields yet; the boolean flag is always valid. + for key, mode := range p.Candidates { + if !chain.IsValidCandidateKey(key) { + return fmt.Errorf("build.candidates: unknown candidate key %q", key) + } + + if config.NormalizedCandidateMode(mode, "") == "" { + return fmt.Errorf("build.candidates.%s: mode must be auto, always or never (got %q)", key, mode) + } + } + return nil } diff --git a/pkg/builderapi/epbs/handler.go b/pkg/builderapi/epbs/handler.go index 5ace7b8..35cdd2a 100644 --- a/pkg/builderapi/epbs/handler.go +++ b/pkg/builderapi/epbs/handler.go @@ -94,12 +94,13 @@ type Handler struct { // serving is decided exclusively by the slot's frozen plan. planSvc *action_plan.PlanService - revealSvc *payload_bidder.RevealService // SetRevealService — the ONLY reveal path - propPrefsStore *memstore.Store[phase0.Slot, *gloasspec.SignedProposerPreferences] // SetProposerPreferencesStore - prefsStore *BuilderPreferencesStore // created in NewHandler - broadcaster BlockBroadcaster // SetBlockBroadcaster - events EventBroadcaster // SetEventBroadcaster (nil-checked) - recorder SlotResultRecorder // SetResultRecorder (nil-checked) + revealSvc *payload_bidder.RevealService // SetRevealService — the ONLY reveal path + onDemandBuilder OnDemandPayloadBuilder // SetOnDemandBuilder (nil-checked) + propPrefsStore *memstore.Store[phase0.Slot, *gloasspec.SignedProposerPreferences] // SetProposerPreferencesStore + prefsStore *BuilderPreferencesStore // created in NewHandler + broadcaster BlockBroadcaster // SetBlockBroadcaster + events EventBroadcaster // SetEventBroadcaster (nil-checked) + recorder SlotResultRecorder // SetResultRecorder (nil-checked) lastBidMu sync.Mutex lastBids map[phase0.Slot]recordedBid // dedupe of repeated identical bid records @@ -211,6 +212,20 @@ func (h *Handler) SetRevealService(rs *payload_bidder.RevealService) { h.revealSvc = rs } +// OnDemandPayloadBuilder builds a payload for a specific parent tuple on +// request (implemented by payload_builder.Service). Used to answer bid +// requests for legal parents no candidate build covered. +type OnDemandPayloadBuilder interface { + BuildCandidateOnDemand(ctx context.Context, slot phase0.Slot, + parentRoot phase0.Root, parentHash phase0.Hash32) (*payload_builder.Payload, error) +} + +// SetOnDemandBuilder wires the on-demand payload builder used when a bid +// request asks for a legal parent tuple without a built candidate. +func (h *Handler) SetOnDemandBuilder(builder OnDemandPayloadBuilder) { + h.onDemandBuilder = builder +} + // SetProposerPreferencesStore wires the per-slot proposer preferences store // (owned by payload_bidder.ProposerPreferencesService) used to resolve the fee // recipient when building Gloas execution payload bids. diff --git a/pkg/builderapi/epbs/handler_test.go b/pkg/builderapi/epbs/handler_test.go index 5a8d92c..f7e3110 100644 --- a/pkg/builderapi/epbs/handler_test.go +++ b/pkg/builderapi/epbs/handler_test.go @@ -95,6 +95,7 @@ func (m *stubChainService) GetEpochStats(phase0.Epoch) *chain.EpochStats { retur func (m *stubChainService) SubscribeEpochStats() *utils.Subscription[*chain.EpochStats] { return nil } func (m *stubChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return nil } +func (m *stubChainService) GetHeadTracker() *chain.HeadTracker { return nil } func (m *stubChainService) GetFinalizedEpoch() phase0.Epoch { return m.finalizedEpoch } func (m *stubChainService) GetBuilderByIndex(uint64) *chain.BuilderInfo { return nil } diff --git a/pkg/builderapi/epbs/payload_bid.go b/pkg/builderapi/epbs/payload_bid.go index 96cbd69..328dfc1 100644 --- a/pkg/builderapi/epbs/payload_bid.go +++ b/pkg/builderapi/epbs/payload_bid.go @@ -19,8 +19,10 @@ import ( "github.com/sirupsen/logrus" "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/payload_bidder" "github.com/ethpandaops/buildoor/pkg/payload_builder" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" ) // GetExecutionPayloadBidResponse is the JSON envelope returned by @@ -237,35 +239,22 @@ func (h *Handler) HandleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Re var parentRoot phase0.Root copy(parentRoot[:], parentRootBytes) - event := h.payloadCache.Get(slot) - if event == nil { - log.Info("getExecutionPayloadBid: returning 204 — no cached payload for slot") - h.recordBid(slot, fork.String(), "", nil, 0, 0, bidStatusFailed, "no cached payload for slot") - w.WriteHeader(http.StatusNoContent) + event, matchErr := h.matchPayloadForParent(r.Context(), slot, parentRoot, parentHash, + frozenSettings.ServeCandidates) + if matchErr != nil { + if matchErr.status == http.StatusNoContent { + log.WithField("reason", matchErr.reason). + Info("getExecutionPayloadBid: returning 204 — no payload for requested parent") + h.recordBid(slot, fork.String(), "", nil, 0, 0, bidStatusFailed, matchErr.reason) + w.WriteHeader(http.StatusNoContent) - return - } - - if event.Attributes.ParentBlockHash != parentHash { - log.WithFields(logrus.Fields{ - "request_parent_hash": "0x" + hex.EncodeToString(parentHash[:]), - "cached_parent_hash": "0x" + hex.EncodeToString(event.Attributes.ParentBlockHash[:]), - }).Info("getExecutionPayloadBid: 400 — parent_hash does not match cached payload") - h.recordBid(slot, fork.String(), "", nil, 0, 0, bidStatusFailed, - "parent_hash does not match cached payload") - writeError(w, http.StatusBadRequest, "parent_hash does not match cached payload") - - return - } + return + } - if event.Attributes.ParentBlockRoot != parentRoot { - log.WithFields(logrus.Fields{ - "request_parent_root": "0x" + hex.EncodeToString(parentRoot[:]), - "cached_parent_root": "0x" + hex.EncodeToString(event.Attributes.ParentBlockRoot[:]), - }).Info("getExecutionPayloadBid: 400 — parent_root does not match cached payload") - h.recordBid(slot, fork.String(), "", nil, 0, 0, bidStatusFailed, - "parent_root does not match cached payload") - writeError(w, http.StatusBadRequest, "parent_root does not match cached payload") + log.WithField("reason", matchErr.reason). + Info("getExecutionPayloadBid: 400 — invalid parent request") + h.recordBid(slot, fork.String(), "", nil, 0, 0, bidStatusFailed, matchErr.reason) + writeError(w, matchErr.status, matchErr.reason) return } @@ -420,3 +409,71 @@ func (h *Handler) HandleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Re h.recordBid(slot, fork.String(), blockHashHex, signedBid, totalValueGwei, executionPaymentGwei, bidStatusServed, "") } + +// bidMatchError describes why no payload could be served for the requested +// parent tuple. +type bidMatchError struct { + status int + reason string +} + +// matchPayloadForParent returns the built payload matching the requested +// parent tuple. A slot may hold several candidate payloads (reorg / +// payload-miss preparedness); the request's (parent_hash, parent_root) picks +// the one the proposer's branch needs, subject to the serve-candidates +// policy. When no candidate covers a chain-view-legal tuple and on-demand +// building is enabled, the payload is built within the request's budget. +// Requests for a tuple the chain view proves illegal (known beacon parent, +// but a hash that is neither its committed payload nor its execution parent) +// fail with 400. +func (h *Handler) matchPayloadForParent( + ctx context.Context, slot phase0.Slot, parentRoot phase0.Root, parentHash phase0.Hash32, + servePolicy string, +) (*payload_builder.Payload, *bidMatchError) { + if servePolicy == "" { + servePolicy = h.cfg.ServeCandidates + } + + if payload := h.payloadCache.GetVariant(slot, beacon.AttrParentKey{Root: parentRoot, Hash: parentHash}); payload != nil { + if !config.ServeCandidateAllowed(servePolicy, string(payload.Candidate)) { + return nil, &bidMatchError{status: http.StatusNoContent, + reason: "candidate " + string(payload.Candidate) + " excluded by serve policy"} + } + + return payload, nil + } + + // Judge the requested tuple against the chain view: a known beacon parent + // only legally pairs with its committed payload hash (full) or its own + // execution parent (empty). + legal := true + + if headTracker := h.chainSvc.GetHeadTracker(); headTracker != nil { + if parentBlock, err := headTracker.GetBlock(ctx, parentRoot); err == nil { + legal = parentHash == parentBlock.ExecutionBlockHash || + parentHash == parentBlock.FinalitySafeExecutionBlockHash + } + } + + if !legal { + return nil, &bidMatchError{status: http.StatusBadRequest, + reason: "parent_hash is neither the parent block's committed payload nor its execution parent"} + } + + if h.cfg.OnDemandBuild && h.onDemandBuilder != nil { + payload, err := h.onDemandBuilder.BuildCandidateOnDemand(ctx, slot, parentRoot, parentHash) + if err != nil { + return nil, &bidMatchError{status: http.StatusNoContent, + reason: "on-demand build failed: " + err.Error()} + } + + if !config.ServeCandidateAllowed(servePolicy, string(payload.Candidate)) { + return nil, &bidMatchError{status: http.StatusNoContent, + reason: "candidate " + string(payload.Candidate) + " excluded by serve policy"} + } + + return payload, nil + } + + return nil, &bidMatchError{status: http.StatusNoContent, reason: "no cached payload for requested parent"} +} diff --git a/pkg/builderapi/legacy/get_header.go b/pkg/builderapi/legacy/get_header.go index f154ec9..d80d48a 100644 --- a/pkg/builderapi/legacy/get_header.go +++ b/pkg/builderapi/legacy/get_header.go @@ -130,23 +130,24 @@ func (h *Handler) HandleGetHeader(w http.ResponseWriter, r *http.Request) { return } - event := h.payloadCache.Get(slot) - if event == nil { - log.WithField("slot", slotU64).Info( - "getHeader: returning 204 — no cached payload for slot") - h.recordBid(slot, fork.String(), "", nil, 0, bidStatusFailed, "no cached payload for slot") - w.WriteHeader(http.StatusNoContent) - - return + // The slot may hold several candidate payloads (reorg preparedness): serve + // whichever one matches the requested parent hash. + var event *payload_builder.Payload + + for _, candidate := range h.payloadCache.GetSlotPayloads(slot) { + if candidate.Attributes.ParentBlockHash == parentHash { + event = candidate + break + } } - if event.Attributes.ParentBlockHash != parentHash { + + if event == nil { log.WithFields(logrus.Fields{ "slot": slotU64, "request_parent_hash": "0x" + hex.EncodeToString(parentHash[:]), - "cached_parent_hash": "0x" + hex.EncodeToString(event.Attributes.ParentBlockHash[:]), - }).Info("getHeader: returning 204 — cached payload parent hash does not match request") + }).Info("getHeader: returning 204 — no cached payload for requested parent") h.recordBid(slot, fork.String(), "", nil, 0, bidStatusFailed, - "cached payload parent hash does not match request") + "no cached payload for requested parent") w.WriteHeader(http.StatusNoContent) return diff --git a/pkg/builderapi/legacy/handler_test.go b/pkg/builderapi/legacy/handler_test.go index b2549c0..a09da89 100644 --- a/pkg/builderapi/legacy/handler_test.go +++ b/pkg/builderapi/legacy/handler_test.go @@ -71,6 +71,7 @@ func (m *stubChainService) GetEpochStats(phase0.Epoch) *chain.EpochStats { retur func (m *stubChainService) SubscribeEpochStats() *utils.Subscription[*chain.EpochStats] { return nil } func (m *stubChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return nil } +func (m *stubChainService) GetHeadTracker() *chain.HeadTracker { return nil } func (m *stubChainService) GetFinalizedEpoch() phase0.Epoch { return 0 } func (m *stubChainService) GetBuilderByIndex(uint64) *chain.BuilderInfo { return nil } diff --git a/pkg/builderapi/mockchain_test.go b/pkg/builderapi/mockchain_test.go index fe39c98..015093b 100644 --- a/pkg/builderapi/mockchain_test.go +++ b/pkg/builderapi/mockchain_test.go @@ -60,6 +60,7 @@ func (m *mockChainService) GetEpochStats(phase0.Epoch) *chain.EpochStats { retur func (m *mockChainService) SubscribeEpochStats() *utils.Subscription[*chain.EpochStats] { return nil } func (m *mockChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return nil } +func (m *mockChainService) GetHeadTracker() *chain.HeadTracker { return nil } func (m *mockChainService) GetFinalizedEpoch() phase0.Epoch { return 0 } func (m *mockChainService) GetBuilderByIndex(uint64) *chain.BuilderInfo { return nil } diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index ca5dad8..aae5c0f 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -150,6 +150,12 @@ func (s *Server) SetRevealService(rs *payload_bidder.RevealService) { s.epbs.SetRevealService(rs) } +// SetOnDemandBuilder wires the on-demand payload builder used by the +// post-Gloas dialect to serve bid requests for unbuilt (but legal) parents. +func (s *Server) SetOnDemandBuilder(builder epbsapi.OnDemandPayloadBuilder) { + s.epbs.SetOnDemandBuilder(builder) +} + // SetEventBroadcaster sets the optional event broadcaster for WebUI events on // both dialect handlers. func (s *Server) SetEventBroadcaster(b EventBroadcaster) { diff --git a/pkg/chain/headtracker.go b/pkg/chain/headtracker.go new file mode 100644 index 0000000..9b96fd6 --- /dev/null +++ b/pkg/chain/headtracker.go @@ -0,0 +1,815 @@ +package chain + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/spec/version" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" + "github.com/ethpandaops/buildoor/pkg/utils" +) + +// PayloadStatus is the reveal status of a Gloas block's committed execution +// payload. Pre-Gloas blocks embed their payload and are always Revealed. +type PayloadStatus string + +const ( + // PayloadStatusPending: no reveal evidence yet and the reveal deadline has + // not passed. + PayloadStatusPending PayloadStatus = "pending" + // PayloadStatusRevealed: the payload was seen (payload-available event or + // a child block built on it). + PayloadStatusRevealed PayloadStatus = "revealed" + // PayloadStatusEmpty: the payload is treated as withheld — either a child + // block built past it, or the reveal deadline passed without evidence + // (provisional until contrary evidence arrives). + PayloadStatusEmpty PayloadStatus = "empty" +) + +// CandidateKey identifies a build-parent candidate for a slot: which beacon +// block and which execution payload the build extends. "parent" and +// "grandparent" are positions on the canonical head chain at resolution time, +// so after missed slots they name the last existing blocks, not fixed slot +// offsets. +type CandidateKey string + +const ( + // CandidateParentFull: build on the head block and its committed payload. + CandidateParentFull CandidateKey = "parent_full" + // CandidateParentEmpty: build on the head block but on the execution + // payload it built upon (the head block's own payload treated as + // withheld). Gloas+ only. + CandidateParentEmpty CandidateKey = "parent_empty" + // CandidateGrandparentFull: build on the head block's parent and its + // committed payload (a deliberate reorg of the head block). + CandidateGrandparentFull CandidateKey = "grandparent_full" + // CandidateGrandparentEmpty: build on the head block's parent but on the + // payload it built upon (reorg of the head block combined with the + // grandparent's payload treated as withheld). Gloas+ only. + CandidateGrandparentEmpty CandidateKey = "grandparent_empty" +) + +// AllCandidateKeys lists every candidate key in canonical priority order. +var AllCandidateKeys = []CandidateKey{ + CandidateParentFull, + CandidateParentEmpty, + CandidateGrandparentFull, + CandidateGrandparentEmpty, +} + +// IsValidCandidateKey reports whether the given string names a candidate key. +func IsValidCandidateKey(key string) bool { + switch CandidateKey(key) { + case CandidateParentFull, CandidateParentEmpty, + CandidateGrandparentFull, CandidateGrandparentEmpty: + return true + default: + return false + } +} + +// CandidateParent is one resolved build-parent candidate for a slot. +type CandidateParent struct { + Key CandidateKey + ParentBlockRoot phase0.Root + ParentSlot phase0.Slot + ParentBlockHash phase0.Hash32 + // ELParentGasLimit is the gas limit of the execution block identified by + // ParentBlockHash (0 = unknown). Committed gas limits come from the bid + // (Gloas) or the embedded payload (pre-Gloas). + ELParentGasLimit uint64 + // ELParentNumber is the block number of the execution block identified by + // ParentBlockHash (0 = unknown; Gloas blocks require the revealed + // envelope to learn it). + ELParentNumber uint64 + // ParentPayloadStatus is the reveal status of the beacon parent block's + // own committed payload. Full candidates are only viable when it is (or + // becomes) revealed; empty candidates when it stays withheld. + ParentPayloadStatus PayloadStatus +} + +// HeadChangeEvent is fired for every accepted head switch. ReorgDepth is 0 for +// a normal chain extension; for a reorg it is the number of slots between the +// old head and the common ancestor. CommonAncestor is zero when the fork point +// is deeper than the scan window. +type HeadChangeEvent struct { + Old *beacon.BlockInfo // nil on the first observed head + New *beacon.BlockInfo + ReorgDepth uint64 + CommonAncestor phase0.Root +} + +const ( + // headBlockRetentionSlots is how many slots of ancestry blocks (and their + // payload evidence) the tracker retains. + headBlockRetentionSlots = 64 + // reorgScanDepthSlots bounds the ancestor walk used to locate the common + // ancestor of the old and new head on a reorg. + reorgScanDepthSlots = 16 + // headFetchTimeout bounds individual beacon-API block/envelope fetches. + headFetchTimeout = 5 * time.Second +) + +// elBlockMeta is envelope-derived metadata of a revealed execution block. +type elBlockMeta struct { + number uint64 + gasLimit uint64 +} + +// HeadTracker maintains buildoor's own view of the canonical chain: the +// current head, a bounded block-by-root ancestry cache, and per-block payload +// reveal status (Gloas). It is the validation oracle for payload-attributes +// sanitization and the source of build-parent candidates. +type HeadTracker struct { + clClient *beacon.Client + chainSpec *ChainSpec + genesis *beacon.Genesis + log logrus.FieldLogger + + mu sync.RWMutex + head *beacon.BlockInfo + finality *beacon.FinalityInfo + finalityHead phase0.Root + blocks map[phase0.Root]*beacon.BlockInfo + payloadRevealed map[phase0.Root]bool + payloadEmpty map[phase0.Root]bool + elMeta map[phase0.Hash32]*elBlockMeta + + headChangeDispatcher *utils.Dispatcher[*HeadChangeEvent] + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewHeadTracker creates a new head tracker. +func NewHeadTracker( + clClient *beacon.Client, + chainSpec *ChainSpec, + genesis *beacon.Genesis, + log logrus.FieldLogger, +) *HeadTracker { + return &HeadTracker{ + clClient: clClient, + chainSpec: chainSpec, + genesis: genesis, + log: log.WithField("component", "head-tracker"), + blocks: make(map[phase0.Root]*beacon.BlockInfo, headBlockRetentionSlots), + payloadRevealed: make(map[phase0.Root]bool, headBlockRetentionSlots), + payloadEmpty: make(map[phase0.Root]bool, headBlockRetentionSlots), + elMeta: make(map[phase0.Hash32]*elBlockMeta, headBlockRetentionSlots), + headChangeDispatcher: &utils.Dispatcher[*HeadChangeEvent]{}, + } +} + +// Start starts the head tracker's event loop. +func (h *HeadTracker) Start(ctx context.Context) { + h.ctx, h.cancel = context.WithCancel(ctx) + + h.wg.Add(1) + + go h.run() +} + +// Stop stops the head tracker and waits for its loop to exit. +func (h *HeadTracker) Stop() { + if h.cancel != nil { + h.cancel() + } + + h.wg.Wait() +} + +// SubscribeHeadChanges returns a subscription for head switch events +// (including reorgs). +func (h *HeadTracker) SubscribeHeadChanges() *utils.Subscription[*HeadChangeEvent] { + return h.headChangeDispatcher.Subscribe(16, false) +} + +// run processes head, payload-available and chain_reorg events. +func (h *HeadTracker) run() { + defer h.wg.Done() + + headSub := h.clClient.Events().SubscribeHead() + payloadSub := h.clClient.Events().SubscribePayloadAvailable() + reorgSub := h.clClient.Events().SubscribeChainReorgs() + + defer headSub.Unsubscribe() + defer payloadSub.Unsubscribe() + defer reorgSub.Unsubscribe() + + for { + select { + case <-h.ctx.Done(): + return + case event := <-headSub.Channel(): + h.processHead(event) + case event := <-payloadSub.Channel(): + h.markPayloadRevealed(event.BlockRoot) + h.prefetchELMeta(event.BlockRoot) + case event := <-reorgSub.Channel(): + h.log.WithFields(logrus.Fields{ + "slot": event.Slot, + "depth": event.Depth, + "old_head": fmt.Sprintf("%#x", event.OldHeadBlock[:8]), + "new_head": fmt.Sprintf("%#x", event.NewHeadBlock[:8]), + }).Info("Beacon node reported chain reorg") + } + } +} + +// FinalityInfo returns the finality checkpoint execution hashes cached for +// the current head (nil until the first refresh completes). Refreshed once +// per head change, so build paths never pay the beacon-API round trips. +func (h *HeadTracker) FinalityInfo() *beacon.FinalityInfo { + h.mu.RLock() + defer h.mu.RUnlock() + + return h.finality +} + +// refreshFinality re-resolves the finality info for the given head, unless it +// is already cached for it. Runs in the tracker's event loop, off every build +// hot path. +func (h *HeadTracker) refreshFinality(headRoot phase0.Root) { + if h.clClient == nil { + return + } + + h.mu.RLock() + upToDate := h.finalityHead == headRoot && h.finality != nil + h.mu.RUnlock() + + if upToDate { + return + } + + ctx, cancel := context.WithTimeout(h.ctx, headFetchTimeout) + defer cancel() + + info, err := h.clClient.GetFinalityInfo(ctx) + if err != nil { + h.log.WithError(err).Debug("Failed to refresh finality info") + return + } + + h.mu.Lock() + h.finality = info + h.finalityHead = headRoot + h.mu.Unlock() +} + +// CurrentHead returns the most recently observed head block (nil until the +// first head event resolves). +func (h *HeadTracker) CurrentHead() *beacon.BlockInfo { + h.mu.RLock() + defer h.mu.RUnlock() + + return h.head +} + +// IsCanonical reports whether the given root is on the current head's +// ancestry within the retention window. +func (h *HeadTracker) IsCanonical(ctx context.Context, root phase0.Root) bool { + head := h.CurrentHead() + if head == nil { + return false + } + + cursor := head + + for { + if cursor.Root == root { + return true + } + + if cursor.Slot == 0 || head.Slot-cursor.Slot >= headBlockRetentionSlots { + return false + } + + parent, err := h.GetBlock(ctx, cursor.ParentRoot) + if err != nil { + return false + } + + cursor = parent + } +} + +// PrimeBlock contributes an already-resolved block to the shared ancestry +// cache (also derives payload-status evidence for its parent). +func (h *HeadTracker) PrimeBlock(info *beacon.BlockInfo) { + h.storeBlock(info) +} + +// PrimeHead seeds the tracker with an already-resolved head block (cached and +// adopted as current head unless an equal-or-newer head is known). Live head +// events take over from the first processed event. +func (h *HeadTracker) PrimeHead(info *beacon.BlockInfo) { + h.storeBlock(info) + + h.mu.Lock() + defer h.mu.Unlock() + + if h.head == nil || info.Slot >= h.head.Slot { + h.head = info + } +} + +// LookupBlock returns a cached block by root without ever fetching, for +// callers that must not block (UI event assembly, hot paths). +func (h *HeadTracker) LookupBlock(root phase0.Root) (*beacon.BlockInfo, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + + info, ok := h.blocks[root] + + return info, ok +} + +// GetBlock resolves a block by root through the shared ancestry cache, +// fetching from the beacon node on a miss. +func (h *HeadTracker) GetBlock(ctx context.Context, root phase0.Root) (*beacon.BlockInfo, error) { + h.mu.RLock() + info, ok := h.blocks[root] + h.mu.RUnlock() + + if ok { + return info, nil + } + + if h.clClient == nil { + return nil, fmt.Errorf("block %#x not cached and no beacon client available", root) + } + + fetchCtx, cancel := context.WithTimeout(ctx, headFetchTimeout) + defer cancel() + + info, err := h.clClient.GetBlockInfo(fetchCtx, fmt.Sprintf("%#x", root)) + if err != nil { + return nil, fmt.Errorf("failed to resolve block %#x: %w", root, err) + } + + h.storeBlock(info) + + return info, nil +} + +// storeBlock caches a block and derives payload-status evidence for its +// parent from the execution parent hash the block committed to. +func (h *HeadTracker) storeBlock(info *beacon.BlockInfo) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, exists := h.blocks[info.Root]; exists { + return + } + + h.blocks[info.Root] = info + + if !h.isGloasSlot(info.Slot) { + return + } + + parent, ok := h.blocks[info.ParentRoot] + if !ok || !h.isGloasSlot(parent.Slot) { + return + } + + // The block's committed execution parent (bid parent_block_hash) proves + // whether its beacon parent's payload made it onto the EL chain: matching + // the parent's committed payload hash means the parent was full, matching + // the parent's own execution parent means the chain built past a withheld + // payload. + switch info.FinalitySafeExecutionBlockHash { + case parent.ExecutionBlockHash: + h.payloadRevealed[parent.Root] = true + case parent.FinalitySafeExecutionBlockHash: + h.payloadEmpty[parent.Root] = true + } +} + +// markPayloadRevealed records an execution_payload_available event. +func (h *HeadTracker) markPayloadRevealed(root phase0.Root) { + h.mu.Lock() + defer h.mu.Unlock() + + h.payloadRevealed[root] = true + delete(h.payloadEmpty, root) +} + +// GetPayloadStatus returns the reveal status of the block's committed payload. +// Unknown blocks report Pending. Pre-Gloas blocks are always Revealed (the +// payload is embedded in the block). +func (h *HeadTracker) GetPayloadStatus(root phase0.Root) PayloadStatus { + h.mu.RLock() + defer h.mu.RUnlock() + + info, ok := h.blocks[root] + if !ok { + return PayloadStatusPending + } + + return h.payloadStatusLocked(info) +} + +// payloadStatusLocked derives the payload status for a cached block. Callers +// must hold at least a read lock. +func (h *HeadTracker) payloadStatusLocked(info *beacon.BlockInfo) PayloadStatus { + if !h.isGloasSlot(info.Slot) { + return PayloadStatusRevealed + } + + if h.payloadRevealed[info.Root] { + return PayloadStatusRevealed + } + + if h.payloadEmpty[info.Root] { + return PayloadStatusEmpty + } + + // Past the reveal deadline without any evidence the payload is treated as + // withheld; a late reveal or child-block evidence still flips it back. + if time.Now().After(h.payloadDueTime(info.Slot)) { + return PayloadStatusEmpty + } + + return PayloadStatusPending +} + +// payloadDueTime returns the wall-clock payload reveal deadline of a slot. +func (h *HeadTracker) payloadDueTime(slot phase0.Slot) time.Time { + slotStart := h.genesis.GenesisTime.Add(time.Duration(uint64(slot)) * h.chainSpec.SecondsPerSlot) + dueOffset := h.chainSpec.SecondsPerSlot * time.Duration(h.chainSpec.PayloadDueBps) / 10000 + + return slotStart.Add(dueOffset) +} + +// isGloasSlot reports whether the Gloas fork is active at the given slot. +func (h *HeadTracker) isGloasSlot(slot phase0.Slot) bool { + epoch := phase0.Epoch(uint64(slot) / h.chainSpec.SlotsPerEpoch) + + return h.chainSpec.IsForkActive(version.DataVersionGloas, epoch) +} + +// processHead resolves a head event's block, detects reorgs against the +// previous head and fires a HeadChangeEvent. +func (h *HeadTracker) processHead(event *beacon.HeadEvent) { + newHead, err := h.GetBlock(h.ctx, event.Block) + if err != nil { + h.log.WithError(err).WithField("slot", event.Slot).Debug("Failed to resolve head block") + return + } + + h.mu.Lock() + oldHead := h.head + + if oldHead != nil && oldHead.Root == newHead.Root { + h.mu.Unlock() + return + } + + h.head = newHead + h.mu.Unlock() + + change := &HeadChangeEvent{ + Old: oldHead, + New: newHead, + } + + if oldHead != nil && newHead.ParentRoot != oldHead.Root { + change.ReorgDepth, change.CommonAncestor = h.resolveReorg(oldHead, newHead) + + h.log.WithFields(logrus.Fields{ + "old_head": fmt.Sprintf("%#x", oldHead.Root[:8]), + "new_head": fmt.Sprintf("%#x", newHead.Root[:8]), + "old_slot": oldHead.Slot, + "new_slot": newHead.Slot, + "depth": change.ReorgDepth, + }).Info("Head switched to a non-child block (reorg)") + } + + h.prune(newHead.Slot) + h.refreshFinality(newHead.Root) + + h.headChangeDispatcher.Fire(change) +} + +// resolveReorg locates the common ancestor of the old and new head within the +// scan window. Returns the reorg depth (slots the old chain lost) and the +// ancestor root; depth falls back to the scan window and the root stays zero +// when the fork point is deeper. +func (h *HeadTracker) resolveReorg(oldHead, newHead *beacon.BlockInfo) (uint64, phase0.Root) { + oldChain := make(map[phase0.Root]phase0.Slot, reorgScanDepthSlots) + + cursor := oldHead + for range reorgScanDepthSlots { + oldChain[cursor.Root] = cursor.Slot + + if cursor.Slot == 0 { + break + } + + parent, err := h.GetBlock(h.ctx, cursor.ParentRoot) + if err != nil { + break + } + + cursor = parent + } + + cursor = newHead + for range reorgScanDepthSlots { + if ancestorSlot, ok := oldChain[cursor.Root]; ok { + return uint64(oldHead.Slot - ancestorSlot), cursor.Root + } + + if cursor.Slot == 0 { + break + } + + parent, err := h.GetBlock(h.ctx, cursor.ParentRoot) + if err != nil { + break + } + + cursor = parent + } + + return reorgScanDepthSlots, phase0.Root{} +} + +// prune drops blocks and payload evidence outside the retention window. +func (h *HeadTracker) prune(headSlot phase0.Slot) { + if headSlot <= headBlockRetentionSlots { + return + } + + minSlot := headSlot - headBlockRetentionSlots + + h.mu.Lock() + defer h.mu.Unlock() + + for root, info := range h.blocks { + if info.Slot >= minSlot { + continue + } + + delete(h.blocks, root) + delete(h.payloadRevealed, root) + delete(h.payloadEmpty, root) + delete(h.elMeta, info.ExecutionBlockHash) + delete(h.elMeta, info.FinalitySafeExecutionBlockHash) + } +} + +// ResolveCandidates derives the build-parent candidates for the given slot +// from the current head chain. The head must be older than the target slot. +// Empty variants are only produced under Gloas; a variant whose parent tuple +// duplicates another is dropped. +func (h *HeadTracker) ResolveCandidates(ctx context.Context, slot phase0.Slot) ([]*CandidateParent, error) { + parent := h.CurrentHead() + if parent == nil { + return nil, fmt.Errorf("no head observed yet") + } + + if parent.Slot >= slot { + return nil, fmt.Errorf("head slot %d is not below target slot %d", parent.Slot, slot) + } + + candidates := make([]*CandidateParent, 0, len(AllCandidateKeys)) + gloas := h.isGloasSlot(parent.Slot) + + parentStatus := h.GetPayloadStatus(parent.Root) + + candidates = append(candidates, h.buildCandidate( + ctx, CandidateParentFull, parent, parent.ExecutionBlockHash, parentStatus)) + + if gloas && parent.FinalitySafeExecutionBlockHash != parent.ExecutionBlockHash { + candidates = append(candidates, h.buildCandidate( + ctx, CandidateParentEmpty, parent, parent.FinalitySafeExecutionBlockHash, parentStatus)) + } + + if parent.Slot > 0 { + grandparent, err := h.GetBlock(ctx, parent.ParentRoot) + if err != nil { + h.log.WithError(err).Debug("Failed to resolve grandparent block for candidates") + } else { + gpStatus := h.GetPayloadStatus(grandparent.Root) + + candidates = append(candidates, h.buildCandidate( + ctx, CandidateGrandparentFull, grandparent, grandparent.ExecutionBlockHash, gpStatus)) + + if h.isGloasSlot(grandparent.Slot) && + grandparent.FinalitySafeExecutionBlockHash != grandparent.ExecutionBlockHash { + candidates = append(candidates, h.buildCandidate( + ctx, CandidateGrandparentEmpty, grandparent, + grandparent.FinalitySafeExecutionBlockHash, gpStatus)) + } + } + } + + return candidates, nil +} + +// buildCandidate assembles one candidate tuple, resolving the EL parent's gas +// limit and block number on a best-effort basis. +func (h *HeadTracker) buildCandidate( + ctx context.Context, + key CandidateKey, + parent *beacon.BlockInfo, + elParentHash phase0.Hash32, + parentStatus PayloadStatus, +) *CandidateParent { + candidate := &CandidateParent{ + Key: key, + ParentBlockRoot: parent.Root, + ParentSlot: parent.Slot, + ParentBlockHash: elParentHash, + ParentPayloadStatus: parentStatus, + } + + // Cache-only: candidate resolution runs on the build hot path and must + // never block on a beacon-API round trip. + committer := h.findCommitterOfExecHash(ctx, parent, elParentHash) + if committer != nil { + candidate.ELParentGasLimit = committer.GasLimit + candidate.ELParentNumber = committer.ExecutionBlockNumber + } + + if candidate.ELParentNumber == 0 { + h.mu.RLock() + meta := h.elMeta[elParentHash] + h.mu.RUnlock() + + if meta != nil { + candidate.ELParentNumber = meta.number + + if candidate.ELParentGasLimit == 0 { + candidate.ELParentGasLimit = meta.gasLimit + } + } + } + + return candidate +} + +// LookupELParentMeta returns the block number and gas limit of the execution +// block identified by execHash from already-known data only: the committing +// beacon block's own fields and the envelope-metadata cache. It never +// performs a beacon-API fetch, so build hot paths never block on one +// (payload-available events prefetch the metadata in the background). +func (h *HeadTracker) LookupELParentMeta( + ctx context.Context, fromRoot phase0.Root, execHash phase0.Hash32, +) (number, gasLimit uint64) { + h.mu.RLock() + from, cached := h.blocks[fromRoot] + meta := h.elMeta[execHash] + h.mu.RUnlock() + + if meta != nil { + return meta.number, meta.gasLimit + } + + if !cached { + return 0, 0 + } + + if committer := h.findCommitterOfExecHash(ctx, from, execHash); committer != nil { + return committer.ExecutionBlockNumber, committer.GasLimit + } + + return 0, 0 +} + +// prefetchELMeta resolves and caches the envelope metadata of a revealed +// payload in the background, so later build passes find it cached instead of +// blocking on a beacon-API fetch. +func (h *HeadTracker) prefetchELMeta(root phase0.Root) { + if h.clClient == nil { + return + } + + go func() { + ctx, cancel := context.WithTimeout(h.ctx, headFetchTimeout) + defer cancel() + + block, err := h.GetBlock(ctx, root) + if err != nil { + return + } + + h.resolveELMeta(ctx, block, block.ExecutionBlockHash) + }() +} + +// ResolveELParentMeta resolves the block number and gas limit of the +// execution block identified by execHash, walking the beacon ancestry from +// fromRoot to find the block that committed it (best-effort; zeros when +// unknown). May fetch from the beacon node — do not call from build hot +// paths; use LookupELParentMeta there. +func (h *HeadTracker) ResolveELParentMeta( + ctx context.Context, fromRoot phase0.Root, execHash phase0.Hash32, +) (number, gasLimit uint64) { + from, err := h.GetBlock(ctx, fromRoot) + if err != nil { + return 0, 0 + } + + committer := h.findCommitterOfExecHash(ctx, from, execHash) + if committer == nil { + return 0, 0 + } + + number = committer.ExecutionBlockNumber + gasLimit = committer.GasLimit + + if number == 0 { + if meta := h.resolveELMeta(ctx, committer, execHash); meta != nil { + number = meta.number + + if gasLimit == 0 { + gasLimit = meta.gasLimit + } + } + } + + return number, gasLimit +} + +// findCommitterOfExecHash walks the ancestry from the given block looking for +// the beacon block that committed the execution block with the given hash. +func (h *HeadTracker) findCommitterOfExecHash( + ctx context.Context, from *beacon.BlockInfo, execHash phase0.Hash32, +) *beacon.BlockInfo { + cursor := from + + for range reorgScanDepthSlots { + if cursor.ExecutionBlockHash == execHash { + return cursor + } + + if cursor.Slot == 0 { + return nil + } + + parent, err := h.GetBlock(ctx, cursor.ParentRoot) + if err != nil { + return nil + } + + cursor = parent + } + + return nil +} + +// resolveELMeta fetches the revealed envelope of the block that committed the +// given execution block hash to learn the EL block number (and gas limit). +// Returns nil when the envelope is unavailable (e.g. withheld payload). +func (h *HeadTracker) resolveELMeta( + ctx context.Context, committer *beacon.BlockInfo, execHash phase0.Hash32, +) *elBlockMeta { + h.mu.RLock() + meta, ok := h.elMeta[execHash] + h.mu.RUnlock() + + if ok { + return meta + } + + if h.clClient == nil { + return nil + } + + fetchCtx, cancel := context.WithTimeout(ctx, headFetchTimeout) + defer cancel() + + envelope, err := h.clClient.GetExecutionPayloadEnvelope(fetchCtx, fmt.Sprintf("%#x", committer.Root)) + if err != nil { + h.log.WithError(err).WithField("root", fmt.Sprintf("%#x", committer.Root)). + Debug("Failed to fetch payload envelope for EL metadata") + return nil + } + + payload := envelope.Message.Payload + if payload.BlockHash != execHash { + return nil + } + + meta = &elBlockMeta{ + number: payload.BlockNumber, + gasLimit: payload.GasLimit, + } + + h.mu.Lock() + h.elMeta[execHash] = meta + h.mu.Unlock() + + return meta +} diff --git a/pkg/chain/headtracker_test.go b/pkg/chain/headtracker_test.go new file mode 100644 index 0000000..37e5aa5 --- /dev/null +++ b/pkg/chain/headtracker_test.go @@ -0,0 +1,212 @@ +package chain + +import ( + "context" + "testing" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/spec/version" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" +) + +// newTestHeadTracker creates an offline head tracker (no beacon client; cache +// misses error out) on a Gloas-from-genesis chain spec. +func newTestHeadTracker(genesisTime time.Time, fork version.DataVersion) *HeadTracker { + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + spec := &ChainSpec{ + SecondsPerSlot: 12 * time.Second, + SlotsPerEpoch: 32, + PayloadDueBps: 5000, + ForkSchedule: []ForkSchedule{ + {Fork: fork, Version: phase0.Version{0x01}, Epoch: 0}, + }, + } + + return NewHeadTracker(nil, spec, &beacon.Genesis{GenesisTime: genesisTime}, log) +} + +// testChain builds a small Gloas chain: grandparent (slot 3) on E2, parent +// (slot 4) committing E4 on top of the grandparent's E3. +func testChain() (gp, parent *beacon.BlockInfo) { + gp = &beacon.BlockInfo{ + Slot: 3, + Root: phase0.Root{0x03}, + ParentRoot: phase0.Root{0x02}, + ExecutionBlockHash: phase0.Hash32{0xe3}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe2}, + GasLimit: 30_000_000, + } + parent = &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ParentRoot: gp.Root, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: gp.ExecutionBlockHash, + GasLimit: 31_000_000, + } + + return gp, parent +} + +func TestHeadTracker_PayloadStatusEvidence(t *testing.T) { + // Genesis far in the past: every slot's reveal deadline has passed, so + // blocks without evidence resolve to empty. + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionGloas) + gp, parent := testChain() + tracker.PrimeBlock(gp) + tracker.PrimeBlock(parent) + + // Priming the parent derived full-evidence for the grandparent (the + // parent committed to the grandparent's payload hash). + assert.Equal(t, PayloadStatusRevealed, tracker.GetPayloadStatus(gp.Root)) + + // No evidence for the parent and past the deadline: provisional empty. + assert.Equal(t, PayloadStatusEmpty, tracker.GetPayloadStatus(parent.Root)) + + // A child that built past the parent's payload marks it empty explicitly. + childEmpty := &beacon.BlockInfo{ + Slot: 5, + Root: phase0.Root{0x05}, + ParentRoot: parent.Root, + ExecutionBlockHash: phase0.Hash32{0xe5}, + FinalitySafeExecutionBlockHash: parent.FinalitySafeExecutionBlockHash, + } + tracker.PrimeBlock(childEmpty) + assert.Equal(t, PayloadStatusEmpty, tracker.GetPayloadStatus(parent.Root)) + + // A payload-available event flips it to revealed and clears the empty + // evidence. + tracker.markPayloadRevealed(parent.Root) + assert.Equal(t, PayloadStatusRevealed, tracker.GetPayloadStatus(parent.Root)) + + // Unknown blocks report pending. + assert.Equal(t, PayloadStatusPending, tracker.GetPayloadStatus(phase0.Root{0xff})) +} + +func TestHeadTracker_PayloadStatusTiming(t *testing.T) { + // Genesis now: slot 0 is in progress, its reveal deadline (50%) is ahead. + tracker := newTestHeadTracker(time.Now(), version.DataVersionGloas) + block := &beacon.BlockInfo{ + Slot: 0, + Root: phase0.Root{0x01}, + ExecutionBlockHash: phase0.Hash32{0xe1}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe0}, + } + tracker.PrimeBlock(block) + + assert.Equal(t, PayloadStatusPending, tracker.GetPayloadStatus(block.Root)) +} + +func TestHeadTracker_PayloadStatusPreGloas(t *testing.T) { + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionElectra) + block := &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ExecutionBlockHash: phase0.Hash32{0xe4}, + } + tracker.PrimeBlock(block) + + // Pre-Gloas payloads are embedded in the block and always revealed. + assert.Equal(t, PayloadStatusRevealed, tracker.GetPayloadStatus(block.Root)) +} + +func TestHeadTracker_ResolveCandidatesGloas(t *testing.T) { + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionGloas) + gp, parent := testChain() + tracker.PrimeBlock(gp) + tracker.PrimeBlock(parent) + tracker.head = parent + + candidates, err := tracker.ResolveCandidates(context.Background(), 5) + require.NoError(t, err) + + byKey := make(map[CandidateKey]*CandidateParent, len(candidates)) + for _, c := range candidates { + byKey[c.Key] = c + } + + require.Contains(t, byKey, CandidateParentFull) + assert.Equal(t, parent.Root, byKey[CandidateParentFull].ParentBlockRoot) + assert.Equal(t, parent.ExecutionBlockHash, byKey[CandidateParentFull].ParentBlockHash) + assert.Equal(t, parent.GasLimit, byKey[CandidateParentFull].ELParentGasLimit) + + require.Contains(t, byKey, CandidateParentEmpty) + assert.Equal(t, parent.Root, byKey[CandidateParentEmpty].ParentBlockRoot) + assert.Equal(t, gp.ExecutionBlockHash, byKey[CandidateParentEmpty].ParentBlockHash) + assert.Equal(t, gp.GasLimit, byKey[CandidateParentEmpty].ELParentGasLimit, + "empty-parent EL gas limit must come from the grandparent's committed payload") + + require.Contains(t, byKey, CandidateGrandparentFull) + assert.Equal(t, gp.Root, byKey[CandidateGrandparentFull].ParentBlockRoot) + assert.Equal(t, gp.ExecutionBlockHash, byKey[CandidateGrandparentFull].ParentBlockHash) + + require.Contains(t, byKey, CandidateGrandparentEmpty) + assert.Equal(t, gp.Root, byKey[CandidateGrandparentEmpty].ParentBlockRoot) + assert.Equal(t, gp.FinalitySafeExecutionBlockHash, byKey[CandidateGrandparentEmpty].ParentBlockHash) +} + +func TestHeadTracker_ResolveCandidatesPreGloas(t *testing.T) { + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionElectra) + + // Pre-Gloas blocks: the committed and finality-safe hashes are identical + // (payload embedded), so no empty variants exist. + gp := &beacon.BlockInfo{ + Slot: 3, Root: phase0.Root{0x03}, ParentRoot: phase0.Root{0x02}, + ExecutionBlockHash: phase0.Hash32{0xe3}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe3}, + } + parent := &beacon.BlockInfo{ + Slot: 4, Root: phase0.Root{0x04}, ParentRoot: gp.Root, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe4}, + } + tracker.PrimeBlock(gp) + tracker.PrimeBlock(parent) + tracker.head = parent + + candidates, err := tracker.ResolveCandidates(context.Background(), 5) + require.NoError(t, err) + + keys := make([]CandidateKey, 0, len(candidates)) + for _, c := range candidates { + keys = append(keys, c.Key) + } + + assert.ElementsMatch(t, []CandidateKey{CandidateParentFull, CandidateGrandparentFull}, keys) +} + +func TestHeadTracker_ResolveCandidatesHeadNotBelowSlot(t *testing.T) { + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionGloas) + _, parent := testChain() + tracker.PrimeBlock(parent) + tracker.head = parent + + _, err := tracker.ResolveCandidates(context.Background(), parent.Slot) + assert.Error(t, err) +} + +func TestHeadTracker_ResolveReorg(t *testing.T) { + tracker := newTestHeadTracker(time.Now().Add(-time.Hour), version.DataVersionGloas) + gp, parent := testChain() + competing := &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x44}, + ParentRoot: gp.Root, + ExecutionBlockHash: phase0.Hash32{0xee}, + FinalitySafeExecutionBlockHash: gp.ExecutionBlockHash, + } + tracker.PrimeBlock(gp) + tracker.PrimeBlock(parent) + tracker.PrimeBlock(competing) + + depth, ancestor := tracker.resolveReorg(parent, competing) + assert.Equal(t, uint64(1), depth) + assert.Equal(t, gp.Root, ancestor) +} diff --git a/pkg/chain/headvotes_test.go b/pkg/chain/headvotes_test.go index 96dcbfe..d6ffaca 100644 --- a/pkg/chain/headvotes_test.go +++ b/pkg/chain/headvotes_test.go @@ -60,6 +60,7 @@ func (s *stubChainService) SubscribeEpochStats() *utils.Subscription[*EpochStats return nil } func (s *stubChainService) GetHeadVoteTracker() *HeadVoteTracker { return nil } +func (s *stubChainService) GetHeadTracker() *HeadTracker { return nil } func (s *stubChainService) GetFinalizedEpoch() phase0.Epoch { return 0 } func (s *stubChainService) GetBuilderByIndex(_ uint64) *BuilderInfo { return nil diff --git a/pkg/chain/service.go b/pkg/chain/service.go index 7adf7e6..80d15bd 100644 --- a/pkg/chain/service.go +++ b/pkg/chain/service.go @@ -42,6 +42,9 @@ type Service interface { // Head vote tracking GetHeadVoteTracker() *HeadVoteTracker + // Canonical head / ancestry / payload-status view + GetHeadTracker() *HeadTracker + // Finality GetFinalizedEpoch() phase0.Epoch @@ -74,9 +77,17 @@ type service struct { currentEpoch phase0.Epoch cacheMu sync.RWMutex + // Duty dependent root each cached epoch's state was fetched under. A head + // event whose dependent root differs proves the cached state came from a + // branch that was reorged across the epoch boundary and must be refetched. + epochDependentRoots map[phase0.Epoch]phase0.Root + // Head vote tracking headVoteTracker *HeadVoteTracker + // Canonical head / ancestry / payload-status view + headTracker *HeadTracker + // Event dispatching epochStatsDispatcher *utils.Dispatcher[*EpochStats] @@ -101,6 +112,7 @@ func NewService( genesis: genesis, log: log.WithField("component", "chain-service"), stateCache: make(map[phase0.Epoch]*EpochStats, 2), + epochDependentRoots: make(map[phase0.Epoch]phase0.Root, 2), epochStatsDispatcher: &utils.Dispatcher[*EpochStats]{}, } } @@ -118,6 +130,10 @@ func (s *service) Start(ctx context.Context) error { s.headVoteTracker = NewHeadVoteTracker(s.cfg, s, s.clClient, s.log) s.headVoteTracker.Start(s.ctx) + // Start canonical head tracker + s.headTracker = NewHeadTracker(s.clClient, s.chainSpec, s.genesis, s.log) + s.headTracker.Start(s.ctx) + // Subscribe to head events to detect epoch transitions s.wg.Add(1) go s.runEpochMonitor() @@ -135,6 +151,10 @@ func (s *service) Stop() error { s.headVoteTracker.Stop() } + if s.headTracker != nil { + s.headTracker.Stop() + } + if s.cancel != nil { s.cancel() } @@ -291,17 +311,26 @@ func (s *service) GetHeadVoteTracker() *HeadVoteTracker { return s.headVoteTracker } +// GetHeadTracker returns the canonical head tracker. +func (s *service) GetHeadTracker() *HeadTracker { + return s.headTracker +} + // RefreshBuilders re-fetches the head state to pick up new builder registrations. func (s *service) RefreshBuilders(ctx context.Context) error { s.log.Debug("Refreshing builders from head state") - stats, err := s.fetchEpochStats(ctx, "head", s.currentEpoch) + s.cacheMu.RLock() + epoch := s.currentEpoch + s.cacheMu.RUnlock() + + stats, err := s.fetchEpochStats(ctx, "head", epoch) if err != nil { return fmt.Errorf("failed to refresh builders: %w", err) } s.cacheMu.Lock() - s.stateCache[s.currentEpoch] = stats + s.stateCache[epoch] = stats s.cacheMu.Unlock() return nil @@ -326,17 +355,22 @@ func (s *service) runEpochMonitor() { } } -// handleHeadEvent checks if a head event represents a new epoch and fetches state. +// handleHeadEvent checks if a head event represents a new epoch and fetches +// state. For already-cached epochs it verifies the event's duty dependent +// root against the one the cached state was fetched under: a mismatch means +// a reorg crossed the epoch boundary and the cached state (duties, builders, +// randao) belongs to an orphaned branch, so it is refetched and re-fired. func (s *service) handleHeadEvent(event *beacon.HeadEvent) { newEpoch := phase0.Epoch(uint64(event.Slot) / s.chainSpec.SlotsPerEpoch) s.cacheMu.RLock() currentEpoch := s.currentEpoch _, alreadyCached := s.stateCache[newEpoch] + knownDependentRoot, hasDependentRoot := s.epochDependentRoots[newEpoch] s.cacheMu.RUnlock() - // Only fetch state when we enter a new epoch that hasn't been cached yet if newEpoch <= currentEpoch || alreadyCached { + s.checkDependentRoot(event, newEpoch, alreadyCached, knownDependentRoot, hasDependentRoot) return } @@ -352,14 +386,17 @@ func (s *service) handleHeadEvent(event *beacon.HeadEvent) { return } - // Update current epoch and evict old states + // Update current epoch, record the dependent root the state was fetched + // under, and evict old states s.cacheMu.Lock() s.currentEpoch = newEpoch + s.epochDependentRoots[newEpoch] = event.CurrentDutyDependentRoot // Keep only last 2 epochs for epoch := range s.stateCache { if epoch < newEpoch-1 { delete(s.stateCache, epoch) + delete(s.epochDependentRoots, epoch) } } s.cacheMu.Unlock() @@ -371,6 +408,57 @@ func (s *service) handleHeadEvent(event *beacon.HeadEvent) { } } +// checkDependentRoot refetches a cached epoch's state when the head event +// proves it was derived from a branch that has since been reorged out. +func (s *service) checkDependentRoot( + event *beacon.HeadEvent, + epoch phase0.Epoch, + cached bool, + knownRoot phase0.Root, + hasRoot bool, +) { + if !cached || event.CurrentDutyDependentRoot == (phase0.Root{}) { + return + } + + // The initial startup fetch has no head event, so the first event of an + // epoch adopts its dependent root without a refetch. + if !hasRoot { + s.cacheMu.Lock() + if _, exists := s.epochDependentRoots[epoch]; !exists { + s.epochDependentRoots[epoch] = event.CurrentDutyDependentRoot + } + s.cacheMu.Unlock() + + return + } + + if knownRoot == event.CurrentDutyDependentRoot { + return + } + + s.log.WithFields(logrus.Fields{ + "epoch": epoch, + "old_root": fmt.Sprintf("%#x", knownRoot[:8]), + "new_root": fmt.Sprintf("%#x", event.CurrentDutyDependentRoot[:8]), + }).Warn("Epoch dependent root changed (reorg across epoch boundary), refetching state") + + stateID := fmt.Sprintf("%d", event.Slot) + if err := s.fetchAndCacheEpochState(s.ctx, stateID, epoch); err != nil { + s.log.WithError(err).Error("Failed to refetch epoch state after reorg") + return + } + + s.cacheMu.Lock() + s.epochDependentRoots[epoch] = event.CurrentDutyDependentRoot + s.cacheMu.Unlock() + + stats := s.GetEpochStats(epoch) + if stats != nil { + s.epochStatsDispatcher.Fire(stats) + } +} + // fetchCurrentEpochState fetches the state for the current epoch at startup. func (s *service) fetchCurrentEpochState(ctx context.Context) error { // Get current slot to determine epoch diff --git a/pkg/chain/spec.go b/pkg/chain/spec.go index c802cb3..5ee5517 100644 --- a/pkg/chain/spec.go +++ b/pkg/chain/spec.go @@ -55,6 +55,10 @@ type ChainSpec struct { // ePBS parameters PtcSize uint64 + // PAYLOAD_DUE_BPS: the builder payload reveal deadline as basis points of + // the slot duration (Gloas). A block whose payload has not been seen by + // this offset is treated as empty by payload timeliness voting. + PayloadDueBps uint64 // Deposit contract DepositContractAddress *common.Address @@ -241,6 +245,12 @@ func (s *ChainSpec) parseSpecData(specData map[string]string, rawData map[string s.PtcSize = v } + if v, err := parseSpecUint64(specData, "PAYLOAD_DUE_BPS"); err == nil { + s.PayloadDueBps = v + } else { + s.PayloadDueBps = 5000 + } + // Parse deposit contract address if addrStr, ok := specData["DEPOSIT_CONTRACT_ADDRESS"]; ok { addr := common.HexToAddress(addrStr) diff --git a/pkg/config/default.go b/pkg/config/default.go index 8388265..8a8ff28 100644 --- a/pkg/config/default.go +++ b/pkg/config/default.go @@ -12,6 +12,7 @@ func DefaultConfig() *Config { BuilderAPIEnabled: false, // Disabled by default BuilderAPI: BuilderAPIConfig{ BlockValueSubsidyGwei: 100000, // 100k Gwei + ServeCandidates: "all", }, DepositAmount: 50000000000, // 50 ETH in Gwei TopupThreshold: 10000000000, // 10 ETH in Gwei @@ -33,6 +34,15 @@ func DefaultConfig() *Config { BidInterval: 500, // 500ms between bids BidSubsidy: 100000000, // 100M gwei = 0.1 ETH; clears validator local-EL threshold HeadVoteThresholdPct: 60, // Gloas builder payment quorum (6/10) + BidCandidate: "auto", + }, + Build: BuildConfig{ + CandidateParentFull: CandidateModeAlways, + CandidateParentEmpty: CandidateModeAuto, + CandidateGrandparentFull: CandidateModeAuto, + CandidateGrandparentEmpty: CandidateModeNever, + Parallel: true, + AutoWeakHeadPct: 40, }, Reveal: RevealConfig{ Enabled: true, @@ -45,6 +55,7 @@ func DefaultConfig() *Config { BroadcastValidation: BroadcastValidationConsensusAndEquivocation, MaxAttempts: 3, RetryIntervalMs: 500, + RebindOnReorg: true, // TimeMs: 0 = auto-compute from slot time (see ApplySlotDefaults). }, } diff --git a/pkg/config/settings_fields.go b/pkg/config/settings_fields.go index 4f06113..b2a2772 100644 --- a/pkg/config/settings_fields.go +++ b/pkg/config/settings_fields.go @@ -93,6 +93,8 @@ func Fields() []Field { newField(KeyEPBSBidSubsidy, "epbs-bid-subsidy", func(c *Config) *uint64 { return &c.EPBS.BidSubsidy }), newField(KeyEPBSBidValueOverride, "epbs-bid-value-override", func(c *Config) *uint64 { return &c.EPBS.BidValueOverride }), newField(KeyEPBSHeadVoteThreshold, "epbs-vote-threshold", func(c *Config) *uint64 { return &c.EPBS.HeadVoteThresholdPct }), + newField(KeyEPBSBidCandidate, "epbs-bid-candidate", func(c *Config) *string { return &c.EPBS.BidCandidate }), + newField(KeyEPBSBidCandidateSwitch, "epbs-bid-candidate-switch", func(c *Config) *bool { return &c.EPBS.BidCandidateSwitch }), newField(KeyRevealEnabled, "reveal-enabled", func(c *Config) *bool { return &c.Reveal.Enabled }), newField(KeyRevealGateMode, "reveal-gate-mode", func(c *Config) *string { return &c.Reveal.GateMode }), @@ -101,11 +103,23 @@ func Fields() []Field { newField(KeyRevealBroadcastValidation, "reveal-broadcast-validation", func(c *Config) *string { return &c.Reveal.BroadcastValidation }), newField(KeyRevealMaxAttempts, "reveal-max-attempts", func(c *Config) *uint64 { return &c.Reveal.MaxAttempts }), newField(KeyRevealRetryInterval, "reveal-retry-interval", func(c *Config) *int64 { return &c.Reveal.RetryIntervalMs }), + newField(KeyRevealRebindOnReorg, "reveal-rebind-on-reorg", func(c *Config) *bool { return &c.Reveal.RebindOnReorg }), + + newField(KeyBuildCandidateParentFull, "build-candidate-parent-full", func(c *Config) *string { return &c.Build.CandidateParentFull }), + newField(KeyBuildCandidateParentEmpty, "build-candidate-parent-empty", func(c *Config) *string { return &c.Build.CandidateParentEmpty }), + newField(KeyBuildCandidateGrandparentFull, "build-candidate-grandparent-full", func(c *Config) *string { return &c.Build.CandidateGrandparentFull }), + newField(KeyBuildCandidateGrandparentEmpty, "build-candidate-grandparent-empty", func(c *Config) *string { return &c.Build.CandidateGrandparentEmpty }), + newField(KeyBuildParallel, "build-parallel", func(c *Config) *bool { return &c.Build.Parallel }), + newField(KeyBuildSpeculativeBuildTime, "build-speculative-build-time", func(c *Config) *uint64 { return &c.Build.SpeculativeBuildTimeMs }), + newField(KeyBuildAutoWeakHeadPct, "build-auto-weak-head-pct", func(c *Config) *uint64 { return &c.Build.AutoWeakHeadPct }), + newField(KeyBuildEnforceBidGasLimit, "build-enforce-bid-gas-limit", func(c *Config) *bool { return &c.Build.EnforceBidGasLimit }), newField(KeyPayloadBuildTime, "payload-build-time", func(c *Config) *uint64 { return &c.PayloadBuildTime }), newField(KeyExtraData, "extra-data", func(c *Config) *string { return &c.ExtraData }), newField(KeyBuilderAPISubsidy, "builder-api-subsidy", func(c *Config) *uint64 { return &c.BuilderAPI.BlockValueSubsidyGwei }), newField(KeyBuilderAPIValueOverride, "builder-api-value-override", func(c *Config) *uint64 { return &c.BuilderAPI.ValueOverrideGwei }), + newField(KeyBuilderAPIServeCandidates, "builder-api-serve-candidates", func(c *Config) *string { return &c.BuilderAPI.ServeCandidates }), + newField(KeyBuilderAPIOnDemandBuild, "builder-api-on-demand-build", func(c *Config) *bool { return &c.BuilderAPI.OnDemandBuild }), newField(KeySlotResultRetentionEpochs, "slot-result-retention-epochs", func(c *Config) *uint64 { return &c.SlotResultRetentionEpochs }), newField(KeySlotArtifactRetentionEpochs, "slot-artifact-retention-epochs", func(c *Config) *uint64 { return &c.SlotArtifactRetentionEpochs }), diff --git a/pkg/config/settings_keys.go b/pkg/config/settings_keys.go index d3e91cc..37eead7 100644 --- a/pkg/config/settings_keys.go +++ b/pkg/config/settings_keys.go @@ -9,15 +9,17 @@ const ( KeyScheduleNextN = "schedule.next_n" KeyScheduleStartSlot = "schedule.start_slot" - KeyEPBSBuildStartTime = "epbs.build_start_time" - KeyEPBSBidStartTime = "epbs.bid_start_time" - KeyEPBSBidEndTime = "epbs.bid_end_time" - KeyEPBSBidMinAmount = "epbs.bid_min_amount" - KeyEPBSBidIncrease = "epbs.bid_increase" - KeyEPBSBidInterval = "epbs.bid_interval" - KeyEPBSBidSubsidy = "epbs.bid_subsidy" - KeyEPBSBidValueOverride = "epbs.bid_value_override" - KeyEPBSHeadVoteThreshold = "epbs.head_vote_threshold_pct" + KeyEPBSBuildStartTime = "epbs.build_start_time" + KeyEPBSBidStartTime = "epbs.bid_start_time" + KeyEPBSBidEndTime = "epbs.bid_end_time" + KeyEPBSBidMinAmount = "epbs.bid_min_amount" + KeyEPBSBidIncrease = "epbs.bid_increase" + KeyEPBSBidInterval = "epbs.bid_interval" + KeyEPBSBidSubsidy = "epbs.bid_subsidy" + KeyEPBSBidValueOverride = "epbs.bid_value_override" + KeyEPBSHeadVoteThreshold = "epbs.head_vote_threshold_pct" + KeyEPBSBidCandidate = "epbs.bid_candidate" + KeyEPBSBidCandidateSwitch = "epbs.bid_candidate_switch" KeyRevealEnabled = "reveal.enabled" KeyRevealGateMode = "reveal.gate_mode" @@ -26,11 +28,23 @@ const ( KeyRevealBroadcastValidation = "reveal.broadcast_validation" KeyRevealMaxAttempts = "reveal.max_attempts" KeyRevealRetryInterval = "reveal.retry_interval_ms" + KeyRevealRebindOnReorg = "reveal.rebind_on_reorg" - KeyPayloadBuildTime = "payload_build_time" - KeyExtraData = "extra_data" - KeyBuilderAPISubsidy = "builder_api.block_value_subsidy_gwei" - KeyBuilderAPIValueOverride = "builder_api.value_override_gwei" + KeyBuildCandidateParentFull = "build.candidate_parent_full" + KeyBuildCandidateParentEmpty = "build.candidate_parent_empty" + KeyBuildCandidateGrandparentFull = "build.candidate_grandparent_full" + KeyBuildCandidateGrandparentEmpty = "build.candidate_grandparent_empty" + KeyBuildParallel = "build.parallel" + KeyBuildSpeculativeBuildTime = "build.speculative_build_time_ms" + KeyBuildAutoWeakHeadPct = "build.auto_weak_head_pct" + KeyBuildEnforceBidGasLimit = "build.enforce_bid_gas_limit" + + KeyPayloadBuildTime = "payload_build_time" + KeyExtraData = "extra_data" + KeyBuilderAPISubsidy = "builder_api.block_value_subsidy_gwei" + KeyBuilderAPIValueOverride = "builder_api.value_override_gwei" + KeyBuilderAPIServeCandidates = "builder_api.serve_candidates" + KeyBuilderAPIOnDemandBuild = "builder_api.on_demand_build" KeySlotResultRetentionEpochs = "slot_result_retention_epochs" KeySlotArtifactRetentionEpochs = "slot_artifact_retention_epochs" diff --git a/pkg/config/types.go b/pkg/config/types.go index a5cb3f9..4c4decf 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -1,6 +1,8 @@ // Package config handles configuration loading and validation for buildoor. package config +import "strings" + // ValidatorRangesConfig configures how to load validator index → client name mappings. // If both are set, URL takes precedence. type ValidatorRangesConfig struct { @@ -44,6 +46,7 @@ type Config struct { Schedule ScheduleConfig `yaml:"schedule" json:"schedule"` EPBS EPBSConfig `yaml:"epbs" json:"epbs"` // Time-scheduled ePBS config Reveal RevealConfig `yaml:"reveal" json:"reveal"` // Payload reveal config (shared by p2p bidder + Builder API) + Build BuildConfig `yaml:"build" json:"build"` // Payload build candidate policy Debug bool `yaml:"debug" json:"debug"` Pprof bool `yaml:"pprof" json:"pprof"` PayloadBuildTime uint64 `yaml:"payload_build_time" json:"payload_build_time"` // The time given to the EL to build the payload after triggering the payload build via fcu (in ms) @@ -113,6 +116,46 @@ type BuilderAPIConfig struct { // (block value + subsidy) with this absolute amount in gwei — an alternative // to the subsidy for testing. Per-slot action plans override this per slot. ValueOverrideGwei uint64 `yaml:"value_override_gwei" json:"value_override_gwei"` + + // ServeCandidates controls which built candidate payloads bid requests may + // be answered from: "all" (default; serve whichever candidate matches the + // requested parent), "canonical_only" (only parent_full and unclassified + // payloads), or a comma-separated list of candidate keys. + ServeCandidates string `yaml:"serve_candidates" json:"serve_candidates"` + + // OnDemandBuild builds a payload on the fly when a bid request asks for a + // legal parent tuple no candidate covers yet (bounded by the request's + // response budget). + OnDemandBuild bool `yaml:"on_demand_build" json:"on_demand_build"` +} + +// ServeCandidateAllowed reports whether the given serve policy allows +// answering from a payload classified as the given candidate key ("" = +// unclassified, always allowed under "all" and "canonical_only"). +func ServeCandidateAllowed(policy, key string) bool { + switch policy { + case "", "all": + return true + case "canonical_only": + return key == "" || key == "parent_full" + default: + if key == "" { + return false + } + + for _, allowed := range strings.Split(policy, ",") { + if strings.TrimSpace(allowed) == key { + return true + } + } + + return false + } +} + +// ServeCandidateAllowed applies the config's own serve policy. +func (c *BuilderAPIConfig) ServeCandidateAllowed(key string) bool { + return ServeCandidateAllowed(c.ServeCandidates, key) } // EPBSConfig defines time-scheduled bidding parameters for ePBS. @@ -152,6 +195,20 @@ type EPBSConfig struct { // plans override this per slot. BidValueOverride uint64 `yaml:"bid_value_override" json:"bid_value_override"` + // BidCandidate selects which built candidate payload the p2p bids commit + // to: "auto" (default; match the chain view's current head and payload + // status), a specific candidate key (parent_full, parent_empty, + // grandparent_full, grandparent_empty), or "all" (gossip a bid for every + // built candidate — deliberate multi-parent bidding for gossip testing; + // most nodes propagate only a builder's first bid per slot). + BidCandidate string `yaml:"bid_candidate" json:"bid_candidate"` + + // BidCandidateSwitch allows the auto selection to switch to a different + // candidate mid-slot when the chain view changes. Default off: the first + // gossiped candidate sticks (the gossip first-seen rule makes a switched + // bid unlikely to propagate anyway). + BidCandidateSwitch bool `yaml:"bid_candidate_switch" json:"bid_candidate_switch"` + // HeadVoteThresholdPct is the head-vote participation threshold in percent // (0-100) the vote tracker reports against: crossing it fires an immediate // update with threshold_met set. 0 disables threshold checking. The default @@ -161,6 +218,84 @@ type EPBSConfig struct { HeadVoteThresholdPct uint64 `yaml:"head_vote_threshold_pct" json:"head_vote_threshold_pct"` } +// Candidate build modes: whether a build-parent candidate is built for a slot. +const ( + // CandidateModeAuto builds the candidate when live chain signals suggest + // it may be needed (parent payload reveal status, parent block weakness). + CandidateModeAuto = "auto" + // CandidateModeAlways builds the candidate every scheduled slot. + CandidateModeAlways = "always" + // CandidateModeNever suppresses the candidate. + CandidateModeNever = "never" +) + +// NormalizedCandidateMode returns the candidate mode, falling back to the +// given default for unknown values (UI overrides are free-form strings). +func NormalizedCandidateMode(mode, fallback string) string { + switch mode { + case CandidateModeAuto, CandidateModeAlways, CandidateModeNever: + return mode + default: + return fallback + } +} + +// BuildConfig defines which build-parent candidates are built per slot and how +// the engine builds are sequenced. Candidates name the parent tuple a payload +// extends: the head block ("parent") or its parent ("grandparent", a +// deliberate reorg), each on the committed payload ("full") or on the payload +// it built upon ("empty", the Gloas payload-miss case). +type BuildConfig struct { + // CandidateParentFull: the normal build on the head block and its payload. + CandidateParentFull string `yaml:"candidate_parent_full" json:"candidate_parent_full"` + // CandidateParentEmpty: build on the head block but on its execution + // parent (head payload treated as withheld). Gloas only. + CandidateParentEmpty string `yaml:"candidate_parent_empty" json:"candidate_parent_empty"` + // CandidateGrandparentFull: build on the head block's parent (reorg). + CandidateGrandparentFull string `yaml:"candidate_grandparent_full" json:"candidate_grandparent_full"` + // CandidateGrandparentEmpty: reorg combined with a withheld grandparent + // payload. Gloas only. + CandidateGrandparentEmpty string `yaml:"candidate_grandparent_empty" json:"candidate_grandparent_empty"` + + // Parallel runs the selected candidate builds concurrently against the + // EL, each with its own payload ID (default). Disable it to serialize + // them — the canonical candidate builds first so it keeps its scheduled + // start, and the speculative ones follow. + Parallel bool `yaml:"parallel" json:"parallel"` + + // SpeculativeBuildTimeMs, when non-zero, is the EL build time granted to + // speculative (non-parent_full) candidates instead of PayloadBuildTime. + SpeculativeBuildTimeMs uint64 `yaml:"speculative_build_time_ms" json:"speculative_build_time_ms"` + + // AutoWeakHeadPct is the head-vote participation (percent) below which + // the head block counts as contested and auto-mode grandparent + // candidates arm. 0 disables the weak-head signal. + AutoWeakHeadPct uint64 `yaml:"auto_weak_head_pct" json:"auto_weak_head_pct"` + + // EnforceBidGasLimit adjusts the built payload's gas limit to the exact + // value the bid gossip rules require (EL parent gas limit stepped toward + // the proposer's target) when the EL ignored the target. Disabled by + // default: the override rewrites the block header after building. + EnforceBidGasLimit bool `yaml:"enforce_bid_gas_limit" json:"enforce_bid_gas_limit"` +} + +// CandidateMode returns the normalized mode configured for the given +// candidate key ("" for unknown keys). +func (c *BuildConfig) CandidateMode(key string) string { + switch key { + case "parent_full": + return NormalizedCandidateMode(c.CandidateParentFull, CandidateModeAlways) + case "parent_empty": + return NormalizedCandidateMode(c.CandidateParentEmpty, CandidateModeAuto) + case "grandparent_full": + return NormalizedCandidateMode(c.CandidateGrandparentFull, CandidateModeAuto) + case "grandparent_empty": + return NormalizedCandidateMode(c.CandidateGrandparentEmpty, CandidateModeNever) + default: + return "" + } +} + // Reveal gate modes: how the reveal moment of a won slot is decided. const ( // RevealGateTime reveals at TimeMs into the slot. @@ -217,6 +352,12 @@ type RevealConfig struct { // RetryIntervalMs is the wait between failed publish attempts. RetryIntervalMs int64 `yaml:"retry_interval_ms" json:"retry_interval_ms"` + + // RebindOnReorg re-binds a slot's reveal to a different beacon block when + // the block the reveal was scheduled for is reorged out and our payload is + // re-included under a sibling root: the envelope is rebuilt and re-signed + // for the new root (the payload bytes are unchanged). + RebindOnReorg bool `yaml:"rebind_on_reorg" json:"rebind_on_reorg"` } // NormalizedGateMode returns the gate mode, falling back to RevealGateTime diff --git a/pkg/p2p_bidder/bid_tracker.go b/pkg/p2p_bidder/bid_tracker.go index 8a783e3..1be8202 100644 --- a/pkg/p2p_bidder/bid_tracker.go +++ b/pkg/p2p_bidder/bid_tracker.go @@ -77,7 +77,11 @@ func (t *BidTracker) GetHighestBid(slot phase0.Slot) *TrackedBid { // GetHighestCompetitorBid returns the highest tracked bid value (gwei) for // the slot excluding our own builder index, and whether any competitor bid is // known. Unlike GetHighestBid it can never report our own bid back to us. -func (t *BidTracker) GetHighestCompetitorBid(slot phase0.Slot, ourBuilderIndex uint64) (uint64, bool) { +// A non-zero parentHash restricts the comparison to bids committing to the +// same execution parent (bids on other forks are not competing). +func (t *BidTracker) GetHighestCompetitorBid( + slot phase0.Slot, ourBuilderIndex uint64, parentHash phase0.Hash32, +) (uint64, bool) { t.mu.RLock() defer t.mu.RUnlock() @@ -95,6 +99,10 @@ func (t *BidTracker) GetHighestCompetitorBid(slot phase0.Slot, ourBuilderIndex u continue } + if parentHash != (phase0.Hash32{}) && tracked.Bid.ParentBlockHash != parentHash { + continue + } + if !found || tracked.Bid.Value > highest { highest = tracked.Bid.Value found = true diff --git a/pkg/p2p_bidder/bid_tracker_test.go b/pkg/p2p_bidder/bid_tracker_test.go index 71d8ec2..fb23f3a 100644 --- a/pkg/p2p_bidder/bid_tracker_test.go +++ b/pkg/p2p_bidder/bid_tracker_test.go @@ -171,7 +171,7 @@ func TestBidTracker_GetHighestCompetitorBid(t *testing.T) { tracker.TrackBid(bid, bid.BuilderIndex == tt.ourBuilderIdx) } - value, ok := tracker.GetHighestCompetitorBid(tt.slot, tt.ourBuilderIdx) + value, ok := tracker.GetHighestCompetitorBid(tt.slot, tt.ourBuilderIdx, phase0.Hash32{}) assert.Equal(t, tt.wantOK, ok, "competitor bid known") assert.Equal(t, tt.wantValue, value, "highest competitor value") }) diff --git a/pkg/p2p_bidder/scheduler.go b/pkg/p2p_bidder/scheduler.go index ec3685c..4f42fe5 100644 --- a/pkg/p2p_bidder/scheduler.go +++ b/pkg/p2p_bidder/scheduler.go @@ -16,6 +16,7 @@ import ( "github.com/ethpandaops/buildoor/pkg/action_plan" "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/memstore" "github.com/ethpandaops/buildoor/pkg/payload_builder" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" @@ -27,8 +28,19 @@ type SlotState struct { LastBidTime time.Time LastBidHash phase0.Hash32 BidCount int - BidsClosed bool // Block received, no more bids possible - NoPrefsWarnedFor bool // Missing-preferences skip already reported for this slot + BidsClosed bool // Block received, no more bids possible + ClosedByRoot phase0.Root // block root that closed bidding (reopens if orphaned) + NoPrefsWarnedFor bool // Missing-preferences skip already reported for this slot + // BidPayloads tracks the last bid time per payload: interval throttling + // and single-bid dedup are PER PAYLOAD, so multi-candidate bidding + // ("all") does not starve the other candidates behind one payload's + // interval gate. + BidPayloads map[phase0.Hash32]time.Time + + // BidCandidate is the candidate the auto selection committed to on the + // first bid of the slot (sticky unless candidate switching is enabled). + BidCandidate chain.CandidateKey + BidCandidateSet bool // Frozen is the slot's immutable action-plan snapshot, resolved on the // first scheduler evaluation of the slot (nil until then). @@ -46,6 +58,7 @@ type Scheduler struct { blsSigner *signer.BLSSigner propPrefsStore *memstore.Store[phase0.Slot, *gloasspec.SignedProposerPreferences] planSvc *action_plan.PlanService // per-slot scheduling/settings authority + cfg *config.Config // shared config; mutable settings read live log logrus.FieldLogger // Simple state tracking per slot @@ -55,7 +68,9 @@ type Scheduler struct { // NewScheduler creates a new scheduler. planSvc is the mandatory per-slot // action plan service: every bid setting the scheduler acts on comes from its -// frozen slot snapshots, never from the live config. +// frozen slot snapshots, never from the live config — except the bid +// candidate selection, which is deliberately live (the whole point is +// choosing at bid time, after the plan froze). func NewScheduler( chainSvc chain.Service, bidCreator *BidCreator, @@ -65,6 +80,7 @@ func NewScheduler( blsSigner *signer.BLSSigner, propPrefsStore *memstore.Store[phase0.Slot, *gloasspec.SignedProposerPreferences], planSvc *action_plan.PlanService, + cfg *config.Config, log logrus.FieldLogger, ) *Scheduler { return &Scheduler{ @@ -76,6 +92,7 @@ func NewScheduler( blsSigner: blsSigner, propPrefsStore: propPrefsStore, planSvc: planSvc, + cfg: cfg, slotStates: make(map[phase0.Slot]*SlotState), log: log.WithField("component", "scheduler"), } @@ -100,10 +117,45 @@ func (s *Scheduler) OnHeadEvent(event *beacon.HeadEvent) { slotState := s.getSlotState(event.Slot) if !slotState.BidsClosed { slotState.BidsClosed = true + slotState.ClosedByRoot = event.Block s.log.WithField("slot", event.Slot).Debug("Bidding closed for slot (block received)") } } +// OnHeadChange reopens bidding for slots whose closing block was reorged out: +// with the block gone, the slot's proposer opportunity is live again for the +// rest of its bid window. +func (s *Scheduler) OnHeadChange(ctx context.Context, change *chain.HeadChangeEvent) { + if change.ReorgDepth == 0 || change.Old == nil { + return + } + + headTracker := s.chainSvc.GetHeadTracker() + if headTracker == nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + for slot, state := range s.slotStates { + if !state.BidsClosed || state.ClosedByRoot == (phase0.Root{}) { + continue + } + + if headTracker.IsCanonical(ctx, state.ClosedByRoot) { + continue + } + + state.BidsClosed = false + state.ClosedByRoot = phase0.Root{} + + s.log.WithFields(logrus.Fields{ + "slot": slot, + }).Info("Reopening bidding for slot (closing block was reorged out)") + } +} + // ProcessTick is called frequently to check if any bids are due. func (s *Scheduler) ProcessTick(ctx context.Context) { now := time.Now() @@ -174,9 +226,10 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n return } - // Get payload from builder cache - payload := s.payloadCache.Get(slot) - if payload == nil { + // Select the candidate payload(s) to bid on. The selection runs at bid + // time — after builds finished and with the freshest chain view. + payloads := s.selectBidPayloads(slot, bidSettings) + if len(payloads) == 0 { return } @@ -200,14 +253,14 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n if !alreadyWarned { s.log.WithFields(logrus.Fields{ "slot": slot, - "block_hash": fmt.Sprintf("%x", payload.BlockHash[:8]), + "block_hash": fmt.Sprintf("%x", payloads[0].BlockHash[:8]), }).Warn("No proposer preferences for slot — skipping bids " + "(cache refills from gossip within ~1 epoch after a restart)") if s.service != nil { s.service.FireBidSubmission(&BidSubmissionEvent{ Slot: slot, - BlockHash: payload.BlockHash, + BlockHash: payloads[0].BlockHash, Success: false, Warning: "no proposer preferences for slot — bid skipped", }) @@ -217,27 +270,142 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n return } + for _, payload := range payloads { + s.trySubmitBid(ctx, slot, now, msRelativeToSlot, bidSettings, payload, prefsBypassed) + } +} + +// selectBidPayloads returns the built payload(s) the slot's bids commit to, +// per the live bid-candidate setting: a specific candidate, every built +// candidate ("all"), or the auto selection matching the chain view (sticky +// per slot unless candidate switching is enabled). +func (s *Scheduler) selectBidPayloads( + slot phase0.Slot, bidSettings *action_plan.ResolvedBidSettings, +) []*payload_builder.Payload { + // The frozen per-slot selection wins; the live config covers snapshots + // frozen before the setting existed. + mode := bidSettings.BidCandidate + if mode == "" { + mode = s.cfg.EPBS.BidCandidate + } + + switch { + case mode == "all": + return s.payloadCache.GetSlotPayloads(slot) + + case mode != "" && mode != "auto": + if !chain.IsValidCandidateKey(mode) { + s.log.WithField("bid_candidate", mode). + Warn("Unknown bid candidate setting, falling back to auto selection") + break + } + + if payload := s.payloadCache.GetCandidate(slot, chain.CandidateKey(mode)); payload != nil { + return []*payload_builder.Payload{payload} + } + + return nil + } + + s.mu.Lock() + state := s.getSlotState(slot) + chosen, chosenSet := state.BidCandidate, state.BidCandidateSet + s.mu.Unlock() + + if chosenSet && !s.cfg.EPBS.BidCandidateSwitch { + // Sticky: keep bidding the committed candidate (the gossip first-seen + // rule makes a switched bid unlikely to propagate anyway); fall back + // to the primary payload when that candidate produced none. + if payload := s.payloadCache.GetCandidate(slot, chosen); payload != nil { + return []*payload_builder.Payload{payload} + } + + if payload := s.payloadCache.Get(slot); payload != nil { + return []*payload_builder.Payload{payload} + } + + return nil + } + + payload := s.preferredPayload(slot) + if payload == nil { + return nil + } + + s.mu.Lock() + state = s.getSlotState(slot) + + if state.BidCandidateSet && state.BidCandidate != payload.Candidate { + s.log.WithFields(logrus.Fields{ + "slot": slot, + "from": state.BidCandidate, + "to": payload.Candidate, + }).Warn("Switching bid candidate mid-slot (chain view changed)") + } + + state.BidCandidate = payload.Candidate + state.BidCandidateSet = true + s.mu.Unlock() + + return []*payload_builder.Payload{payload} +} + +// preferredPayload picks the built payload matching the chain view's current +// head and its payload status, falling back to the cache's primary payload. +func (s *Scheduler) preferredPayload(slot phase0.Slot) *payload_builder.Payload { + headTracker := s.chainSvc.GetHeadTracker() + if headTracker != nil { + if head := headTracker.CurrentHead(); head != nil && head.Slot < slot { + hash := head.ExecutionBlockHash + if headTracker.GetPayloadStatus(head.Root) == chain.PayloadStatusEmpty { + hash = head.FinalitySafeExecutionBlockHash + } + + key := beacon.AttrParentKey{Root: head.Root, Hash: hash} + if payload := s.payloadCache.GetVariant(slot, key); payload != nil { + return payload + } + } + } + + return s.payloadCache.Get(slot) +} + +// trySubmitBid runs the per-payload bid checks (window close, interval, +// single-bid dedup), computes the bid value and submits. +func (s *Scheduler) trySubmitBid( + ctx context.Context, + slot phase0.Slot, + now time.Time, + msRelativeToSlot int64, + bidSettings *action_plan.ResolvedBidSettings, + payload *payload_builder.Payload, + prefsBypassed bool, +) { s.mu.Lock() state := s.getSlotState(slot) // Check if we should bid // - Not if bidding is closed (block already received) // - Not if we bid too recently (respect interval) - // - Not if payload hasn't changed and we already bid (single bid mode) + // - Not if we already bid this payload (single bid mode) if state.BidsClosed { s.mu.Unlock() return } - // Check bid interval + // Check bid interval (per payload, so "all" mode candidates do not + // throttle each other) + lastBid, alreadyBid := state.BidPayloads[payload.BlockHash] + if bidSettings.IntervalMs > 0 { - if time.Since(state.LastBidTime) < time.Duration(bidSettings.IntervalMs)*time.Millisecond { + if alreadyBid && time.Since(lastBid) < time.Duration(bidSettings.IntervalMs)*time.Millisecond { s.mu.Unlock() return } } else { - // Single bid mode - only bid if payload changed or never bid - if state.BidCount > 0 && state.LastBidHash == payload.BlockHash { + // Single bid mode - only bid payloads we have not bid yet. + if alreadyBid { s.mu.Unlock() return } @@ -286,6 +454,12 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n s.mu.Lock() state.LastBidTime = now state.LastBidHash = payload.BlockHash + + if state.BidPayloads == nil { + state.BidPayloads = make(map[phase0.Hash32]time.Time, 2) + } + + state.BidPayloads[payload.BlockHash] = now state.BidCount++ bidCount := state.BidCount s.mu.Unlock() @@ -302,7 +476,8 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n event.Warning = "no proposer preferences for slot — bid sent anyway (ignore_missing_prefs)" } - if high, ok := s.bidTracker.GetHighestCompetitorBid(slot, s.bidCreator.GetBuilderIndex()); ok { + if high, ok := s.bidTracker.GetHighestCompetitorBid(slot, s.bidCreator.GetBuilderIndex(), + payload.Attributes.ParentBlockHash); ok { event.CompetitorHighGwei = &high } @@ -327,10 +502,12 @@ func (s *Scheduler) checkSlotForBidding(ctx context.Context, slot phase0.Slot, n // Track the bid s.bidTracker.TrackBid(&ExecutionPayloadBid{ - Slot: slot, - BuilderIndex: s.bidCreator.builderIndex, - Value: bidValue, - BlockHash: payload.BlockHash, + Slot: slot, + BuilderIndex: s.bidCreator.builderIndex, + Value: bidValue, + BlockHash: payload.BlockHash, + ParentBlockHash: payload.Attributes.ParentBlockHash, + ParentBlockRoot: payload.Attributes.ParentBlockRoot, }, true) // Fire bid success event diff --git a/pkg/p2p_bidder/scheduler_test.go b/pkg/p2p_bidder/scheduler_test.go index 57eb2de..7af8cc3 100644 --- a/pkg/p2p_bidder/scheduler_test.go +++ b/pkg/p2p_bidder/scheduler_test.go @@ -45,6 +45,8 @@ type stubChainService struct { fork version.DataVersion } +func (s *stubChainService) GetHeadTracker() *chain.HeadTracker { return nil } + func newStubChainService() *stubChainService { return &stubChainService{ spec: &chain.ChainSpec{ @@ -163,7 +165,7 @@ func newSchedulerHarness(t *testing.T, opts harnessOptions) *schedulerHarness { cache := payload_builder.NewPayloadCache(8) scheduler := NewScheduler(chainSvc, bidCreator, bidTracker, - cache, svc, blsSigner, prefs, planSvc, log) + cache, svc, blsSigner, prefs, planSvc, cfg, log) events := svc.SubscribeBidSubmissions(16, false) t.Cleanup(events.Unsubscribe) @@ -481,6 +483,7 @@ func TestSchedulerIntervalIncreaseAndCompetitorHigh(t *testing.T) { // Age the last bid past the interval, then re-bid with the increase. h.scheduler.mu.Lock() h.scheduler.slotStates[testSlot].LastBidTime = time.Now().Add(-time.Second) + h.scheduler.slotStates[testSlot].BidPayloads[phase0.Hash32{0xbb}] = time.Now().Add(-time.Second) h.scheduler.mu.Unlock() h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1100) @@ -508,6 +511,7 @@ func TestSchedulerOverflowClampsInsteadOfWrapping(t *testing.T) { // Re-bid: MaxUint64 + 1*10 must clamp, not wrap to 9. h.scheduler.mu.Lock() h.scheduler.slotStates[testSlot].LastBidTime = time.Now().Add(-time.Second) + h.scheduler.slotStates[testSlot].BidPayloads[phase0.Hash32{0xbb}] = time.Now().Add(-time.Second) h.scheduler.mu.Unlock() h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1100) @@ -577,3 +581,106 @@ func TestBidCreatorReturnsBidOnSubmitFailure(t *testing.T) { }) } } + +// newCandidatePayload builds a test payload classified as the given candidate +// with a distinct parent tuple and block hash. +func newCandidatePayload(slot phase0.Slot, key chain.CandidateKey, marker byte) *payload_builder.Payload { + return &payload_builder.Payload{ + Attributes: &beacon.PayloadAttributesEvent{ + ProposalSlot: slot, + ParentBlockRoot: phase0.Root{marker}, + ParentBlockHash: phase0.Hash32{marker}, + }, + Candidate: key, + ExecutionPayload: ð2all.ExecutionPayload{ + Version: version.DataVersionGloas, + BlockHash: phase0.Hash32{marker, 0xbb}, + GasLimit: 30_000_000, + }, + BlockHash: phase0.Hash32{marker, 0xbb}, + BlockValue: gweiToWei(1000), + ReadyAt: time.Now(), + } +} + +func TestSchedulerBidCandidateSelection(t *testing.T) { + h := newSchedulerHarness(t, harnessOptions{epbsEnabled: true, serviceEnabled: true}) + + full := newCandidatePayload(testSlot, chain.CandidateParentFull, 0x01) + empty := newCandidatePayload(testSlot, chain.CandidateParentEmpty, 0x02) + h.cache.Store(full) + h.cache.Store(empty) + h.prefs.Put(testSlot, &gloasspec.SignedProposerPreferences{}) + + // A forced candidate key bids exactly that payload. + h.cfg.EPBS.BidCandidate = "parent_empty" + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1000) + + event := h.nextEvent() + require.NotNil(t, event) + assert.EqualValues(t, empty.BlockHash, event.BlockHash, "forced candidate must be bid") + require.Nil(t, h.nextEvent(), "only one candidate must be bid") +} + +func TestSchedulerBidCandidateAll(t *testing.T) { + h := newSchedulerHarness(t, harnessOptions{epbsEnabled: true, serviceEnabled: true}) + + h.cache.Store(newCandidatePayload(testSlot, chain.CandidateParentFull, 0x01)) + h.cache.Store(newCandidatePayload(testSlot, chain.CandidateParentEmpty, 0x02)) + h.prefs.Put(testSlot, &gloasspec.SignedProposerPreferences{}) + + h.cfg.EPBS.BidCandidate = "all" + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1000) + + require.NotNil(t, h.nextEvent(), "first candidate bid expected") + require.NotNil(t, h.nextEvent(), "second candidate bid expected") + require.Nil(t, h.nextEvent()) + + // The single-bid dedup is per payload: a second tick bids nothing new. + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1100) + require.Nil(t, h.nextEvent()) +} + +func TestSchedulerAutoCandidateSticky(t *testing.T) { + h := newSchedulerHarness(t, harnessOptions{epbsEnabled: true, serviceEnabled: true}) + + empty := newCandidatePayload(testSlot, chain.CandidateParentEmpty, 0x02) + h.cache.Store(empty) + h.prefs.Put(testSlot, &gloasspec.SignedProposerPreferences{}) + + // Auto (no head tracker in the stub): primary payload wins and the + // choice sticks on the slot state. + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1000) + + event := h.nextEvent() + require.NotNil(t, event) + assert.EqualValues(t, empty.BlockHash, event.BlockHash) + + h.scheduler.mu.Lock() + state := h.scheduler.getSlotState(testSlot) + h.scheduler.mu.Unlock() + assert.True(t, state.BidCandidateSet) + assert.Equal(t, chain.CandidateParentEmpty, state.BidCandidate) +} + +func TestSchedulerBidAllIntervalPerPayload(t *testing.T) { + h := newSchedulerHarness(t, harnessOptions{epbsEnabled: true, serviceEnabled: true}) + + // Interval mode: candidates must not throttle each other in one tick. + h.applyBidPlan(t, testSlot, `{"mode":"custom","bid_interval":500,"bid_candidate":"all"}`) + + h.cache.Store(newCandidatePayload(testSlot, chain.CandidateParentFull, 0x01)) + h.cache.Store(newCandidatePayload(testSlot, chain.CandidateParentEmpty, 0x02)) + h.prefs.Put(testSlot, &gloasspec.SignedProposerPreferences{}) + + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1000) + + require.NotNil(t, h.nextEvent(), "first candidate bid expected") + require.NotNil(t, h.nextEvent(), + "second candidate must bid in the same tick despite the interval") + require.Nil(t, h.nextEvent()) + + // Within the interval neither payload re-bids. + h.scheduler.checkSlotForBidding(context.Background(), testSlot, time.Now(), 1100) + require.Nil(t, h.nextEvent()) +} diff --git a/pkg/p2p_bidder/service.go b/pkg/p2p_bidder/service.go index 3fb36b9..cf716cb 100644 --- a/pkg/p2p_bidder/service.go +++ b/pkg/p2p_bidder/service.go @@ -232,6 +232,7 @@ func (s *Service) Start(ctx context.Context, builderSvc *payload_builder.Service s.blsSigner, s.propPrefsStore, s.planSvc, + builderSvc.GetConfig(), s.log, ) @@ -272,6 +273,17 @@ func (s *Service) run() { defer epochSub.Unsubscribe() defer ticker.Stop() + // Head-change events reopen bidding for slots whose closing block was + // reorged out (a nil channel simply never fires). + var headChangeCh <-chan *chain.HeadChangeEvent + + if headTracker := s.chainSvc.GetHeadTracker(); headTracker != nil { + headChangeSub := headTracker.SubscribeHeadChanges() + defer headChangeSub.Unsubscribe() + + headChangeCh = headChangeSub.Channel() + } + for { select { case <-s.ctx.Done(): @@ -283,11 +295,20 @@ func (s *Service) run() { case event := <-bidSub.Channel(): s.handleBidEvent(event) - case _, ok := <-epochSub.Channel(): + case epochStats, ok := <-epochSub.Channel(): if ok { s.RefreshRegistrationState() + + // Prune per-slot bid state that left the retention window. + if firstSlot := epochStats.Epoch * phase0.Epoch(s.chainSvc.GetChainSpec().SlotsPerEpoch); firstSlot > 64 { + s.scheduler.Cleanup(phase0.Slot(firstSlot) - 64) + s.bidTracker.Cleanup(phase0.Slot(firstSlot) - 64) + } } + case change := <-headChangeCh: + s.scheduler.OnHeadChange(s.ctx, change) + case <-ticker.C: // The enable policy is per slot: the scheduler resolves it from // the frozen action plan (a plan may activate bidding for a slot diff --git a/pkg/payload_bidder/inclusion_tracker.go b/pkg/payload_bidder/inclusion_tracker.go index 6766e09..a1e9daa 100644 --- a/pkg/payload_bidder/inclusion_tracker.go +++ b/pkg/payload_bidder/inclusion_tracker.go @@ -60,9 +60,6 @@ const ( // being re-evaluated against the head's ancestry; reorgs deeper than this // window no longer revise the recorded status. wonTrackingWindowSlots = 16 - // blockCacheExtraSlots keeps ancestry blocks slightly longer than the - // tracking window so verdict walks rarely refetch. - blockCacheExtraSlots = 4 ) // wonTracking is the run-loop-owned reorg-aware state for one won slot. @@ -90,9 +87,9 @@ type InclusionTracker struct { // Reorg-aware verdict state, owned by the run loop (no mutex): every won // slot is re-evaluated against each new head's ancestry until it leaves - // the tracking window. blockCache holds resolved ancestry blocks by root. + // the tracking window. Ancestry blocks are resolved through the chain + // service's shared head tracker cache. trackedWins map[phase0.Slot]*wonTracking - blockCache map[phase0.Root]*beacon.BlockInfo ctx context.Context cancel context.CancelFunc @@ -118,7 +115,6 @@ func NewInclusionTracker( revealSvc: revealSvc, payments: payments, trackedWins: make(map[phase0.Slot]*wonTracking, 4), - blockCache: make(map[phase0.Root]*beacon.BlockInfo, 32), log: log.WithField("component", "inclusion-tracker"), } } @@ -188,12 +184,9 @@ func (t *InclusionTracker) run() { // processHead resolves the head block's info and runs the inclusion checks. func (t *InclusionTracker) processHead(event *beacon.HeadEvent) { - ctx, cancel := context.WithTimeout(t.ctx, 5*time.Second) - defer cancel() - - blockInfo, err := t.clClient.GetBlockInfo(ctx, fmt.Sprintf("0x%x", event.Block[:])) - if err != nil { - t.log.WithError(err).WithField("slot", event.Slot).Debug("Failed to get block info") + blockInfo, ok := t.getBlock(event.Block) + if !ok { + t.log.WithField("slot", event.Slot).Debug("Failed to get head block info") return } @@ -206,8 +199,6 @@ func (t *InclusionTracker) processHead(event *beacon.HeadEvent) { // this head's ancestry — reorgs flip verdicts, each change fires an event. // 3. Prune tracking state that left the window. func (t *InclusionTracker) processBlockInfo(blockInfo *beacon.BlockInfo) { - t.blockCache[blockInfo.Root] = blockInfo - t.checkForOurPayload(blockInfo) t.evaluateTrackedWins(blockInfo) t.pruneTracking(blockInfo.Slot) @@ -235,6 +226,8 @@ func (t *InclusionTracker) evaluateTrackedWins(head *beacon.BlockInfo) { firstVerdict := win.verdict == "" win.verdict = verdict + t.applyVerdictSideEffects(slot, win, verdict) + t.payloadStatusDispatch.Fire(&PayloadStatusEvent{ Slot: slot, Verdict: verdict, @@ -261,6 +254,24 @@ func (t *InclusionTracker) evaluateTrackedWins(head *beacon.BlockInfo) { } } +// applyVerdictSideEffects propagates a verdict change into the win and +// payment bookkeeping: an orphaned winning block clears the payload's won +// marker (so a re-inclusion is detected again) and disputes the pending +// payment; a block returning to the canonical chain restores it. +func (t *InclusionTracker) applyVerdictSideEffects( + slot phase0.Slot, win *wonTracking, verdict PayloadVerdict, +) { + orphaned := verdict == PayloadVerdictOrphaned + + if orphaned { + t.builderSvc.UnmarkPayloadWon(win.execHash) + } + + if t.payments != nil { + t.payments.SetPaymentDisputed(slot, orphaned) + } +} + // resolveVerdict walks the head's ancestry down to the won slot. Returns an // empty verdict when an ancestor cannot be resolved (transient fetch failure; // retried on the next head event). @@ -296,10 +307,18 @@ func (t *InclusionTracker) resolveVerdict( return PayloadVerdictMissed, next } -// getBlock resolves a block by root through the ancestry cache, fetching from -// the beacon node on a miss. +// getBlock resolves a block by root through the chain service's shared head +// tracker (cache-then-fetch), falling back to a direct beacon-API fetch when +// the tracker is unavailable. func (t *InclusionTracker) getBlock(root phase0.Root) (*beacon.BlockInfo, bool) { - if info, ok := t.blockCache[root]; ok { + if headTracker := t.chainSvc.GetHeadTracker(); headTracker != nil { + info, err := headTracker.GetBlock(t.ctx, root) + if err != nil { + t.log.WithError(err).WithField("root", fmt.Sprintf("%#x", root)). + Debug("Failed to resolve ancestry block") + return nil, false + } + return info, true } @@ -313,13 +332,10 @@ func (t *InclusionTracker) getBlock(root phase0.Root) (*beacon.BlockInfo, bool) return nil, false } - t.blockCache[root] = info - return info, true } -// pruneTracking drops won-slot tracking and ancestry-cache entries that left -// the reorg window. +// pruneTracking drops won-slot tracking entries that left the reorg window. func (t *InclusionTracker) pruneTracking(headSlot phase0.Slot) { if headSlot <= wonTrackingWindowSlots { return @@ -331,17 +347,6 @@ func (t *InclusionTracker) pruneTracking(headSlot phase0.Slot) { delete(t.trackedWins, slot) } } - - if headSlot <= wonTrackingWindowSlots+blockCacheExtraSlots { - return - } - - minCacheSlot := headSlot - wonTrackingWindowSlots - blockCacheExtraSlots - for root, info := range t.blockCache { - if info.Slot < minCacheSlot { - delete(t.blockCache, root) - } - } } // logPaymentState logs the payment consequence once the first verdict for a diff --git a/pkg/payload_bidder/inclusion_tracker_test.go b/pkg/payload_bidder/inclusion_tracker_test.go index cf6febe..67c74d1 100644 --- a/pkg/payload_bidder/inclusion_tracker_test.go +++ b/pkg/payload_bidder/inclusion_tracker_test.go @@ -103,6 +103,9 @@ func TestInclusionTracker_PayloadVerdicts(t *testing.T) { t.Run(tt.name, func(t *testing.T) { logger, _ := newHookedLogger() chainSvc := &stubChainService{currentFork: version.DataVersionGloas} + // Seed both slot-5 branches into the shared ancestry cache (as + // head events would). + chainSvc.primeHeadTracker(logger, winBlock, competing5) builderSvc := newTestBuilderSvc(chainSvc) tracker := NewInclusionTracker(nil, chainSvc, builderSvc, nil, nil, logger) @@ -114,9 +117,6 @@ func TestInclusionTracker_PayloadVerdicts(t *testing.T) { tracker.processBlockInfo(winBlock) require.Contains(t, tracker.trackedWins, phase0.Slot(5), "win must be tracked") - // Seed the competing branch into the ancestry cache (as a head - // event would). - tracker.blockCache[competing5.Root] = competing5 tracker.processBlockInfo(tt.followUp) @@ -135,31 +135,34 @@ func TestInclusionTracker_PayloadVerdicts(t *testing.T) { func TestInclusionTracker_ReorgRevisesVerdict(t *testing.T) { logger, hook := newHookedLogger() + + ourHash := phase0.Hash32{0xab} + ourRoot := phase0.Root{0x05} + winBlock := &beacon.BlockInfo{Slot: 5, Root: ourRoot, ExecutionBlockHash: ourHash} + onChain6 := &beacon.BlockInfo{ + Slot: 6, Root: phase0.Root{0x06}, ParentRoot: ourRoot, + FinalitySafeExecutionBlockHash: ourHash, + } + competing5 := &beacon.BlockInfo{ + Slot: 5, Root: phase0.Root{0x55}, ExecutionBlockHash: phase0.Hash32{0x55}, + } + chainSvc := &stubChainService{currentFork: version.DataVersionGloas} + chainSvc.primeHeadTracker(logger, winBlock, onChain6, competing5) builderSvc := newTestBuilderSvc(chainSvc) tracker := NewInclusionTracker(nil, chainSvc, builderSvc, nil, nil, logger) statusSub := tracker.SubscribePayloadStatus(8, false) defer statusSub.Unsubscribe() - ourHash := phase0.Hash32{0xab} - ourRoot := phase0.Root{0x05} payload := newTestPayload(5, ourHash, big.NewInt(1_000_000_000_000)) builderSvc.GetPayloadCache().Store(payload) // Win at slot 5, canonical follow-up at slot 6. - tracker.processBlockInfo(&beacon.BlockInfo{Slot: 5, Root: ourRoot, ExecutionBlockHash: ourHash}) - - onChain6 := &beacon.BlockInfo{ - Slot: 6, Root: phase0.Root{0x06}, ParentRoot: ourRoot, - FinalitySafeExecutionBlockHash: ourHash, - } + tracker.processBlockInfo(winBlock) tracker.processBlockInfo(onChain6) // Reorg: a competing slot-5 block and a slot-6 head on top of it. - competing5 := &beacon.BlockInfo{ - Slot: 5, Root: phase0.Root{0x55}, ExecutionBlockHash: phase0.Hash32{0x55}, - } tracker.processBlockInfo(competing5) tracker.processBlockInfo(&beacon.BlockInfo{ Slot: 6, Root: phase0.Root{0x66}, ParentRoot: competing5.Root, @@ -195,16 +198,20 @@ func TestInclusionTracker_PaymentStateLogging(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { logger, hook := newHookedLogger() + + ourHash := phase0.Hash32{0xab} + ourRoot := phase0.Root{0x05} + winBlock := &beacon.BlockInfo{Slot: 5, Root: ourRoot, ExecutionBlockHash: ourHash} + chainSvc := &stubChainService{currentFork: version.DataVersionGloas} + chainSvc.primeHeadTracker(logger, winBlock) builderSvc := newTestBuilderSvc(chainSvc) tracker := NewInclusionTracker(nil, chainSvc, builderSvc, nil, nil, logger) - ourHash := phase0.Hash32{0xab} - ourRoot := phase0.Root{0x05} payload := newTestPayload(5, ourHash, big.NewInt(1_000_000_000_000)) builderSvc.GetPayloadCache().Store(payload) - tracker.processBlockInfo(&beacon.BlockInfo{Slot: 5, Root: ourRoot, ExecutionBlockHash: ourHash}) + tracker.processBlockInfo(winBlock) if tt.revealed { payload.MarkRevealed(payload_builder.RevealRecord{ @@ -386,3 +393,53 @@ func TestWonBlockCodecRoundTrip(t *testing.T) { _, err = codec.DecodeValue([]byte("not json")) require.Error(t, err) } + +func TestInclusionTracker_OrphanDisputesPaymentAndUnmarksWin(t *testing.T) { + logger, _ := newHookedLogger() + + ourHash := phase0.Hash32{0xab} + ourRoot := phase0.Root{0x05} + winBlock := &beacon.BlockInfo{Slot: 5, Root: ourRoot, ExecutionBlockHash: ourHash} + competing5 := &beacon.BlockInfo{ + Slot: 5, Root: phase0.Root{0x55}, ExecutionBlockHash: phase0.Hash32{0x55}, + } + onChain6 := &beacon.BlockInfo{ + Slot: 6, Root: phase0.Root{0x06}, ParentRoot: ourRoot, + FinalitySafeExecutionBlockHash: ourHash, + } + + chainSvc := &stubChainService{currentFork: version.DataVersionGloas} + chainSvc.primeHeadTracker(logger, winBlock, competing5, onChain6) + builderSvc := newTestBuilderSvc(chainSvc) + payments := NewPaymentTracker(chainSvc, logger) + tracker := NewInclusionTracker(nil, chainSvc, builderSvc, nil, payments, logger) + + payload := newTestPayload(5, ourHash, big.NewInt(1_000_000_000_000)) + builderSvc.GetPayloadCache().Store(payload) + + // Win at slot 5: pending payment recorded... (payments require revealSvc + // too in checkForOurPayload, so record directly). + payments.RecordWonBid(5, 1000) + tracker.processBlockInfo(winBlock) + + require.Equal(t, uint64(1000), payments.GetTotalPendingPayments()) + + // Canonical follow-up, then a reorg replacing our slot-5 block. + tracker.processBlockInfo(onChain6) + require.Equal(t, uint64(1000), payments.GetTotalPendingPayments()) + + tracker.processBlockInfo(&beacon.BlockInfo{ + Slot: 6, Root: phase0.Root{0x66}, ParentRoot: competing5.Root, + FinalitySafeExecutionBlockHash: competing5.ExecutionBlockHash, + }) + assert.Zero(t, payments.GetTotalPendingPayments(), + "orphaned win must dispute the pending payment") + + // The chain switches back: the payment is restored. + tracker.processBlockInfo(&beacon.BlockInfo{ + Slot: 7, Root: phase0.Root{0x07}, ParentRoot: onChain6.Root, + FinalitySafeExecutionBlockHash: phase0.Hash32{0x06}, + }) + assert.Equal(t, uint64(1000), payments.GetTotalPendingPayments(), + "re-canonical win must restore the pending payment") +} diff --git a/pkg/payload_bidder/mockchain_test.go b/pkg/payload_bidder/mockchain_test.go index 0f8231b..99ded49 100644 --- a/pkg/payload_bidder/mockchain_test.go +++ b/pkg/payload_bidder/mockchain_test.go @@ -28,10 +28,21 @@ type stubChainService struct { currentFork version.DataVersion currentEpoch phase0.Epoch genesis beacon.Genesis + headTracker *chain.HeadTracker epochStatsDispatch utils.Dispatcher[*chain.EpochStats] } +// primeHeadTracker equips the stub with an offline head tracker whose +// ancestry cache holds the given blocks (no beacon client; cache misses +// error out instead of fetching). +func (m *stubChainService) primeHeadTracker(log logrus.FieldLogger, blocks ...*beacon.BlockInfo) { + m.headTracker = chain.NewHeadTracker(nil, m.GetChainSpec(), &m.genesis, log) + for _, block := range blocks { + m.headTracker.PrimeBlock(block) + } +} + var _ chain.Service = (*stubChainService)(nil) func (m *stubChainService) Start(context.Context) error { return nil } @@ -77,6 +88,7 @@ func (m *stubChainService) SubscribeEpochStats() *utils.Subscription[*chain.Epoc } func (m *stubChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return nil } +func (m *stubChainService) GetHeadTracker() *chain.HeadTracker { return m.headTracker } func (m *stubChainService) GetFinalizedEpoch() phase0.Epoch { return 0 } func (m *stubChainService) GetBuilderByIndex(uint64) *chain.BuilderInfo { return nil } diff --git a/pkg/payload_bidder/payment_tracker.go b/pkg/payload_bidder/payment_tracker.go index ced6243..8b3fcb6 100644 --- a/pkg/payload_bidder/payment_tracker.go +++ b/pkg/payload_bidder/payment_tracker.go @@ -14,6 +14,9 @@ type PendingPayment struct { Slot phase0.Slot Epoch phase0.Epoch Value uint64 // Gwei + // Disputed marks a payment whose winning block was reorged out: it no + // longer counts toward the pending total unless the block returns. + Disputed bool } // PaymentTracker tracks the builder's payment obligations and live balance @@ -145,7 +148,8 @@ func (t *PaymentTracker) ReconcileToEpoch(snapshotEpoch phase0.Epoch) { t.adjustmentEpoch = snapshotEpoch } -// GetTotalPendingPayments returns the sum of unrevealed won bid obligations. +// GetTotalPendingPayments returns the sum of unrevealed won bid obligations +// (disputed payments — winning block reorged out — excluded). func (t *PaymentTracker) GetTotalPendingPayments() uint64 { t.pendingMu.Lock() defer t.pendingMu.Unlock() @@ -153,12 +157,43 @@ func (t *PaymentTracker) GetTotalPendingPayments() uint64 { var total uint64 for _, p := range t.pendingPayments { + if p.Disputed { + continue + } + total += p.Value } return total } +// SetPaymentDisputed flags (or clears) a pending payment whose winning block +// was reorged out. An already settled payment (revealed and deducted) cannot +// be rolled back locally — the on-chain payment quorum decides its fate — so +// the dispute is only logged in that case. +func (t *PaymentTracker) SetPaymentDisputed(slot phase0.Slot, disputed bool) { + t.pendingMu.Lock() + payment, ok := t.pendingPayments[slot] + + if ok { + payment.Disputed = disputed + } + t.pendingMu.Unlock() + + logCtx := t.log.WithFields(logrus.Fields{ + "slot": slot, + "disputed": disputed, + }) + + switch { + case ok: + logCtx.Info("Updated pending payment dispute state (reorg)") + case disputed: + logCtx.Warn("Winning block reorged out after the payment settled locally — " + + "the on-chain payment quorum decides the final outcome") + } +} + // PruneExpiredPayments removes pending payments older than 2 epochs. func (t *PaymentTracker) PruneExpiredPayments(currentEpoch phase0.Epoch) { t.pendingMu.Lock() diff --git a/pkg/payload_bidder/reveal_service.go b/pkg/payload_bidder/reveal_service.go index 4e37324..c3faff1 100644 --- a/pkg/payload_bidder/reveal_service.go +++ b/pkg/payload_bidder/reveal_service.go @@ -334,13 +334,23 @@ func (s *RevealService) schedule(req *RevealRequest) { slot := req.Payload.Attributes.ProposalSlot - if _, exists := s.pending[slot]; exists { + if existing, exists := s.pending[slot]; exists { + if !s.shouldRebind(slot, existing, req) { + s.log.WithFields(logrus.Fields{ + "slot": slot, + "transport": req.Transport, + }).Debug("Duplicate reveal request for slot, ignoring") + + return + } + s.log.WithFields(logrus.Fields{ - "slot": slot, - "transport": req.Transport, - }).Debug("Duplicate reveal request for slot, ignoring") + "slot": slot, + "old_root": fmt.Sprintf("%#x", existing.req.BlockInfo.Root[:8]), + "new_root": fmt.Sprintf("%#x", req.BlockInfo.Root[:8]), + }).Warn("Re-binding reveal to a different beacon block (reorg): rebuilding the envelope") - return + delete(s.pending, slot) } frozen := s.planSvc.Freeze(slot) @@ -462,6 +472,46 @@ func (s *RevealService) schedule(req *RevealRequest) { }).Debug("Scheduled payload reveal") } +// shouldRebind decides whether a second reveal request for an already +// scheduled slot replaces the schedule: only when re-binding is enabled, the +// request targets a different beacon block, and the previously bound block is +// no longer canonical (our payload was re-included under a sibling root after +// a reorg). The rebuilt envelope is re-signed for the new root — the envelope +// signature covers the beacon block root, so the old one cannot be reused. +func (s *RevealService) shouldRebind(slot phase0.Slot, existing *revealState, req *RevealRequest) bool { + if !s.cfg.Reveal.RebindOnReorg { + return false + } + + if existing.req == nil || existing.req.BlockInfo == nil { + return false + } + + oldRoot := existing.req.BlockInfo.Root + if req.BlockInfo.Root == oldRoot { + return false + } + + // Confirm the old block actually left the canonical chain when the chain + // view can tell; the request itself (fired from a head observation of the + // new block) is the fallback evidence. + if headTracker := s.chainSvc.GetHeadTracker(); headTracker != nil { + ctx, cancel := context.WithTimeout(s.ctx, 3*time.Second) + defer cancel() + + if headTracker.IsCanonical(ctx, oldRoot) { + s.log.WithFields(logrus.Fields{ + "slot": slot, + "old_root": fmt.Sprintf("%#x", oldRoot[:8]), + }).Warn("Ignoring reveal re-bind request: the bound block is still canonical") + + return false + } + } + + return true +} + // processDue publishes every pending reveal whose attempt time has come and // whose gates are open, expires unsatisfied vote gates, handles success // bookkeeping and bounded retries, then prunes stale entries. diff --git a/pkg/payload_builder/attributes.go b/pkg/payload_builder/attributes.go new file mode 100644 index 0000000..dc11895 --- /dev/null +++ b/pkg/payload_builder/attributes.go @@ -0,0 +1,114 @@ +package payload_builder + +import ( + "context" + "fmt" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/spec/version" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" +) + +// attrSanitizeTimeout bounds the chain-view lookups a single attributes +// sanitization may perform. +const attrSanitizeTimeout = 3 * time.Second + +// sanitizeAttributes validates a payload-attributes event against the chain +// view and returns a corrected copy when the event is inconsistent (the +// original is returned untouched when it is fine or cannot be verified). +// +// Corrections applied: +// - A Gloas event whose execution parent hash is the beacon parent's +// committed payload hash while that payload is known withheld references +// an execution block that does not exist (a forkchoiceUpdated on it can +// never resolve); the hash is redirected to the parent's own execution +// parent — the last actually built block. +// - A missing execution parent block number is backfilled from the chain +// view (several clients omit it under Gloas). +func (s *Service) sanitizeAttributes(event *beacon.PayloadAttributesEvent) *beacon.PayloadAttributesEvent { + headTracker := s.chainSvc.GetHeadTracker() + if headTracker == nil { + return event + } + + parentEpoch := phase0.Epoch(0) + if event.ProposalSlot > 0 { + parentEpoch = s.chainSvc.GetEpochOfSlot(event.ProposalSlot - 1) + } + + if s.chainSvc.ActiveForkAtEpoch(parentEpoch) < version.DataVersionGloas { + return event + } + + ctx, cancel := context.WithTimeout(s.ctx, attrSanitizeTimeout) + defer cancel() + + parentBlock, err := headTracker.GetBlock(ctx, event.ParentBlockRoot) + if err != nil { + s.log.WithError(err).WithFields(logrus.Fields{ + "slot": event.ProposalSlot, + "parent_root": fmt.Sprintf("%#x", event.ParentBlockRoot[:8]), + }).Debug("Cannot verify payload attributes parent (block unknown)") + + return event + } + + sanitized := event + + // The event claims a full parent whose payload the chain view knows was + // withheld: the referenced execution block was never revealed and cannot + // be built on. Redirect to the parent's own execution parent. The + // expected withdrawals differ between the full and empty parent (they + // stay unchanged when the parent payload is withheld), so they are + // re-sourced from the parent block's own slot attributes when available. + if event.ParentBlockHash == parentBlock.ExecutionBlockHash && + parentBlock.ExecutionBlockHash != parentBlock.FinalitySafeExecutionBlockHash && + headTracker.GetPayloadStatus(parentBlock.Root) == chain.PayloadStatusEmpty { + corrected := *sanitized + corrected.ParentBlockHash = parentBlock.FinalitySafeExecutionBlockHash + corrected.ParentBlockNumber = 0 + + var parentSlotAttrs *beacon.PayloadAttributesEvent + if s.clClient != nil { + parentSlotAttrs = s.clClient.Events().GetLatestPayloadAttributes(parentBlock.Slot) + } + + if parentSlotAttrs != nil { + corrected.Withdrawals = parentSlotAttrs.Withdrawals + } else { + s.log.WithFields(logrus.Fields{ + "slot": event.ProposalSlot, + "parent_slot": parentBlock.Slot, + }).Warn("No attributes for the parent block's slot, " + + "redirected build keeps the (possibly wrong) full-parent withdrawals") + } + + sanitized = &corrected + + s.log.WithFields(logrus.Fields{ + "slot": event.ProposalSlot, + "claimed_parent": fmt.Sprintf("%x", event.ParentBlockHash[:8]), + "actual_parent": fmt.Sprintf("%x", corrected.ParentBlockHash[:8]), + }).Warn("Payload attributes reference an unrevealed execution payload, " + + "redirecting to the last built execution block") + } + + if sanitized.ParentBlockNumber == 0 { + number, _ := headTracker.LookupELParentMeta( + ctx, sanitized.ParentBlockRoot, sanitized.ParentBlockHash) + if number != 0 { + if sanitized == event { + corrected := *event + sanitized = &corrected + } + + sanitized.ParentBlockNumber = number + } + } + + return sanitized +} diff --git a/pkg/payload_builder/attributes_test.go b/pkg/payload_builder/attributes_test.go new file mode 100644 index 0000000..7ac0e04 --- /dev/null +++ b/pkg/payload_builder/attributes_test.go @@ -0,0 +1,205 @@ +package payload_builder + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/spec/version" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/action_plan" + "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" +) + +// newPrimedHeadTracker creates an offline head tracker (no beacon client) +// with the given blocks in its ancestry cache, on a Gloas-from-genesis spec +// whose genesis lies an hour in the past. +func newPrimedHeadTracker(spec *chain.ChainSpec, blocks ...*beacon.BlockInfo) *chain.HeadTracker { + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + gloasSpec := *spec + gloasSpec.ForkSchedule = []chain.ForkSchedule{ + {Fork: version.DataVersionGloas, Version: phase0.Version{0x01}, Epoch: 0}, + } + + tracker := chain.NewHeadTracker(nil, &gloasSpec, + &beacon.Genesis{GenesisTime: time.Now().Add(-time.Hour)}, log) + for _, block := range blocks { + tracker.PrimeBlock(block) + } + + return tracker +} + +// sanitizeTestSetup builds a service whose chain stub carries an offline head +// tracker primed with the given blocks (genesis an hour in the past, so every +// Gloas payload without reveal evidence resolves to empty). +func sanitizeTestSetup(t *testing.T, blocks ...*beacon.BlockInfo) *Service { + t.Helper() + + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + spec := &chain.ChainSpec{ + SecondsPerSlot: 12 * time.Second, + SlotsPerEpoch: 32, + PayloadDueBps: 5000, + } + chainSvc := &stubChainService{spec: spec} + chainSvc.headTracker = newPrimedHeadTracker(spec, blocks...) + + cfg := config.DefaultConfig() + planSvc := action_plan.NewPlanService(cfg, chainSvc, log) + + svc, err := NewService(cfg, nil, chainSvc, planSvc, nil, common.Address{}, log) + require.NoError(t, err) + + svc.ctx = context.Background() + + return svc +} + +func TestSanitizeAttributes_UnrevealedParentPayload(t *testing.T) { + parentBlock := &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe3}, + } + svc := sanitizeTestSetup(t, parentBlock) + + // The event claims the parent's committed payload as execution parent, + // but that payload was never revealed (past the deadline, no evidence). + event := &beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parentBlock.Root, + ParentBlockHash: parentBlock.ExecutionBlockHash, + ParentBlockNumber: 44, + } + + sanitized := svc.sanitizeAttributes(event) + require.NotSame(t, event, sanitized, "correction must copy, never mutate the cached event") + assert.Equal(t, parentBlock.FinalitySafeExecutionBlockHash, sanitized.ParentBlockHash, + "execution parent must be redirected to the last built block") + assert.Equal(t, parentBlock.Root, sanitized.ParentBlockRoot, "beacon parent stays") + assert.Equal(t, event.ParentBlockHash, phase0.Hash32{0xe4}, "original event untouched") +} + +func TestSanitizeAttributes_ConsistentEventUnchanged(t *testing.T) { + parentBlock := &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe3}, + } + svc := sanitizeTestSetup(t, parentBlock) + + // The event already references the empty-parent execution block. + event := &beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parentBlock.Root, + ParentBlockHash: parentBlock.FinalitySafeExecutionBlockHash, + ParentBlockNumber: 43, + } + + assert.Same(t, event, svc.sanitizeAttributes(event)) +} + +func TestSanitizeAttributes_BackfillsParentBlockNumber(t *testing.T) { + // A parent whose committed and finality-safe hashes agree (no empty + // variant) and whose execution block number is known. + parentBlock := &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe4}, + ExecutionBlockNumber: 44, + } + svc := sanitizeTestSetup(t, parentBlock) + + event := &beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parentBlock.Root, + ParentBlockHash: parentBlock.ExecutionBlockHash, + } + + sanitized := svc.sanitizeAttributes(event) + require.NotSame(t, event, sanitized) + assert.Equal(t, uint64(44), sanitized.ParentBlockNumber) + assert.Equal(t, uint64(0), event.ParentBlockNumber, "original event untouched") +} + +func TestSanitizeAttributes_UnknownParentUnchanged(t *testing.T) { + svc := sanitizeTestSetup(t) + + event := &beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: phase0.Root{0xff}, + ParentBlockHash: phase0.Hash32{0xff}, + } + + assert.Same(t, event, svc.sanitizeAttributes(event)) +} + +func TestHandlePayloadAttributes_RescheduleOnParentChange(t *testing.T) { + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + spec := &chain.ChainSpec{ + SecondsPerSlot: 12 * time.Second, + SlotsPerEpoch: 32, + } + chainSvc := &stubChainService{spec: spec} + + cfg := config.DefaultConfig() + cfg.EPBSEnabled = true + planSvc := action_plan.NewPlanService(cfg, chainSvc, log) + + clClient, err := beacon.NewClient(context.Background(), "http://127.0.0.1:1", log) + require.NoError(t, err) + + svc, err := NewService(cfg, clClient, chainSvc, planSvc, nil, common.Address{}, log) + require.NoError(t, err) + + svc.ctx = context.Background() + + slot := phase0.Slot(200) + first := &beacon.PayloadAttributesEvent{ + ProposalSlot: slot, + ParentBlockRoot: phase0.Root{0x01}, + ParentBlockHash: phase0.Hash32{0xaa}, + } + + svc.handlePayloadAttributesEvent(first) + + svc.scheduledBuildMu.Lock() + state := svc.slotBuilds[slot] + svc.scheduledBuildMu.Unlock() + require.NotNil(t, state) + assert.True(t, state.passScheduled, "first attributes event schedules the build pass") + + // Later events for the same slot — same or different parent — accumulate + // as variants without rescheduling; the pass resolves them at fire time. + svc.handlePayloadAttributesEvent(first) + + reorged := &beacon.PayloadAttributesEvent{ + ProposalSlot: slot, + ParentBlockRoot: phase0.Root{0x02}, + ParentBlockHash: phase0.Hash32{0xbb}, + } + svc.handlePayloadAttributesEvent(reorged) + + svc.scheduledBuildMu.Lock() + state = svc.slotBuilds[slot] + svc.scheduledBuildMu.Unlock() + assert.True(t, state.passScheduled) + assert.Empty(t, state.started, "no candidate build starts before the pass fires") +} diff --git a/pkg/payload_builder/build_skip_test.go b/pkg/payload_builder/build_skip_test.go index 293548f..c02b53d 100644 --- a/pkg/payload_builder/build_skip_test.go +++ b/pkg/payload_builder/build_skip_test.go @@ -21,11 +21,16 @@ import ( type stubChainService struct { chain.Service - spec *chain.ChainSpec + spec *chain.ChainSpec + headTracker *chain.HeadTracker } func (s *stubChainService) GetChainSpec() *chain.ChainSpec { return s.spec } func (s *stubChainService) GetCurrentSlot() phase0.Slot { return 100 } +func (s *stubChainService) SlotToTime(phase0.Slot) time.Time { + // Far in the future: scheduled builds stay pending for the test lifetime. + return time.Now().Add(time.Hour) +} func (s *stubChainService) GetEpochOfSlot(slot phase0.Slot) phase0.Epoch { return phase0.Epoch(uint64(slot) / s.spec.SlotsPerEpoch) } @@ -35,6 +40,9 @@ func (s *stubChainService) ActiveForkAtEpoch(_ phase0.Epoch) version.DataVersion func (s *stubChainService) SubscribeEpochStats() *utils.Subscription[*chain.EpochStats] { return (&utils.Dispatcher[*chain.EpochStats]{}).Subscribe(1, false) } +func (s *stubChainService) GetEpochStats(phase0.Epoch) *chain.EpochStats { return nil } +func (s *stubChainService) GetHeadTracker() *chain.HeadTracker { return s.headTracker } +func (s *stubChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return nil } func newSkipTestService(t *testing.T, cfg *config.Config) (*Service, *action_plan.PlanService) { t.Helper() diff --git a/pkg/payload_builder/candidates.go b/pkg/payload_builder/candidates.go new file mode 100644 index 0000000..f3142ae --- /dev/null +++ b/pkg/payload_builder/candidates.go @@ -0,0 +1,448 @@ +package payload_builder + +import ( + "context" + "fmt" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" +) + +// buildTarget is one payload build the slot's build pass will run: which +// candidate it is (empty when unclassified), the attributes to build from, +// and whether those attributes were synthesized locally. +type buildTarget struct { + candidate chain.CandidateKey + attrs *beacon.PayloadAttributesEvent + derived bool +} + +// candidateBuildOrder lists candidate keys in the order a sequential build +// pass runs them: the canonical candidate first, so it starts at exactly the +// configured build start time and is never delayed by speculative builds +// (payload attributes typically arrive barely one build ahead of the slot). +// The speculative builds follow; the EL head they leave behind is transient — +// the beacon node's own forkchoiceUpdated calls drive it back to the +// canonical chain, and the next slot's canonical build targets it again. +var candidateBuildOrder = []chain.CandidateKey{ + chain.CandidateParentFull, + chain.CandidateParentEmpty, + chain.CandidateGrandparentFull, + chain.CandidateGrandparentEmpty, +} + +// resolveBuildTargets assembles the slot's build list from the configured +// candidate policy, the chain view's candidate tuples, the received +// attribute variants and local synthesis. CL-received variants whose parent +// tuple matches no known candidate are appended unclassified (the beacon +// node's own suggestion is always honored). +func (s *Service) resolveBuildTargets(slot phase0.Slot) []*buildTarget { + variants := make([]*beacon.PayloadAttributesEvent, 0, 4) + for _, event := range s.clClient.Events().GetPayloadAttributesVariants(slot) { + variants = append(variants, s.sanitizeAttributes(event)) + } + + candidates := s.resolveChainCandidates(slot) + + candidateByKey := make(map[chain.CandidateKey]*chain.CandidateParent, len(candidates)) + candidateByTuple := make(map[beacon.AttrParentKey]*chain.CandidateParent, len(candidates)) + + for _, candidate := range candidates { + tuple := beacon.AttrParentKey{Root: candidate.ParentBlockRoot, Hash: candidate.ParentBlockHash} + candidateByKey[candidate.Key] = candidate + candidateByTuple[tuple] = candidate + } + + variantByTuple := make(map[beacon.AttrParentKey]*beacon.PayloadAttributesEvent, len(variants)) + for _, event := range variants { + variantByTuple[beacon.AttrParentKeyOf(event)] = event + } + + targets := make([]*buildTarget, 0, len(candidateBuildOrder)+len(variants)) + covered := make(map[beacon.AttrParentKey]bool, len(candidateBuildOrder)+len(variants)) + + for _, key := range candidateBuildOrder { + candidate := candidateByKey[key] + if candidate == nil { + continue + } + + mode := s.candidateMode(slot, key) + if mode == config.CandidateModeNever || mode == "" { + continue + } + + if mode == config.CandidateModeAuto && !s.candidateAutoSignal(candidate) { + continue + } + + tuple := beacon.AttrParentKey{Root: candidate.ParentBlockRoot, Hash: candidate.ParentBlockHash} + + attrs := variantByTuple[tuple] + derived := false + + if attrs == nil { + synthesized, err := s.synthesizeCandidateAttributes(slot, candidate) + if err != nil { + s.log.WithError(err).WithFields(logrus.Fields{ + "slot": slot, + "candidate": key, + }).Info("Cannot synthesize candidate attributes, skipping candidate") + + continue + } + + attrs = synthesized + derived = true + } + + targets = append(targets, &buildTarget{candidate: key, attrs: attrs, derived: derived}) + covered[tuple] = true + } + + // CL variants outside the covered set: build unclassified ones as-is + // (unknown branches, head races — the beacon node's own suggestion is + // honored), but variants classifying to a policy-suppressed candidate + // stay suppressed. + for _, event := range variants { + tuple := beacon.AttrParentKeyOf(event) + if covered[tuple] { + continue + } + + target := &buildTarget{attrs: event} + if candidate := candidateByTuple[tuple]; candidate != nil { + if s.candidateMode(slot, candidate.Key) == config.CandidateModeNever { + s.log.WithFields(logrus.Fields{ + "slot": slot, + "candidate": candidate.Key, + }).Debug("Suppressing received attribute variant (policy: never)") + + continue + } + + target.candidate = candidate.Key + } + + targets = append(targets, target) + covered[tuple] = true + } + + return targets +} + +// candidateMode returns the slot's effective build mode for a candidate key: +// the frozen plan's resolved candidate policy (global config merged with the +// slot's plan overrides), falling back to the live config when the frozen +// snapshot carries none. +func (s *Service) candidateMode(slot phase0.Slot, key chain.CandidateKey) string { + if frozen := s.planSvc.Freeze(slot); frozen.Build != nil && frozen.Build.CandidateModes != nil { + if mode, ok := frozen.Build.CandidateModes[string(key)]; ok { + return mode + } + } + + return s.cfg.Build.CandidateMode(string(key)) +} + +// resolveChainCandidates fetches the chain view's build-parent candidates for +// the slot (nil when the chain view has no usable head yet). +func (s *Service) resolveChainCandidates(slot phase0.Slot) []*chain.CandidateParent { + headTracker := s.chainSvc.GetHeadTracker() + if headTracker == nil { + return nil + } + + ctx, cancel := context.WithTimeout(s.ctx, attrSanitizeTimeout) + defer cancel() + + candidates, err := headTracker.ResolveCandidates(ctx, slot) + if err != nil { + s.log.WithError(err).WithField("slot", slot).Debug("Cannot resolve build candidates") + return nil + } + + return candidates +} + +// classifyCandidate returns the candidate key matching the event's parent +// tuple relative to the current chain view ("" when unknown). +func (s *Service) classifyCandidate(event *beacon.PayloadAttributesEvent) chain.CandidateKey { + for _, candidate := range s.resolveChainCandidates(event.ProposalSlot) { + if candidate.ParentBlockRoot == event.ParentBlockRoot && + candidate.ParentBlockHash == event.ParentBlockHash { + return candidate.Key + } + } + + return "" +} + +// candidateAutoSignal decides whether an auto-mode candidate should build, +// from live chain signals: the parent payload's reveal status for the +// full/empty choice, and head-vote weakness of the head block for the reorg +// candidates. +func (s *Service) candidateAutoSignal(candidate *chain.CandidateParent) bool { + switch candidate.Key { + case chain.CandidateParentFull: + // Pointless only when the parent payload is known withheld. + return candidate.ParentPayloadStatus != chain.PayloadStatusEmpty + + case chain.CandidateParentEmpty: + // Needed unless the parent payload is confirmed revealed. + return candidate.ParentPayloadStatus != chain.PayloadStatusRevealed + + case chain.CandidateGrandparentFull: + return s.headContested() + + case chain.CandidateGrandparentEmpty: + return s.headContested() && candidate.ParentPayloadStatus != chain.PayloadStatusRevealed + + default: + return false + } +} + +// headContested reports whether the current head block's attestation +// participation is below the configured weak-head threshold — the signal that +// the next proposer may reorg it out. +func (s *Service) headContested() bool { + threshold := s.cfg.Build.AutoWeakHeadPct + if threshold == 0 { + return false + } + + headTracker := s.chainSvc.GetHeadTracker() + voteTracker := s.chainSvc.GetHeadVoteTracker() + + if headTracker == nil || voteTracker == nil { + return false + } + + head := headTracker.CurrentHead() + if head == nil { + return false + } + + update, ok := voteTracker.GetParticipation(head.Slot, head.Root) + if !ok { + return false + } + + return update.ParticipationPct < float64(threshold) +} + +// synthesizeCandidateAttributes derives buildable attributes for a candidate +// no CL variant covers, from the cached attribute variants of this and the +// parent block's slot: +// +// - parent_empty takes a same-beacon-parent variant of this slot as base, +// swaps the execution parent to the candidate's, and sources the +// withdrawals from the parent block's own slot attributes (the expected +// withdrawals list is unchanged when the parent payload is withheld). +// - grandparent candidates re-target the parent block's slot attributes +// (whose parent already is the grandparent) at this slot: proposal slot, +// timestamp and proposer advance, everything else carries over. For the +// empty variant the execution parent and withdrawals are swapped like +// above. Synthesis across an epoch boundary is refused (the carried +// prev_randao would be stale). +// - parent_full cannot be synthesized: it requires a freshly computed +// withdrawals list only the beacon node has. +func (s *Service) synthesizeCandidateAttributes( + slot phase0.Slot, candidate *chain.CandidateParent, +) (*beacon.PayloadAttributesEvent, error) { + events := s.clClient.Events() + + switch candidate.Key { + case chain.CandidateParentEmpty: + base := s.findVariantWithParentRoot(slot, candidate.ParentBlockRoot) + if base == nil { + return nil, fmt.Errorf("no attribute variant with the same beacon parent") + } + + withdrawalsSrc := events.GetLatestPayloadAttributes(candidate.ParentSlot) + if withdrawalsSrc == nil { + return nil, fmt.Errorf("no attributes for the parent block's slot %d "+ + "(withdrawals source)", candidate.ParentSlot) + } + + synthesized := *base + synthesized.ParentBlockHash = candidate.ParentBlockHash + synthesized.ParentBlockNumber = candidate.ELParentNumber + synthesized.Withdrawals = withdrawalsSrc.Withdrawals + + return &synthesized, nil + + case chain.CandidateGrandparentFull, chain.CandidateGrandparentEmpty: + // The parent block's slot attributes already build on the + // grandparent; the base must match the candidate's beacon parent. + base := s.findVariantWithParentRoot(candidate.ParentSlot+1, candidate.ParentBlockRoot) + if base == nil { + base = s.findLatestVariantWithParentRoot(candidate.ParentBlockRoot, slot) + } + + if base == nil { + return nil, fmt.Errorf("no attribute variant building on the grandparent") + } + + spec := s.chainSvc.GetChainSpec() + if uint64(slot)/spec.SlotsPerEpoch != uint64(base.ProposalSlot)/spec.SlotsPerEpoch { + return nil, fmt.Errorf("base attributes from slot %d cross an epoch boundary "+ + "(stale prev_randao)", base.ProposalSlot) + } + + synthesized := *base + synthesized.ProposalSlot = slot + synthesized.Timestamp = base.Timestamp + + uint64(slot-base.ProposalSlot)*uint64(spec.SecondsPerSlot.Seconds()) + + if proposer, ok := s.lookupProposer(slot); ok { + synthesized.ProposerIndex = proposer + } + + if candidate.Key == chain.CandidateGrandparentEmpty { + withdrawalsSrc := events.GetLatestPayloadAttributes(candidate.ParentSlot) + if withdrawalsSrc == nil { + return nil, fmt.Errorf("no attributes for the grandparent block's slot %d "+ + "(withdrawals source)", candidate.ParentSlot) + } + + synthesized.ParentBlockHash = candidate.ParentBlockHash + synthesized.ParentBlockNumber = candidate.ELParentNumber + synthesized.Withdrawals = withdrawalsSrc.Withdrawals + } + + return &synthesized, nil + + default: + return nil, fmt.Errorf("candidate %s requires beacon-node attributes", candidate.Key) + } +} + +// findVariantWithParentRoot returns a cached attribute variant of the given +// slot whose beacon parent matches root (any execution parent), or nil. +func (s *Service) findVariantWithParentRoot( + slot phase0.Slot, root phase0.Root, +) *beacon.PayloadAttributesEvent { + for _, event := range s.clClient.Events().GetPayloadAttributesVariants(slot) { + if event.ParentBlockRoot == root { + return s.sanitizeAttributes(event) + } + } + + return nil +} + +// findLatestVariantWithParentRoot searches backwards from beforeSlot for the +// newest cached variant building on the given beacon parent. +func (s *Service) findLatestVariantWithParentRoot( + root phase0.Root, beforeSlot phase0.Slot, +) *beacon.PayloadAttributesEvent { + for lookback := phase0.Slot(1); lookback <= attrFallbackLookback && lookback <= beforeSlot; lookback++ { + if event := s.findVariantWithParentRoot(beforeSlot-lookback, root); event != nil { + return event + } + } + + return nil +} + +// onDemandPollInterval paces waiting for an in-flight build when an +// on-demand request races the slot's regular build pass. +const onDemandPollInterval = 50 * time.Millisecond + +// BuildCandidateOnDemand returns the slot's payload for the given parent +// tuple, building it on the fly when no candidate covers it yet (used by the +// Builder API to serve a proposer requesting a legal but unbuilt parent). +// Bounded by ctx; a tuple whose attributes cannot be resolved (unknown to the +// chain view and no received variant) fails. +func (s *Service) BuildCandidateOnDemand( + ctx context.Context, slot phase0.Slot, parentRoot phase0.Root, parentHash phase0.Hash32, +) (*Payload, error) { + tuple := beacon.AttrParentKey{Root: parentRoot, Hash: parentHash} + + if payload := s.payloadCache.GetVariant(slot, tuple); payload != nil { + return payload, nil + } + + target := s.resolveOnDemandTarget(slot, tuple) + if target == nil { + return nil, fmt.Errorf("cannot resolve build attributes for parent %#x/%#x", parentRoot, parentHash) + } + + // Synchronous build; a concurrent build of the same tuple (regular pass) + // makes this a no-op and the wait below picks up its result. + s.executeCandidateBuild(slot, target) + + for { + if payload := s.payloadCache.GetVariant(slot, tuple); payload != nil { + return payload, nil + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("on-demand build did not complete: %w", ctx.Err()) + case <-time.After(onDemandPollInterval): + } + } +} + +// resolveOnDemandTarget constructs the build target for an explicitly +// requested parent tuple: a received attribute variant when present, +// otherwise synthesis for a chain-view candidate matching the tuple. +func (s *Service) resolveOnDemandTarget(slot phase0.Slot, tuple beacon.AttrParentKey) *buildTarget { + if variant := s.clClient.Events().GetPayloadAttributesVariant(slot, tuple); variant != nil { + sanitized := s.sanitizeAttributes(variant) + if beacon.AttrParentKeyOf(sanitized) == tuple { + return &buildTarget{candidate: s.classifyCandidate(sanitized), attrs: sanitized} + } + } + + for _, candidate := range s.resolveChainCandidates(slot) { + if candidate.ParentBlockRoot != tuple.Root || candidate.ParentBlockHash != tuple.Hash { + continue + } + + attrs, err := s.synthesizeCandidateAttributes(slot, candidate) + if err != nil { + s.log.WithError(err).WithFields(logrus.Fields{ + "slot": slot, + "candidate": candidate.Key, + }).Info("Cannot synthesize attributes for on-demand build") + + return nil + } + + return &buildTarget{candidate: candidate.Key, attrs: attrs, derived: true} + } + + return nil +} + +// candidateBuildTime returns the EL build time for a target. Parallel builds +// all run inside the same designated build window, so every candidate gets +// the full build time; only serialized builds shorten the speculative ones to +// fit them alongside the canonical build. +func (s *Service) candidateBuildTime(target *buildTarget) uint64 { + if s.cfg.Build.Parallel { + return s.cfg.PayloadBuildTime + } + + if target.candidate != chain.CandidateParentFull && target.candidate != "" && + s.cfg.Build.SpeculativeBuildTimeMs != 0 { + return s.cfg.Build.SpeculativeBuildTimeMs + } + + return s.cfg.PayloadBuildTime +} + +// slotEndTime returns the wall-clock end of a slot (the bound for late +// candidate activation). +func (s *Service) slotEndTime(slot phase0.Slot) time.Time { + return s.chainSvc.SlotToTime(slot + 1) +} diff --git a/pkg/payload_builder/candidates_test.go b/pkg/payload_builder/candidates_test.go new file mode 100644 index 0000000..e00a109 --- /dev/null +++ b/pkg/payload_builder/candidates_test.go @@ -0,0 +1,218 @@ +package payload_builder + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/action_plan" + "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" +) + +// candidateTestSetup builds a service with an offline event stream and a +// chain stub whose head tracker holds the grandparent/parent chain with the +// parent as current head. +func candidateTestSetup(t *testing.T, gp, parent *beacon.BlockInfo) *Service { + t.Helper() + + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + spec := &chain.ChainSpec{ + SecondsPerSlot: 12 * time.Second, + SlotsPerEpoch: 32, + PayloadDueBps: 5000, + } + chainSvc := &stubChainService{spec: spec} + chainSvc.headTracker = newPrimedHeadTracker(spec, gp) + chainSvc.headTracker.PrimeHead(parent) + + cfg := config.DefaultConfig() + cfg.EPBSEnabled = true + + planSvc := action_plan.NewPlanService(cfg, chainSvc, log) + + clClient, err := beacon.NewClient(context.Background(), "http://127.0.0.1:1", log) + require.NoError(t, err) + + svc, err := NewService(cfg, clClient, chainSvc, planSvc, nil, common.Address{}, log) + require.NoError(t, err) + + svc.ctx = context.Background() + + return svc +} + +func TestResolveBuildTargets_PayloadMissAddsEmptyCandidate(t *testing.T) { + gp, parent := testCandidateChain() + svc := candidateTestSetup(t, gp, parent) + events := svc.clClient.Events() + + // The parent block's own slot attributes (withdrawals source for the + // empty-parent candidate). + parentSlotWithdrawals := []*capella.Withdrawal{{Index: 7}} + require.True(t, events.InjectPayloadAttributes(&beacon.PayloadAttributesEvent{ + ProposalSlot: parent.Slot, + ParentBlockRoot: gp.Root, + ParentBlockHash: gp.ExecutionBlockHash, + Withdrawals: parentSlotWithdrawals, + })) + + // The CL emitted only the full-parent variant for the target slot. + fullVariant := &beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parent.Root, + ParentBlockHash: parent.ExecutionBlockHash, + Withdrawals: []*capella.Withdrawal{{Index: 9}}, + } + require.True(t, events.InjectPayloadAttributes(fullVariant)) + + targets := svc.resolveBuildTargets(5) + require.Len(t, targets, 1) + + // The parent payload counts as withheld (genesis an hour in the past, no + // reveal evidence), so sanitization redirects the CL's full-parent + // variant to the empty-parent tuple — including the withdrawals swap — + // and the unbuildable full-parent candidate is dropped (a withheld + // payload cannot be built on and its withdrawals cannot be derived). + assert.Equal(t, chain.CandidateParentEmpty, targets[0].candidate) + assert.False(t, targets[0].derived) + assert.Equal(t, gp.ExecutionBlockHash, targets[0].attrs.ParentBlockHash, + "empty candidate builds on the grandparent's payload") + assert.Equal(t, parent.Root, targets[0].attrs.ParentBlockRoot) + assert.Equal(t, parentSlotWithdrawals, targets[0].attrs.Withdrawals, + "withdrawals come from the parent block's slot attributes") +} + +func TestResolveBuildTargets_NeverModeSuppresses(t *testing.T) { + gp, parent := testCandidateChain() + svc := candidateTestSetup(t, gp, parent) + svc.cfg.Build.CandidateParentEmpty = config.CandidateModeNever + + events := svc.clClient.Events() + require.True(t, events.InjectPayloadAttributes(&beacon.PayloadAttributesEvent{ + ProposalSlot: parent.Slot, + ParentBlockRoot: gp.Root, + ParentBlockHash: gp.ExecutionBlockHash, + })) + require.True(t, events.InjectPayloadAttributes(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parent.Root, + ParentBlockHash: parent.FinalitySafeExecutionBlockHash, + })) + + // The only received variant classifies as parent_empty, which the policy + // suppresses — nothing is built for the slot. + targets := svc.resolveBuildTargets(5) + assert.Empty(t, targets) +} + +func TestResolveBuildTargets_UnknownBranchVariantBuilt(t *testing.T) { + gp, parent := testCandidateChain() + svc := candidateTestSetup(t, gp, parent) + + // A variant on a branch the chain view does not know is still built + // (unclassified) — the beacon node's own suggestion is honored. + events := svc.clClient.Events() + require.True(t, events.InjectPayloadAttributes(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: phase0.Root{0x99}, + ParentBlockHash: phase0.Hash32{0x99}, + })) + + targets := svc.resolveBuildTargets(5) + require.NotEmpty(t, targets) + + last := targets[len(targets)-1] + assert.Equal(t, chain.CandidateKey(""), last.candidate) + assert.Equal(t, phase0.Hash32{0x99}, last.attrs.ParentBlockHash) +} + +func TestClassifyCandidate(t *testing.T) { + gp, parent := testCandidateChain() + svc := candidateTestSetup(t, gp, parent) + + assert.Equal(t, chain.CandidateParentFull, svc.classifyCandidate(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parent.Root, + ParentBlockHash: parent.ExecutionBlockHash, + })) + assert.Equal(t, chain.CandidateParentEmpty, svc.classifyCandidate(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: parent.Root, + ParentBlockHash: parent.FinalitySafeExecutionBlockHash, + })) + assert.Equal(t, chain.CandidateGrandparentFull, svc.classifyCandidate(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: gp.Root, + ParentBlockHash: gp.ExecutionBlockHash, + })) + assert.Equal(t, chain.CandidateKey(""), svc.classifyCandidate(&beacon.PayloadAttributesEvent{ + ProposalSlot: 5, + ParentBlockRoot: phase0.Root{0x99}, + ParentBlockHash: phase0.Hash32{0x99}, + })) +} + +func TestPayloadCacheCandidatePriority(t *testing.T) { + cache := NewPayloadCache(8) + + makePayload := func(key chain.CandidateKey, hash phase0.Hash32) *Payload { + return &Payload{ + Attributes: &beacon.PayloadAttributesEvent{ + ProposalSlot: 10, + ParentBlockRoot: phase0.Root{byte(hash[0])}, + ParentBlockHash: hash, + }, + Candidate: key, + BlockHash: hash, + BlockValue: big.NewInt(1), + ReadyAt: time.Now(), + } + } + + empty := makePayload(chain.CandidateParentEmpty, phase0.Hash32{0x02}) + cache.Store(empty) + assert.Same(t, empty, cache.Get(10), "only candidate wins") + + full := makePayload(chain.CandidateParentFull, phase0.Hash32{0x01}) + cache.Store(full) + assert.Same(t, full, cache.Get(10), "parent_full outranks parent_empty") + + assert.Same(t, empty, cache.GetCandidate(10, chain.CandidateParentEmpty)) + assert.Len(t, cache.GetSlotPayloads(10), 2) + assert.Same(t, empty, cache.GetByBlockHash(phase0.Hash32{0x02})) +} + +// testCandidateChain builds the grandparent (slot 3, payload E3 on E2) and +// parent (slot 4, committing E4 on E3) blocks. +func testCandidateChain() (gp, parent *beacon.BlockInfo) { + gp = &beacon.BlockInfo{ + Slot: 3, + Root: phase0.Root{0x03}, + ParentRoot: phase0.Root{0x02}, + ExecutionBlockHash: phase0.Hash32{0xe3}, + FinalitySafeExecutionBlockHash: phase0.Hash32{0xe2}, + GasLimit: 30_000_000, + } + parent = &beacon.BlockInfo{ + Slot: 4, + Root: phase0.Root{0x04}, + ParentRoot: gp.Root, + ExecutionBlockHash: phase0.Hash32{0xe4}, + FinalitySafeExecutionBlockHash: gp.ExecutionBlockHash, + GasLimit: 31_000_000, + } + + return gp, parent +} diff --git a/pkg/payload_builder/events.go b/pkg/payload_builder/events.go index f374ee1..b341167 100644 --- a/pkg/payload_builder/events.go +++ b/pkg/payload_builder/events.go @@ -11,6 +11,7 @@ import ( // the build as in-progress rather than waiting for the payload to be ready. type PayloadBuildStartedEvent struct { Slot phase0.Slot + Candidate string // candidate key the build targets ("" = unclassified) StartedAt time.Time // When the build started } @@ -18,9 +19,10 @@ type PayloadBuildStartedEvent struct { // (e.g. the WebUI) use it to mark the in-progress build as failed instead of // leaving it rendered as perpetually building. type PayloadBuildFailedEvent struct { - Slot phase0.Slot - Error string // Failure reason - FailedAt time.Time // When the build failed + Slot phase0.Slot + Candidate string // candidate key the build targeted ("" = unclassified) + Error string // Failure reason + FailedAt time.Time // When the build failed } // BuildSkippedEvent is emitted when the builder deliberately does not build diff --git a/pkg/payload_builder/gaslimit.go b/pkg/payload_builder/gaslimit.go new file mode 100644 index 0000000..40d61c6 --- /dev/null +++ b/pkg/payload_builder/gaslimit.go @@ -0,0 +1,26 @@ +package payload_builder + +// expectedBidGasLimit returns the only gas limit a Gloas bid may carry for a +// given EL parent and proposer target: the bid gossip rules require exact +// equality with the target clamped into the EIP-1559 adjustment band around +// the parent's gas limit (at most parent/1024 - 1 away per block). +func expectedBidGasLimit(parentGasLimit, targetGasLimit uint64) uint64 { + maxDiff := parentGasLimit / 1024 + if maxDiff == 0 { + maxDiff = 1 + } + + maxDiff-- + + minLimit := parentGasLimit - maxDiff + maxLimit := parentGasLimit + maxDiff + + switch { + case targetGasLimit < minLimit: + return minLimit + case targetGasLimit > maxLimit: + return maxLimit + default: + return targetGasLimit + } +} diff --git a/pkg/payload_builder/payload.go b/pkg/payload_builder/payload.go index 8c5d4ba..6808d5d 100644 --- a/pkg/payload_builder/payload.go +++ b/pkg/payload_builder/payload.go @@ -9,6 +9,7 @@ import ( eth2all "github.com/ethpandaops/go-eth2-client/spec/all" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" ) @@ -23,6 +24,10 @@ import ( type Payload struct { // Attributes is the payload_attributes event this build was triggered by. Attributes *beacon.PayloadAttributesEvent + // Candidate classifies the parent tuple this payload was built on + // relative to the chain view at build time (parent/grandparent x + // full/empty). Empty when the tuple matched no known candidate. + Candidate chain.CandidateKey // ExecutionPayload is the fork-agnostic beacon execution payload. ExecutionPayload *eth2all.ExecutionPayload // BlobsBundle holds the blobs/commitments/proofs (Deneb+), nil if none. diff --git a/pkg/payload_builder/payload_builder.go b/pkg/payload_builder/payload_builder.go index e1452c3..c28ac85 100644 --- a/pkg/payload_builder/payload_builder.go +++ b/pkg/payload_builder/payload_builder.go @@ -30,14 +30,26 @@ type PayloadBuilder struct { cfg *config.Config // shared config; mutable settings are read live, never cached log logrus.FieldLogger - // Active build tracking - activeBuild *activeBuild - mu sync.Mutex + // Active build tracking: multiple candidate builds may run for the same + // slot (one per parent tuple); builds for older slots are cancelled when + // a newer slot starts. + activeBuilds map[activeBuildKey]*activeBuild + mu sync.Mutex +} + +// activeBuildKey identifies one in-progress build by the full parent tuple: +// candidates can share an execution parent while differing in the beacon +// parent (parent_empty and grandparent_full both extend the grandparent's +// payload), so the beacon root must be part of the key or they collide and +// cancel each other. +type activeBuildKey struct { + slot phase0.Slot + parentRoot phase0.Root + parentHash phase0.Hash32 } // activeBuild tracks an in-progress payload build. type activeBuild struct { - slot phase0.Slot payloadID paris.PayloadID cancelFn context.CancelFunc } @@ -62,6 +74,7 @@ func NewPayloadBuilder( feeRecipient: feeRecipient, settingsResolvers: settingsResolvers, cfg: cfg, + activeBuilds: make(map[activeBuildKey]*activeBuild, 4), log: log.WithField("component", "payload-builder"), } } @@ -71,35 +84,52 @@ func NewPayloadBuilder( // The event contains all necessary information: timestamp, randao, withdrawals, etc. // // The attributes may be an effective copy with the parent fields redirected to -// the grandparent payload (parent-reorg test); this method treats whatever -// parent it is given as authoritative and stores it on the returned Payload, -// so the bid built from that payload advertises the same parent it built on. +// another candidate parent (reorg / payload-miss handling); this method treats +// whatever parent it is given as authoritative and stores it on the returned +// Payload, so the bid built from that payload advertises the same parent it +// built on. +// +// buildTimeMs is the EL build wait; 0 uses the live-configured +// PayloadBuildTime. func (b *PayloadBuilder) BuildPayloadFromAttributes( ctx context.Context, attrs *beacon.PayloadAttributesEvent, + buildTimeMs uint64, ) (*Payload, error) { + buildKey := activeBuildKey{ + slot: attrs.ProposalSlot, + parentRoot: attrs.ParentBlockRoot, + parentHash: attrs.ParentBlockHash, + } + b.mu.Lock() - // Cancel any existing build for a different slot - if b.activeBuild != nil && b.activeBuild.slot != attrs.ProposalSlot { - b.activeBuild.cancelFn() - b.activeBuild = nil + // Cancel builds for older slots and any earlier build of this exact + // parent tuple; concurrent candidate builds of the same slot on other + // parents keep running. + for key, build := range b.activeBuilds { + if key.slot != attrs.ProposalSlot || key == buildKey { + build.cancelFn() + delete(b.activeBuilds, key) + } } buildCtx, cancel := context.WithCancel(ctx) - b.activeBuild = &activeBuild{ - slot: attrs.ProposalSlot, - cancelFn: cancel, - } + build := &activeBuild{cancelFn: cancel} + b.activeBuilds[buildKey] = build b.mu.Unlock() defer func() { b.mu.Lock() - if b.activeBuild != nil && b.activeBuild.slot == attrs.ProposalSlot { - b.activeBuild = nil + // Only retire our own build: a newer build of the same tuple has + // already replaced (and cancelled) this one. + if current, ok := b.activeBuilds[buildKey]; ok && current == build { + delete(b.activeBuilds, buildKey) } b.mu.Unlock() + + cancel() }() // Resolve the fork active at the build epoch and the engine method version it implies. @@ -111,10 +141,22 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( return nil, fmt.Errorf("cannot build payload for fork %s: %w", beaconFork, err) } - // Get finality info (still need safe/finalized block hashes). - finalityInfo, err := b.clClient.GetFinalityInfo(buildCtx) - if err != nil { - return nil, fmt.Errorf("failed to get finality info: %w", err) + // Safe/finalized hashes for the forkchoice state. The head tracker keeps + // them fresh per head change, so concurrent candidate builds share one + // snapshot instead of each paying the beacon-API round trips; the direct + // fetch only covers the window before the first refresh. + var finalityInfo *beacon.FinalityInfo + if headTracker := b.chainSvc.GetHeadTracker(); headTracker != nil { + finalityInfo = headTracker.FinalityInfo() + } + + if finalityInfo == nil { + fetched, err := b.clClient.GetFinalityInfo(buildCtx) + if err != nil { + return nil, fmt.Errorf("failed to get finality info: %w", err) + } + + finalityInfo = fetched } // Resolve the fee recipient (and target gas limit) for the build. The @@ -170,7 +212,7 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( Version: engineVersion, Timestamp: attrs.Timestamp, PrevRandao: paris.Hash32(attrs.PrevRandao), - SuggestedFeeRecipient: paris.Address(b.feeRecipient), + SuggestedFeeRecipient: paris.Address(proposerFeeRecipient), Withdrawals: convertWithdrawalsToEngineFormat(attrs.Withdrawals), ParentBeaconBlockRoot: paris.Hash32(attrs.ParentBeaconBlockRoot), SlotNumber: uint64(attrs.ProposalSlot), @@ -220,9 +262,7 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( payloadID := *fcuResp.PayloadID b.mu.Lock() - if b.activeBuild != nil && b.activeBuild.slot == attrs.ProposalSlot { - b.activeBuild.payloadID = payloadID - } + build.payloadID = payloadID b.mu.Unlock() b.log.WithFields(logrus.Fields{ @@ -230,8 +270,12 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( "payload_id": fmt.Sprintf("%x", payloadID[:]), }).Debug("Payload build requested from attributes") - // Read the build time live from config so UI overrides take effect immediately. + // Read the build time live from config so UI overrides take effect + // immediately; an explicit per-build time (speculative candidates) wins. payloadBuildTime := b.cfg.PayloadBuildTime + if buildTimeMs != 0 { + payloadBuildTime = buildTimeMs + } b.log.Infof("Allowing payload to build for: %dms", payloadBuildTime) @@ -258,12 +302,17 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( return nil, fmt.Errorf("getPayload returned no execution payload") } - // Inject our extra-data marker and recompute the block hash on the typed payload. + gasLimitOverride := b.resolveGasLimitOverride(buildCtx, attrs, beaconFork, + targetGasLimit, enginePayload.GasLimit, enginePayload.GasUsed) + + // Inject our extra-data marker (and the gas limit override, if any) and + // recompute the block hash on the typed payload. newHash, err := ModifyPayloadExtraData( enginePayload, resp.ExecutionRequests, []byte(b.cfg.ExtraData), common.Hash(attrs.ParentBeaconBlockRoot), + gasLimitOverride, ) if err != nil { return nil, fmt.Errorf("failed to modify payload extra data: %w", err) @@ -298,7 +347,7 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( b.log.WithFields(logrus.Fields{ "slot": attrs.ProposalSlot, "block_hash": fmt.Sprintf("%x", newHash[:8]), - "parent_hash": finalityInfo.HeadExecutionBlockHash, + "parent_hash": fmt.Sprintf("%x", attrs.ParentBlockHash[:8]), "block_value": blockValue.String(), "has_blobs": resp.BlobsBundle != nil, "has_exec_requests": len(resp.ExecutionRequests) > 0, @@ -310,15 +359,69 @@ func (b *PayloadBuilder) BuildPayloadFromAttributes( return event, nil } -// AbortBuild aborts any active build for the given slot. +// resolveGasLimitOverride returns the gas limit the built payload must carry +// per the bid gossip rules (the EL parent's gas limit stepped toward the +// proposer's target), or 0 when no override applies: the rule is disabled, +// the EL already produced the exact value, the parent gas limit is unknown, +// or the payload's gas usage exceeds the required limit. +func (b *PayloadBuilder) resolveGasLimitOverride( + ctx context.Context, + attrs *beacon.PayloadAttributesEvent, + beaconFork version.DataVersion, + targetGasLimit, payloadGasLimit, payloadGasUsed uint64, +) uint64 { + if !b.cfg.Build.EnforceBidGasLimit || beaconFork < version.DataVersionGloas || targetGasLimit == 0 { + return 0 + } + + headTracker := b.chainSvc.GetHeadTracker() + if headTracker == nil { + return 0 + } + + _, parentGasLimit := headTracker.ResolveELParentMeta(ctx, attrs.ParentBlockRoot, attrs.ParentBlockHash) + if parentGasLimit == 0 { + return 0 + } + + expected := expectedBidGasLimit(parentGasLimit, targetGasLimit) + if expected == payloadGasLimit { + return 0 + } + + if payloadGasUsed > expected { + b.log.WithFields(logrus.Fields{ + "slot": attrs.ProposalSlot, + "expected": expected, + "built": payloadGasLimit, + "gas_used": payloadGasUsed, + }).Error("Cannot enforce bid gas limit: payload gas usage exceeds the required limit") + + return 0 + } + + b.log.WithFields(logrus.Fields{ + "slot": attrs.ProposalSlot, + "parent": parentGasLimit, + "target": targetGasLimit, + "built": payloadGasLimit, + "enforced": expected, + }).Warn("Overriding payload gas limit to the bid-gossip-required value") + + return expected +} + +// AbortBuild aborts every active build for the given slot. func (b *PayloadBuilder) AbortBuild(slot phase0.Slot) { b.mu.Lock() defer b.mu.Unlock() - if b.activeBuild != nil && b.activeBuild.slot == slot { - b.activeBuild.cancelFn() - b.activeBuild = nil + for key, build := range b.activeBuilds { + if key.slot == slot { + build.cancelFn() + delete(b.activeBuilds, key) - b.log.WithField("slot", slot).Debug("Build aborted") + b.log.WithField("slot", slot).Debug("Build aborted") + } } } diff --git a/pkg/payload_builder/payload_cache.go b/pkg/payload_builder/payload_cache.go index 04590a2..6bfc1b2 100644 --- a/pkg/payload_builder/payload_cache.go +++ b/pkg/payload_builder/payload_cache.go @@ -4,6 +4,9 @@ import ( "sync" "github.com/ethpandaops/go-eth2-client/spec/phase0" + + "github.com/ethpandaops/buildoor/pkg/chain" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" ) const ( @@ -11,10 +14,20 @@ const ( DefaultCacheSize = 1000 ) -// PayloadCache stores built payloads for a limited number of slots. -// It uses a simple LRU-like approach, keeping only the most recent slots. +// candidatePriority orders candidate keys from most to least canonical; it +// decides which of a slot's payloads Get returns as the primary one. +var candidatePriority = []chain.CandidateKey{ + chain.CandidateParentFull, + chain.CandidateParentEmpty, + chain.CandidateGrandparentFull, + chain.CandidateGrandparentEmpty, +} + +// PayloadCache stores built payloads per slot, keyed by the parent tuple they +// were built on: reorg and payload-miss handling can produce several +// candidate payloads for the same slot. type PayloadCache struct { - payloads map[phase0.Slot]*Payload + payloads map[phase0.Slot]map[beacon.AttrParentKey]*Payload maxSlots int mu sync.RWMutex } @@ -26,27 +39,99 @@ func NewPayloadCache(maxSlots int) *PayloadCache { } return &PayloadCache{ - payloads: make(map[phase0.Slot]*Payload, maxSlots), + payloads: make(map[phase0.Slot]map[beacon.AttrParentKey]*Payload, maxSlots), maxSlots: maxSlots, } } -// Store stores a payload in the cache. -// It automatically evicts old payloads to maintain the size limit. +// Store stores a payload in the cache (replacing an earlier build on the same +// parent tuple) and evicts old slots to maintain the size limit. func (c *PayloadCache) Store(event *Payload) { c.mu.Lock() defer c.mu.Unlock() - c.payloads[event.Attributes.ProposalSlot] = event - c.evictOld(event.Attributes.ProposalSlot) + slot := event.Attributes.ProposalSlot + + variants := c.payloads[slot] + if variants == nil { + variants = make(map[beacon.AttrParentKey]*Payload, 2) + c.payloads[slot] = variants + } + + variants[beacon.AttrParentKeyOf(event.Attributes)] = event + c.evictOld() } -// Get retrieves a payload for the given slot. +// Get retrieves the slot's primary payload: the most canonical classified +// candidate, falling back to the most recently built unclassified one. func (c *PayloadCache) Get(slot phase0.Slot) *Payload { c.mu.RLock() defer c.mu.RUnlock() - return c.payloads[slot] + variants := c.payloads[slot] + if len(variants) == 0 { + return nil + } + + byCandidate := make(map[chain.CandidateKey]*Payload, len(variants)) + + var newestUnclassified *Payload + + for _, payload := range variants { + if payload.Candidate != "" { + byCandidate[payload.Candidate] = payload + continue + } + + if newestUnclassified == nil || payload.ReadyAt.After(newestUnclassified.ReadyAt) { + newestUnclassified = payload + } + } + + for _, key := range candidatePriority { + if payload, ok := byCandidate[key]; ok { + return payload + } + } + + return newestUnclassified +} + +// GetCandidate retrieves the slot's payload built for the given candidate key. +func (c *PayloadCache) GetCandidate(slot phase0.Slot, key chain.CandidateKey) *Payload { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, payload := range c.payloads[slot] { + if payload.Candidate == key { + return payload + } + } + + return nil +} + +// GetVariant retrieves the slot's payload built on the given parent tuple. +func (c *PayloadCache) GetVariant(slot phase0.Slot, key beacon.AttrParentKey) *Payload { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.payloads[slot][key] +} + +// GetSlotPayloads returns all payloads built for the slot (arbitrary order). +func (c *PayloadCache) GetSlotPayloads(slot phase0.Slot) []*Payload { + c.mu.RLock() + defer c.mu.RUnlock() + + variants := c.payloads[slot] + result := make([]*Payload, 0, len(variants)) + + for _, payload := range variants { + result = append(result, payload) + } + + return result } // GetByBlockHash retrieves a payload by its block hash. @@ -54,16 +139,18 @@ func (c *PayloadCache) GetByBlockHash(blockHash phase0.Hash32) *Payload { c.mu.RLock() defer c.mu.RUnlock() - for _, payload := range c.payloads { - if payload.BlockHash == blockHash { - return payload + for _, variants := range c.payloads { + for _, payload := range variants { + if payload.BlockHash == blockHash { + return payload + } } } return nil } -// Delete removes a payload for the given slot. +// Delete removes all payloads for the given slot. func (c *PayloadCache) Delete(slot phase0.Slot) { c.mu.Lock() defer c.mu.Unlock() @@ -77,14 +164,17 @@ func (c *PayloadCache) GetAll() []*Payload { defer c.mu.RUnlock() result := make([]*Payload, 0, len(c.payloads)) - for _, payload := range c.payloads { - result = append(result, payload) + + for _, variants := range c.payloads { + for _, payload := range variants { + result = append(result, payload) + } } return result } -// Size returns the number of payloads in the cache. +// Size returns the number of slots with cached payloads. func (c *PayloadCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() @@ -92,34 +182,22 @@ func (c *PayloadCache) Size() int { return len(c.payloads) } -// evictOld removes payloads older than the retention limit. +// evictOld removes the oldest slots beyond the retention limit. // Must be called with lock held. -func (c *PayloadCache) evictOld(_ phase0.Slot) { - if len(c.payloads) <= c.maxSlots { - return - } - - // Find and remove the oldest slots beyond our limit - var oldestSlot phase0.Slot - - for slot := range c.payloads { - if oldestSlot == 0 || slot < oldestSlot { - oldestSlot = slot - } - } - - // Keep evicting until we're at the limit +func (c *PayloadCache) evictOld() { for len(c.payloads) > c.maxSlots { - delete(c.payloads, oldestSlot) + var oldestSlot phase0.Slot - // Find next oldest - oldestSlot = 0 + first := true for slot := range c.payloads { - if oldestSlot == 0 || slot < oldestSlot { + if first || slot < oldestSlot { oldestSlot = slot + first = false } } + + delete(c.payloads, oldestSlot) } } diff --git a/pkg/payload_builder/payload_modifier.go b/pkg/payload_builder/payload_modifier.go index a158735..8b1381e 100644 --- a/pkg/payload_builder/payload_modifier.go +++ b/pkg/payload_builder/payload_modifier.go @@ -30,6 +30,11 @@ const maxExtraDataSize = 32 // API response (Electra/Prague+); they are needed to compute the requestsHash // header field. Pass nil for pre-Electra payloads. // +// gasLimitOverride, when non-zero, additionally rewrites the payload's gas +// limit (part of the header, so it changes the block hash): used to enforce +// the exact bid-gossip-legal gas limit when the EL ignored the proposer's +// target. Callers must ensure the payload's gas usage fits the override. +// // The function first verifies it can reconstruct the original block hash from // the payload fields. If verification fails (e.g. an unhandled fork added new // header fields) it returns an error rather than producing an incorrect hash. @@ -38,6 +43,7 @@ func ModifyPayloadExtraData( executionRequests []prague.ExecutionRequest, extraDataPrefix []byte, parentBeaconBlockRoot common.Hash, + gasLimitOverride uint64, ) (common.Hash, error) { header, err := buildHeaderFromPayload(p, parentBeaconBlockRoot, executionRequests) if err != nil { @@ -94,6 +100,12 @@ func ModifyPayloadExtraData( } header.Extra = newExtraData + + if gasLimitOverride != 0 { + header.GasLimit = gasLimitOverride + p.GasLimit = gasLimitOverride + } + newHash := header.Hash() p.ExtraData = newExtraData diff --git a/pkg/payload_builder/service.go b/pkg/payload_builder/service.go index 87cb40b..51b8348 100644 --- a/pkg/payload_builder/service.go +++ b/pkg/payload_builder/service.go @@ -66,8 +66,12 @@ type Service struct { lastKnownPayloadSlot phase0.Slot // Slot of the block with known payload // Build tracking - scheduledBuildMu sync.Mutex - buildStartedSlots map[phase0.Slot]bool // Slots where building has started (to prevent re-building) + scheduledBuildMu sync.Mutex + // Per-slot build pass state: the first attributes event schedules the + // slot's build pass (which resolves the candidate set at fire time); + // every started candidate build is tracked by its parent tuple so late + // variants activate additional builds instead of duplicating. + slotBuilds map[phase0.Slot]*slotBuildState skipFiredSlots map[phase0.Slot]bool // Slots a BuildSkippedEvent was fired for (dedup per slot) attrFallbackArmed map[phase0.Slot]bool // Slots a missing-attributes fallback check is armed for @@ -122,7 +126,7 @@ func NewService( buildSkippedDispatcher: &utils.Dispatcher[*BuildSkippedEvent]{}, stats: &BuilderStats{}, log: serviceLog, - buildStartedSlots: make(map[phase0.Slot]bool), + slotBuilds: make(map[phase0.Slot]*slotBuildState, 16), skipFiredSlots: make(map[phase0.Slot]bool, 16), attrFallbackArmed: make(map[phase0.Slot]bool, 16), wonPayloads: make(map[phase0.Hash32]phase0.Slot, 16), @@ -431,16 +435,87 @@ func (s *Service) handlePayloadAttributesEvent(event *beacon.PayloadAttributesEv return } - // Check if already scheduled/building/built for this slot + // The first attributes event schedules the slot's build pass; the pass + // resolves the full candidate set (all variants received by then) at + // fire time. Later events before the pass just accumulate in the variant + // cache; events after the pass may activate an additional candidate + // build for a parent the pass did not cover. s.scheduledBuildMu.Lock() - if s.buildStartedSlots[event.ProposalSlot] { + state := s.slotBuilds[event.ProposalSlot] + + if state == nil { + state = newSlotBuildState() + s.slotBuilds[event.ProposalSlot] = state + } + + if !state.passScheduled { + state.passScheduled = true + state.buildStartMs = frozen.Build.BuildStartTimeMs s.scheduledBuildMu.Unlock() + + s.scheduleBuildForSlot(event.ProposalSlot, frozen.Build.BuildStartTimeMs) + return } - s.buildStartedSlots[event.ProposalSlot] = true + + buildStartMs := state.buildStartMs s.scheduledBuildMu.Unlock() - s.scheduleBuildForSlot(event.ProposalSlot, frozen.Build.BuildStartTimeMs) + buildTime := s.chainSvc.SlotToTime(event.ProposalSlot). + Add(time.Duration(buildStartMs) * time.Millisecond) + if time.Now().After(buildTime) { + s.maybeLateBuild(event.ProposalSlot, event) + } +} + +// slotBuildState tracks a slot's build pass and its started candidate builds. +type slotBuildState struct { + passScheduled bool + buildStartMs int64 + started map[beacon.AttrParentKey]bool + readyFired bool // OnSlotBuilt/stat accounting fired (once per slot) +} + +func newSlotBuildState() *slotBuildState { + return &slotBuildState{started: make(map[beacon.AttrParentKey]bool, 4)} +} + +// maybeLateBuild activates a candidate build for an attributes variant that +// arrived after the slot's build pass already ran: the chain moved (reorg, +// payload-miss flip, late reveal) and the new parent still deserves a payload +// if the slot has not ended and the candidate policy allows it. +func (s *Service) maybeLateBuild(slot phase0.Slot, event *beacon.PayloadAttributesEvent) { + event = s.sanitizeAttributes(event) + + s.scheduledBuildMu.Lock() + state := s.slotBuilds[slot] + alreadyStarted := state != nil && state.started[beacon.AttrParentKeyOf(event)] + s.scheduledBuildMu.Unlock() + + if alreadyStarted || time.Now().After(s.slotEndTime(slot)) { + return + } + + candidateKey := s.classifyCandidate(event) + if candidateKey != "" { + mode := s.candidateMode(slot, candidateKey) + if mode == config.CandidateModeNever { + s.log.WithFields(logrus.Fields{ + "slot": slot, + "candidate": candidateKey, + }).Debug("Suppressing late candidate build (policy: never)") + + return + } + } + + s.log.WithFields(logrus.Fields{ + "slot": slot, + "candidate": candidateKey, + "parent_hash": fmt.Sprintf("%x", event.ParentBlockHash[:8]), + }).Info("New build parent after the slot's build pass, building additional candidate") + + go s.executeCandidateBuild(slot, &buildTarget{candidate: candidateKey, attrs: event}) } // fireBuildSkipped emits a BuildSkippedEvent (once per slot) when the skip is @@ -563,6 +638,25 @@ func (s *Service) applyAttributesFallback(targetSlot phase0.Slot) { synthesized.Timestamp = parent.Timestamp + skippedSlots*uint64(s.chainSvc.GetChainSpec().SecondsPerSlot.Seconds()) + // The proposer changes per slot: resolve the target slot's proposer from + // the cached duties instead of carrying the source slot's over. + if proposer, ok := s.lookupProposer(targetSlot); ok { + synthesized.ProposerIndex = proposer + } else { + s.log.WithField("slot", targetSlot).Warn( + "Cannot resolve proposer for synthesized attributes, keeping the source slot's") + } + + // The randao mix rotates at epoch boundaries; a value copied across one + // is invalid and any payload built from it will be rejected. + spec := s.chainSvc.GetChainSpec() + if uint64(targetSlot)/spec.SlotsPerEpoch != uint64(parent.ProposalSlot)/spec.SlotsPerEpoch { + s.log.WithFields(logrus.Fields{ + "slot": targetSlot, + "attrs_from": parent.ProposalSlot, + }).Warn("Synthesized attributes cross an epoch boundary, prev_randao may be stale") + } + if !events.InjectPayloadAttributes(&synthesized) { return // lost the race against a real event } @@ -576,6 +670,24 @@ func (s *Service) applyAttributesFallback(targetSlot phase0.Slot) { "re-using the last available attributes") } +// lookupProposer resolves the scheduled proposer of a slot from the cached +// epoch duties. +func (s *Service) lookupProposer(slot phase0.Slot) (phase0.ValidatorIndex, bool) { + spec := s.chainSvc.GetChainSpec() + stats := s.chainSvc.GetEpochStats(s.chainSvc.GetEpochOfSlot(slot)) + + if stats == nil { + return 0, false + } + + slotIndex := uint64(slot) % spec.SlotsPerEpoch + if slotIndex >= uint64(len(stats.ProposerDuties)) { + return 0, false + } + + return stats.ProposerDuties[slotIndex], true +} + // scheduleBuildForSlot schedules payload building for the given slot. // buildStartMs is the slot's frozen build start time, milliseconds relative // to the proposal slot start: @@ -609,69 +721,132 @@ func (s *Service) scheduleBuildForSlot(slot phase0.Slot, buildStartMs int64) { }) } -// executeBuildForSlot fetches the latest cached payload_attributes for the -// given slot and performs payload building. +// executeBuildForSlot runs the slot's build pass: resolve the candidate set +// (configured policy + chain view + received attribute variants) and build +// every selected target — sequentially in candidateBuildOrder (canonical +// first, so it starts at the configured build time), or concurrently when +// parallel builds are enabled. func (s *Service) executeBuildForSlot(slot phase0.Slot) { - event := s.clClient.Events().GetLatestPayloadAttributes(slot) - if event == nil { + targets := s.resolveBuildTargets(slot) + if len(targets) == 0 { s.log.WithField("slot", slot).Warn( - "No cached payload attributes for slot, skipping build", + "No build targets for slot (no attributes and nothing synthesizable), skipping build", ) + return } - s.log.WithFields(logrus.Fields{ - "slot": event.ProposalSlot, - "parent_hash": fmt.Sprintf("%x", event.ParentBlockHash[:8]), - }).Info("Starting payload build") + if s.cfg.Build.Parallel { + var wg sync.WaitGroup + + for _, target := range targets { + wg.Add(1) + + go func(target *buildTarget) { + defer wg.Done() + s.executeCandidateBuild(slot, target) + }(target) + } + + wg.Wait() + + return + } + + for _, target := range targets { + s.executeCandidateBuild(slot, target) + } +} + +// executeCandidateBuild builds one candidate payload for the slot (deduped by +// parent tuple), applies the frozen plan's build tweaks and transform, and +// emits the payload. +func (s *Service) executeCandidateBuild(slot phase0.Slot, target *buildTarget) { + s.scheduledBuildMu.Lock() + state := s.slotBuilds[slot] + + if state == nil { + state = newSlotBuildState() + s.slotBuilds[slot] = state + } + + tuple := beacon.AttrParentKeyOf(target.attrs) + if state.started[tuple] { + s.scheduledBuildMu.Unlock() + return + } + + state.started[tuple] = true + s.scheduledBuildMu.Unlock() // The frozen plan (idempotent Freeze) decides whether to build this slot's // payload on the grandparent execution payload (a parent-reorg test). When // so, we build from an effective attributes copy whose parent fields point // at the grandparent, so the build, the stored payload and the bid all // agree on the parent. - event = s.effectiveBuildAttributes(slot, event) + event := s.effectiveBuildAttributes(slot, target.attrs) + + s.log.WithFields(logrus.Fields{ + "slot": slot, + "candidate": target.candidate, + "derived": target.derived, + "parent_hash": fmt.Sprintf("%x", event.ParentBlockHash[:8]), + }).Info("Starting payload build") // Notify subscribers that building has started so the build can be rendered // as in-progress before the payload is ready. s.buildStartedDispatcher.Fire(&PayloadBuildStartedEvent{ Slot: slot, + Candidate: string(target.candidate), StartedAt: time.Now(), }) - // Size the build deadline to the configured build time plus a margin for the + // Size the build deadline to the target's build time plus a margin for the // engine getPayload and finality lookups, so a long PayloadBuildTime doesn't // make the getPayload call time out spuriously. - buildTimeout := time.Duration(s.cfg.PayloadBuildTime)*time.Millisecond + buildCallTimeout + buildTimeMs := s.candidateBuildTime(target) + buildTimeout := time.Duration(buildTimeMs)*time.Millisecond + buildCallTimeout ctx, cancel := context.WithTimeout(s.ctx, buildTimeout) + defer cancel() - payloadEvent, err := s.payloadBuilder.BuildPayloadFromAttributes(ctx, event) + payloadEvent, err := s.payloadBuilder.BuildPayloadFromAttributes(ctx, event, buildTimeMs) if err != nil { - s.log.WithError(err).WithField("slot", slot).Error( - "Failed to build payload from attributes", - ) + s.log.WithError(err).WithFields(logrus.Fields{ + "slot": slot, + "candidate": target.candidate, + }).Error("Failed to build payload from attributes") // Notify subscribers so the in-progress build is marked failed rather than // left rendered as perpetually building. s.buildFailedDispatcher.Fire(&PayloadBuildFailedEvent{ - Slot: slot, - Error: err.Error(), - FailedAt: time.Now(), + Slot: slot, + Candidate: string(target.candidate), + Error: err.Error(), + FailedAt: time.Now(), }) return } + // Classify the payload by the parent it was actually built on (the plan's + // parent-reorg tweak may have redirected it). + if event == target.attrs { + payloadEvent.Candidate = target.candidate + } else { + payloadEvent.Candidate = s.classifyCandidate(payloadEvent.Attributes) + } + // Apply the slot's frozen payload transform (if any) before the payload // feeds the bid commitment and the envelope reveal. if err := s.applyPayloadTransform(ctx, slot, payloadEvent); err != nil { s.log.WithError(err).WithField("slot", slot).Error("Payload transform failed") s.buildFailedDispatcher.Fire(&PayloadBuildFailedEvent{ - Slot: slot, - Error: err.Error(), - FailedAt: time.Now(), + Slot: slot, + Candidate: string(target.candidate), + Error: err.Error(), + FailedAt: time.Now(), }) return @@ -801,7 +976,9 @@ func (s *Service) handlePayloadAvailableEvent(event *beacon.PayloadAvailableEven // Building is now triggered by payload_attributes events. } -// emitPayloadReady stores the payload and emits the ready event. +// emitPayloadReady stores the payload and emits the ready event. Per-slot +// accounting (next_n schedule budget, slots-built stat) fires once per slot +// regardless of how many candidate payloads it produced. func (s *Service) emitPayloadReady(slot phase0.Slot, payloadEvent *Payload) { // Store in cache s.payloadCache.Store(payloadEvent) @@ -811,18 +988,34 @@ func (s *Service) emitPayloadReady(slot phase0.Slot, payloadEvent *Payload) { s.log.WithFields(logrus.Fields{ "slot": slot, + "candidate": payloadEvent.Candidate, "block_hash": fmt.Sprintf("%x", payloadEvent.BlockHash[:8]), "block_value": payloadEvent.BlockValue, "parent_block_hash": fmt.Sprintf("%x", payloadEvent.Attributes.ParentBlockHash[:8]), }).Info("Payload built and dispatched") - // Mark slot as built (next_n schedule accounting + WebUI status). - s.planSvc.OnSlotBuilt(slot) - s.lastBuiltSlot.Store(uint64(slot)) + s.scheduledBuildMu.Lock() + state := s.slotBuilds[slot] - s.incrementStat(func(stats *BuilderStats) { - stats.SlotsBuilt++ - }) + if state == nil { + state = newSlotBuildState() + s.slotBuilds[slot] = state + } + + firstOfSlot := !state.readyFired + state.readyFired = true + s.scheduledBuildMu.Unlock() + + if firstOfSlot { + // Mark slot as built (next_n schedule accounting + WebUI status). + s.planSvc.OnSlotBuilt(slot) + + s.incrementStat(func(stats *BuilderStats) { + stats.SlotsBuilt++ + }) + } + + s.lastBuiltSlot.Store(uint64(slot)) // Cleanup old data if slot > 64 { @@ -832,9 +1025,9 @@ func (s *Service) emitPayloadReady(slot phase0.Slot, payloadEvent *Payload) { // Cleanup old build tracking s.scheduledBuildMu.Lock() - for oldSlot := range s.buildStartedSlots { + for oldSlot := range s.slotBuilds { if oldSlot < cleanupSlot { - delete(s.buildStartedSlots, oldSlot) + delete(s.slotBuilds, oldSlot) } } s.scheduledBuildMu.Unlock() @@ -861,6 +1054,28 @@ func (s *Service) emitPayloadReady(slot phase0.Slot, payloadEvent *Payload) { } } +// UnmarkPayloadWon clears the won marker of a payload whose winning block was +// reorged out, so a later re-inclusion is detected (and counted) again. +func (s *Service) UnmarkPayloadWon(blockHash phase0.Hash32) { + s.wonPayloadsMu.Lock() + _, wasWon := s.wonPayloads[blockHash] + delete(s.wonPayloads, blockHash) + s.wonPayloadsMu.Unlock() + + if !wasWon { + return + } + + s.log.WithField("block_hash", fmt.Sprintf("%x", blockHash[:8])). + Warn("Won payload's block was reorged out") + + s.incrementStat(func(stats *BuilderStats) { + if stats.BlocksIncluded > 0 { + stats.BlocksIncluded-- + } + }) +} + // markPayloadWon records a payload as won (included on-chain), deduplicating // between the two detection methods (payload_attributes parent hash and head event). func (s *Service) markPayloadWon(blockHash phase0.Hash32, slot phase0.Slot) { diff --git a/pkg/rpc/beacon/client.go b/pkg/rpc/beacon/client.go index 0ee41bc..7915766 100644 --- a/pkg/rpc/beacon/client.go +++ b/pkg/rpc/beacon/client.go @@ -275,6 +275,15 @@ type BlockInfo struct { FinalitySafeExecutionBlockHash phase0.Hash32 ParentRoot phase0.Root StateRoot phase0.Root + // Gas limit the block committed to: the embedded payload's gas limit + // pre-Gloas, the bid's gas limit from Gloas on (the envelope check + // enforces payload.gas_limit == bid.gas_limit, so the values agree + // whenever the payload is revealed). + GasLimit uint64 + // Execution block number of the committed payload. Only known pre-Gloas + // (the payload is embedded in the block); zero from Gloas on, where the + // number requires the revealed envelope. + ExecutionBlockNumber uint64 } // FinalityInfo contains finality checkpoint execution block hashes. @@ -324,6 +333,8 @@ func (c *Client) GetBlockInfo(ctx context.Context, blockID string) (*BlockInfo, return nil, fmt.Errorf("failed to get finality-safe execution block hash: %w", err) } + gasLimit, blockNumber := agnosticExecutionGasLimitAndNumber(msg) + return &BlockInfo{ Slot: msg.Slot, Root: root, @@ -331,9 +342,34 @@ func (c *Client) GetBlockInfo(ctx context.Context, blockID string) (*BlockInfo, FinalitySafeExecutionBlockHash: finalitySafeHash, ParentRoot: msg.ParentRoot, StateRoot: msg.StateRoot, + GasLimit: gasLimit, + ExecutionBlockNumber: blockNumber, }, nil } +// agnosticExecutionGasLimitAndNumber extracts the committed gas limit and (where +// knowable) the execution block number from a fork-agnostic beacon block. +// Pre-Gloas both come from the embedded payload; from Gloas on the gas limit is +// on the bid (present even when the payload was withheld) and the number is +// unknown without the envelope, so it stays zero. +func agnosticExecutionGasLimitAndNumber(msg *all.BeaconBlock) (gasLimit, blockNumber uint64) { + body := msg.Body + + if msg.Version >= version.DataVersionGloas { + if body.SignedExecutionPayloadBid != nil && body.SignedExecutionPayloadBid.Message != nil { + return body.SignedExecutionPayloadBid.Message.GasLimit, 0 + } + + return 0, 0 + } + + if body.ExecutionPayload == nil { + return 0, 0 + } + + return body.ExecutionPayload.GasLimit, body.ExecutionPayload.BlockNumber +} + // agnosticExecutionBlockHash extracts the execution block hash from a // fork-agnostic beacon block. Pre-Gloas the payload is embedded in the block; // from Gloas on the block carries only the builder's bid, so the committed diff --git a/pkg/rpc/beacon/events.go b/pkg/rpc/beacon/events.go index 4add9ef..56b46cf 100644 --- a/pkg/rpc/beacon/events.go +++ b/pkg/rpc/beacon/events.go @@ -43,6 +43,34 @@ type headEventJSON struct { CurrentDutyDependentRoot string `json:"current_duty_dependent_root"` } +// ChainReorgEvent represents a chain_reorg event from the beacon node. +// Emitted when fork choice switches the head to a block that is not a +// descendant of the previous head. Not all clients emit this reliably, so +// consumers must treat it as a hint and derive reorgs from head-event +// parent-root discontinuities as the primary mechanism. +type ChainReorgEvent struct { + Slot phase0.Slot + Depth uint64 + OldHeadBlock phase0.Root + NewHeadBlock phase0.Root + OldHeadState phase0.Root + NewHeadState phase0.Root + Epoch phase0.Epoch + ExecutionOptimistic bool +} + +// chainReorgEventJSON is used for JSON unmarshaling of chain_reorg events. +type chainReorgEventJSON struct { + Slot string `json:"slot"` + Depth string `json:"depth"` + OldHeadBlock string `json:"old_head_block"` + NewHeadBlock string `json:"new_head_block"` + OldHeadState string `json:"old_head_state"` + NewHeadState string `json:"new_head_state"` + Epoch string `json:"epoch"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + // BidEvent represents an execution payload bid event. type BidEvent struct { Slot phase0.Slot @@ -191,6 +219,7 @@ type bidEventJSON struct { type EventStream struct { client *Client headDispatcher *utils.Dispatcher[*HeadEvent] + chainReorgDispatcher *utils.Dispatcher[*ChainReorgEvent] bidDispatcher *utils.Dispatcher[*BidEvent] payloadDispatcher *utils.Dispatcher[*PayloadAvailableEvent] payloadAttributesDispatcher *utils.Dispatcher[*PayloadAttributesEvent] @@ -201,24 +230,46 @@ type EventStream struct { mu sync.Mutex wg sync.WaitGroup - // Per-slot cache of latest payload_attributes events. - // Multiple events may arrive for the same slot (e.g. reorgs, updated attributes); - // we always keep the latest one so the builder uses the most up-to-date data. - payloadAttrCache map[phase0.Slot]*PayloadAttributesEvent + // Per-slot cache of payload_attributes events, keyed by parent tuple. + // Multiple events may arrive for the same slot with DIFFERENT parents + // (reorgs, Gloas full/empty parent flips): each parent tuple keeps its + // latest event (last-writer-wins per variant) and the slot additionally + // tracks the newest event overall. + payloadAttrCache map[phase0.Slot]*slotAttrVariants payloadAttrCacheMu sync.RWMutex } +// AttrParentKey identifies a payload-attributes variant by the parent tuple +// it builds on: the beacon parent block root and the execution parent hash. +type AttrParentKey struct { + Root phase0.Root + Hash phase0.Hash32 +} + +// AttrParentKeyOf returns the parent tuple of a payload-attributes event. +func AttrParentKeyOf(event *PayloadAttributesEvent) AttrParentKey { + return AttrParentKey{Root: event.ParentBlockRoot, Hash: event.ParentBlockHash} +} + +// slotAttrVariants holds all payload-attributes variants received for one +// proposal slot plus the newest event overall (arrival order). +type slotAttrVariants struct { + latest *PayloadAttributesEvent + variants map[AttrParentKey]*PayloadAttributesEvent +} + // NewEventStream creates a new event stream for the given client. func NewEventStream(client *Client) *EventStream { return &EventStream{ client: client, headDispatcher: &utils.Dispatcher[*HeadEvent]{}, + chainReorgDispatcher: &utils.Dispatcher[*ChainReorgEvent]{}, bidDispatcher: &utils.Dispatcher[*BidEvent]{}, payloadDispatcher: &utils.Dispatcher[*PayloadAvailableEvent]{}, payloadAttributesDispatcher: &utils.Dispatcher[*PayloadAttributesEvent]{}, singleAttestationDispatcher: &utils.Dispatcher[*SingleAttestationEvent]{}, proposerPreferencesDispatcher: &utils.Dispatcher[*gloas.SignedProposerPreferences]{}, - payloadAttrCache: make(map[phase0.Slot]*PayloadAttributesEvent, 4), + payloadAttrCache: make(map[phase0.Slot]*slotAttrVariants, 4), } } @@ -236,9 +287,10 @@ func (e *EventStream) Start(ctx context.Context) error { e.mu.Unlock() // Start separate goroutines for each topic - e.wg.Add(6) + e.wg.Add(7) go e.runTopicLoop(streamCtx, "head", 5*time.Second) + go e.runTopicLoop(streamCtx, "chain_reorg", 30*time.Second) go e.runTopicLoop(streamCtx, "payload_attributes", 5*time.Second) go e.runTopicLoop(streamCtx, "execution_payload_bid", 30*time.Second) go e.runTopicLoop(streamCtx, "execution_payload_available", 30*time.Second) @@ -267,6 +319,11 @@ func (e *EventStream) SubscribeHead() *utils.Subscription[*HeadEvent] { return e.headDispatcher.Subscribe(16, false) } +// SubscribeChainReorgs returns a subscription for chain_reorg events. +func (e *EventStream) SubscribeChainReorgs() *utils.Subscription[*ChainReorgEvent] { + return e.chainReorgDispatcher.Subscribe(16, false) +} + // SubscribeBids returns a subscription for bid events. func (e *EventStream) SubscribeBids() *utils.Subscription[*BidEvent] { return e.bidDispatcher.Subscribe(64, false) @@ -295,30 +352,98 @@ func (e *EventStream) SubscribeProposerPreferences() *utils.Subscription[*gloas. return e.proposerPreferencesDispatcher.Subscribe(32, false) } -// GetLatestPayloadAttributes returns the latest cached payload_attributes event -// for the given slot, or nil if none has been received. +// GetLatestPayloadAttributes returns the newest cached payload_attributes +// event for the given slot (across all parent variants), or nil if none has +// been received. func (e *EventStream) GetLatestPayloadAttributes(slot phase0.Slot) *PayloadAttributesEvent { e.payloadAttrCacheMu.RLock() defer e.payloadAttrCacheMu.RUnlock() - return e.payloadAttrCache[slot] + entry := e.payloadAttrCache[slot] + if entry == nil { + return nil + } + + return entry.latest +} + +// GetPayloadAttributesVariants returns every cached payload_attributes +// variant for the given slot (one per parent tuple, arbitrary order). +func (e *EventStream) GetPayloadAttributesVariants(slot phase0.Slot) []*PayloadAttributesEvent { + e.payloadAttrCacheMu.RLock() + defer e.payloadAttrCacheMu.RUnlock() + + entry := e.payloadAttrCache[slot] + if entry == nil { + return nil + } + + variants := make([]*PayloadAttributesEvent, 0, len(entry.variants)) + for _, event := range entry.variants { + variants = append(variants, event) + } + + return variants +} + +// GetPayloadAttributesVariant returns the cached payload_attributes event for +// the given slot and parent tuple, or nil. +func (e *EventStream) GetPayloadAttributesVariant( + slot phase0.Slot, key AttrParentKey, +) *PayloadAttributesEvent { + e.payloadAttrCacheMu.RLock() + defer e.payloadAttrCacheMu.RUnlock() + + entry := e.payloadAttrCache[slot] + if entry == nil { + return nil + } + + return entry.variants[key] +} + +// cachePayloadAttributes stores a node-received event: last-writer-wins per +// parent tuple, and the event becomes the slot's newest overall. +func (e *EventStream) cachePayloadAttributes(event *PayloadAttributesEvent) { + e.payloadAttrCacheMu.Lock() + defer e.payloadAttrCacheMu.Unlock() + + entry := e.payloadAttrCache[event.ProposalSlot] + if entry == nil { + entry = &slotAttrVariants{variants: make(map[AttrParentKey]*PayloadAttributesEvent, 2)} + e.payloadAttrCache[event.ProposalSlot] = entry + } + + entry.variants[AttrParentKeyOf(event)] = event + entry.latest = event } // InjectPayloadAttributes caches and dispatches a locally synthesized // payload_attributes event exactly like one received from the beacon node -// (used by the missing-block fallback: some clients do not re-emit attributes -// when a slot's block is missing entirely). A node-received event for the -// slot always wins: injection is dropped if one arrived in the meantime. -// Returns whether the event was injected. +// (used by the missing-block fallback and candidate synthesis). A +// node-received event always wins: injection is dropped when the slot already +// has an event for the same parent tuple, and an injected event only becomes +// the slot's newest when the slot had none at all. Returns whether the event +// was injected. func (e *EventStream) InjectPayloadAttributes(event *PayloadAttributesEvent) bool { e.payloadAttrCacheMu.Lock() - if _, exists := e.payloadAttrCache[event.ProposalSlot]; exists { + entry := e.payloadAttrCache[event.ProposalSlot] + if entry == nil { + entry = &slotAttrVariants{variants: make(map[AttrParentKey]*PayloadAttributesEvent, 2)} + e.payloadAttrCache[event.ProposalSlot] = entry + } + + key := AttrParentKeyOf(event) + if _, exists := entry.variants[key]; exists { e.payloadAttrCacheMu.Unlock() return false } - e.payloadAttrCache[event.ProposalSlot] = event + entry.variants[key] = event + if entry.latest == nil { + entry.latest = event + } e.payloadAttrCacheMu.Unlock() e.payloadAttributesDispatcher.Fire(event) @@ -471,6 +596,21 @@ func (e *EventStream) handleEvent(eventType, data string) { e.headDispatcher.Fire(event) + case "chain_reorg": + var raw chainReorgEventJSON + if err := json.Unmarshal([]byte(data), &raw); err != nil { + e.client.log.WithError(err).WithField("data", data).Warn("Failed to parse chain reorg event JSON") + return + } + + event, err := parseChainReorgEvent(&raw) + if err != nil { + e.client.log.WithError(err).WithField("data", data).Warn("Failed to convert chain reorg event") + return + } + + e.chainReorgDispatcher.Fire(event) + case "execution_payload_bid": var raw bidEventJSON if err := json.Unmarshal([]byte(data), &raw); err != nil { @@ -521,10 +661,7 @@ func (e *EventStream) handleEvent(eventType, data string) { "parent_hash": fmt.Sprintf("%x", event.ParentBlockHash[:8]), }).Debug("Payload attributes event received") - // Cache the latest attributes per slot (overwrites any previous event for the same slot). - e.payloadAttrCacheMu.Lock() - e.payloadAttrCache[event.ProposalSlot] = event - e.payloadAttrCacheMu.Unlock() + e.cachePayloadAttributes(event) e.payloadAttributesDispatcher.Fire(event) @@ -609,6 +746,55 @@ func parseHeadEvent(raw *headEventJSON) (*HeadEvent, error) { }, nil } +// parseChainReorgEvent converts a raw JSON chain_reorg event to the typed ChainReorgEvent. +func parseChainReorgEvent(raw *chainReorgEventJSON) (*ChainReorgEvent, error) { + slot, err := strconv.ParseUint(raw.Slot, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid slot: %w", err) + } + + depth, err := strconv.ParseUint(raw.Depth, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid depth: %w", err) + } + + epoch, err := strconv.ParseUint(raw.Epoch, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid epoch: %w", err) + } + + oldHeadBlock, err := parseRoot(raw.OldHeadBlock) + if err != nil { + return nil, fmt.Errorf("invalid old_head_block: %w", err) + } + + newHeadBlock, err := parseRoot(raw.NewHeadBlock) + if err != nil { + return nil, fmt.Errorf("invalid new_head_block: %w", err) + } + + oldHeadState, err := parseRoot(raw.OldHeadState) + if err != nil { + return nil, fmt.Errorf("invalid old_head_state: %w", err) + } + + newHeadState, err := parseRoot(raw.NewHeadState) + if err != nil { + return nil, fmt.Errorf("invalid new_head_state: %w", err) + } + + return &ChainReorgEvent{ + Slot: phase0.Slot(slot), + Depth: depth, + OldHeadBlock: oldHeadBlock, + NewHeadBlock: newHeadBlock, + OldHeadState: oldHeadState, + NewHeadState: newHeadState, + Epoch: phase0.Epoch(epoch), + ExecutionOptimistic: raw.ExecutionOptimistic, + }, nil +} + // parseBidEvent converts a raw JSON bid event to the typed BidEvent. func parseBidEvent(raw *bidEventJSON) (*BidEvent, error) { msg := &raw.Data.Message diff --git a/pkg/rpc/beacon/events_test.go b/pkg/rpc/beacon/events_test.go index 2042ce4..9b2eaf9 100644 --- a/pkg/rpc/beacon/events_test.go +++ b/pkg/rpc/beacon/events_test.go @@ -156,7 +156,64 @@ func TestInjectPayloadAttributes(t *testing.T) { t.Fatal("expected the injected event to be dispatched") } - // A second injection for the same slot is dropped (the cached event wins). + // A second injection for the same slot and parent tuple is dropped (the + // cached event wins). require.False(t, stream.InjectPayloadAttributes(&PayloadAttributesEvent{ProposalSlot: 10})) assert.Equal(t, synthesized, stream.GetLatestPayloadAttributes(10)) } + +func TestPayloadAttributeVariants(t *testing.T) { + stream := NewEventStream(&Client{}) + + // Two events for the same slot with different parents: both variants are + // retained and the newest becomes the slot's latest. + fullParent := &PayloadAttributesEvent{ + ProposalSlot: 20, + ParentBlockRoot: phase0.Root{0x01}, + ParentBlockHash: phase0.Hash32{0xaa}, + } + emptyParent := &PayloadAttributesEvent{ + ProposalSlot: 20, + ParentBlockRoot: phase0.Root{0x01}, + ParentBlockHash: phase0.Hash32{0xbb}, + } + + stream.cachePayloadAttributes(fullParent) + stream.cachePayloadAttributes(emptyParent) + + assert.Equal(t, emptyParent, stream.GetLatestPayloadAttributes(20), "newest event wins as latest") + assert.Len(t, stream.GetPayloadAttributesVariants(20), 2) + assert.Equal(t, fullParent, stream.GetPayloadAttributesVariant(20, AttrParentKeyOf(fullParent))) + assert.Equal(t, emptyParent, stream.GetPayloadAttributesVariant(20, AttrParentKeyOf(emptyParent))) + + // A newer event for an existing tuple replaces that variant (and the + // latest pointer). + fullParentUpdated := &PayloadAttributesEvent{ + ProposalSlot: 20, + ParentBlockRoot: phase0.Root{0x01}, + ParentBlockHash: phase0.Hash32{0xaa}, + Timestamp: 99, + } + stream.cachePayloadAttributes(fullParentUpdated) + + assert.Len(t, stream.GetPayloadAttributesVariants(20), 2) + assert.Equal(t, fullParentUpdated, stream.GetPayloadAttributesVariant(20, AttrParentKeyOf(fullParent))) + assert.Equal(t, fullParentUpdated, stream.GetLatestPayloadAttributes(20)) + + // An injected variant for a NEW tuple is added but does not steal the + // latest pointer from a node-received event. + derived := &PayloadAttributesEvent{ + ProposalSlot: 20, + ParentBlockRoot: phase0.Root{0x02}, + ParentBlockHash: phase0.Hash32{0xcc}, + } + require.True(t, stream.InjectPayloadAttributes(derived)) + assert.Len(t, stream.GetPayloadAttributesVariants(20), 3) + assert.Equal(t, fullParentUpdated, stream.GetLatestPayloadAttributes(20), + "injected variant must not become latest when node events exist") + + // Cleanup drops the whole slot entry. + stream.CleanupPayloadAttributesCache(21) + assert.Nil(t, stream.GetLatestPayloadAttributes(20)) + assert.Empty(t, stream.GetPayloadAttributesVariants(20)) +} diff --git a/pkg/slot_results/artifacts.go b/pkg/slot_results/artifacts.go index c4f1030..44d05cc 100644 --- a/pkg/slot_results/artifacts.go +++ b/pkg/slot_results/artifacts.go @@ -62,9 +62,10 @@ type BidArtifactMeta struct { type ArtifactStore struct { stateDB *db.Database - mu sync.Mutex - buffer map[phase0.Slot]map[string][]*db.SlotArtifact - bidIdx map[phase0.Slot]int // next bid index per slot; lazily seeded from MAX(idx)+1 + mu sync.Mutex + buffer map[phase0.Slot]map[string][]*db.SlotArtifact + bidIdx map[phase0.Slot]int // next bid index per slot; lazily seeded from MAX(idx)+1 + payloadIdx map[phase0.Slot]*payloadIdxState writeQueue chan []db.SlotArtifact @@ -81,6 +82,7 @@ func NewArtifactStore(stateDB *db.Database, log logrus.FieldLogger) *ArtifactSto stateDB: stateDB, buffer: make(map[phase0.Slot]map[string][]*db.SlotArtifact, memoryBufferSlots), bidIdx: make(map[phase0.Slot]int, memoryBufferSlots), + payloadIdx: make(map[phase0.Slot]*payloadIdxState, memoryBufferSlots), writeQueue: make(chan []db.SlotArtifact, writerQueueCap), log: log.WithField("component", "slot-artifacts"), } @@ -151,11 +153,73 @@ func (s *ArtifactStore) runWriter() { } } -// StorePayload captures the slot's built execution payload (idx 0; repeated -// captures for the same slot replace it — the builder produces one payload -// per slot). -func (s *ArtifactStore) StorePayload(slot phase0.Slot, fork version.DataVersion, payload sszMarshaler) error { - return s.store(slot, ArtifactKindPayload, 0, fork, "", payload) +// PayloadArtifactMeta is the JSON metadata stored alongside a payload +// artifact: which candidate parent the payload was built on. +type PayloadArtifactMeta struct { + V int `json:"v"` + Candidate string `json:"candidate,omitempty"` + ParentBlockRoot string `json:"parent_block_root,omitempty"` + ParentBlockHash string `json:"parent_block_hash,omitempty"` + At int64 `json:"at"` // unix milliseconds +} + +// StorePayload captures one built execution payload and returns its per-slot +// artifact index. A slot may produce several candidate payloads (reorg / +// payload-miss preparedness): each parent tuple gets its own index, and a +// rebuild on the same tuple replaces its artifact. Index allocation is +// restart-safe (seeded from MAX(idx)+1). +func (s *ArtifactStore) StorePayload(slot phase0.Slot, fork version.DataVersion, + payload sszMarshaler, meta PayloadArtifactMeta) (int, error) { + meta.V = 1 + + metaJSON, err := json.Marshal(meta) + if err != nil { + return 0, fmt.Errorf("failed to encode payload artifact meta: %w", err) + } + + tuple := meta.ParentBlockRoot + "/" + meta.ParentBlockHash + + s.mu.Lock() + + state := s.payloadIdx[slot] + if state == nil { + next := 0 + + maxIdx, exists, err := s.stateDB.GetMaxSlotArtifactIdx(uint64(slot), ArtifactKindPayload) + if err != nil { + s.mu.Unlock() + + return 0, fmt.Errorf("failed to seed payload artifact index: %w", err) + } + + if exists { + next = maxIdx + 1 + } + + state = &payloadIdxState{next: next, byTuple: make(map[string]int, 2)} + s.payloadIdx[slot] = state + } + + idx, seen := state.byTuple[tuple] + if !seen { + idx = state.next + state.next++ + state.byTuple[tuple] = idx + } + s.mu.Unlock() + + if err := s.store(slot, ArtifactKindPayload, idx, fork, string(metaJSON), payload); err != nil { + return 0, err + } + + return idx, nil +} + +// payloadIdxState allocates per-slot payload artifact indices: one index per +// parent tuple, rebuilds replace in place. +type payloadIdxState struct { + next int + byTuple map[string]int } // StoreBid captures one signed bid and returns its per-slot artifact index. @@ -277,6 +341,7 @@ func (s *ArtifactStore) insertBuffer(slot phase0.Slot, artifact *db.SlotArtifact for _, evict := range slots[:len(s.buffer)-memoryBufferSlots] { delete(s.buffer, evict) delete(s.bidIdx, evict) + delete(s.payloadIdx, evict) } } } @@ -365,6 +430,49 @@ func (s *ArtifactStore) ListBids(slot phase0.Slot) ([]db.SlotArtifact, error) { return s.stateDB.GetSlotArtifactMetas(uint64(slot), ArtifactKindBid) } +// PayloadIndexForCandidate returns the per-slot payload artifact index built +// for the given candidate key, scanning the slot's payload artifact metadata. +// Returns false when the slot has no payload artifact for that candidate. +func (s *ArtifactStore) PayloadIndexForCandidate(slot phase0.Slot, candidate string) (int, bool) { + s.mu.Lock() + + metas := make([]db.SlotArtifact, 0, 4) + + if kinds, ok := s.buffer[slot]; ok { + for _, artifact := range kinds[ArtifactKindPayload] { + meta := *artifact + meta.Data = nil + metas = append(metas, meta) + } + } + s.mu.Unlock() + + if len(metas) == 0 { + stored, err := s.stateDB.GetSlotArtifactMetas(uint64(slot), ArtifactKindPayload) + if err != nil { + s.log.WithError(err).WithField("slot", slot). + Debug("Failed to list payload artifact metas") + + return 0, false + } + + metas = stored + } + + for _, artifact := range metas { + var meta PayloadArtifactMeta + if err := json.Unmarshal([]byte(artifact.Meta), &meta); err != nil { + continue + } + + if meta.Candidate == candidate { + return artifact.Idx, true + } + } + + return 0, false +} + // PruneBefore drops all artifacts for slots below the cutoff from the buffer // and the database. func (s *ArtifactStore) PruneBefore(cutoff phase0.Slot) { @@ -373,6 +481,7 @@ func (s *ArtifactStore) PruneBefore(cutoff phase0.Slot) { if slot < cutoff { delete(s.buffer, slot) delete(s.bidIdx, slot) + delete(s.payloadIdx, slot) } } s.mu.Unlock() diff --git a/pkg/slot_results/tracker.go b/pkg/slot_results/tracker.go index 16489ac..3dc9f27 100644 --- a/pkg/slot_results/tracker.go +++ b/pkg/slot_results/tracker.go @@ -406,15 +406,95 @@ func (t *Tracker) handlePayloadReady(payload *payload_builder.Payload) { len(reqs.Consolidations) + len(reqs.BuilderDeposits) + len(reqs.BuilderExits) } - t.upsert(slot, func(result *SlotResult) { - result.Build = outcome - }) + outcome.Candidate = string(payload.Candidate) if t.cfg.SlotArtifactCaptureEnabled && payload.ExecutionPayload != nil { - if err := t.artifacts.StorePayload(slot, forkVersion, payload.ExecutionPayload); err != nil { + idx, err := t.artifacts.StorePayload(slot, forkVersion, payload.ExecutionPayload, + PayloadArtifactMeta{ + Candidate: string(payload.Candidate), + ParentBlockRoot: fmt.Sprintf("%#x", payload.Attributes.ParentBlockRoot), + ParentBlockHash: fmt.Sprintf("%#x", payload.Attributes.ParentBlockHash), + At: payload.ReadyAt.UnixMilli(), + }) + if err != nil { t.log.WithError(err).WithField("slot", slot).Warn("Failed to store payload artifact") + } else { + outcome.ArtifactIdx = &idx + } + } + + t.upsert(slot, func(result *SlotResult) { + upsertBuildOutcome(result, outcome) + result.Build = primaryBuildOutcome(result) + }) +} + +// buildCandidatePriority orders candidate keys from most to least canonical +// for primary build selection. +var buildCandidatePriority = map[string]int{ + "parent_full": 0, + "parent_empty": 1, + "grandparent_full": 2, + "grandparent_empty": 3, +} + +// upsertBuildOutcome inserts the outcome into the result's build list, +// replacing an earlier entry of the same candidate parent (matched by the +// attributes parent tuple, falling back to the candidate key). +func upsertBuildOutcome(result *SlotResult, outcome *BuildOutcome) { + for i, existing := range result.Builds { + if buildOutcomesMatch(existing, outcome) { + result.Builds[i] = outcome + return } } + + result.Builds = append(result.Builds, outcome) +} + +// buildOutcomesMatch reports whether two outcomes describe the same candidate +// build. +func buildOutcomesMatch(a, b *BuildOutcome) bool { + if a.Attributes != nil && b.Attributes != nil { + return a.Attributes.ParentBlockRoot == b.Attributes.ParentBlockRoot && + a.Attributes.ParentBlockHash == b.Attributes.ParentBlockHash + } + + return a.Candidate == b.Candidate +} + +// primaryBuildOutcome selects the result's primary build: the most canonical +// ready candidate, then any ready build, then the newest entry. +func primaryBuildOutcome(result *SlotResult) *BuildOutcome { + if len(result.Builds) == 0 { + return result.Build + } + + var best *BuildOutcome + + bestRank := len(buildCandidatePriority) + 1 + + for _, build := range result.Builds { + if build.Status != BuildStatusReady { + continue + } + + rank, classified := buildCandidatePriority[build.Candidate] + if !classified { + rank = len(buildCandidatePriority) + } + + if best == nil || rank < bestRank { + best = build + bestRank = rank + } + } + + if best != nil { + return best + } + + return result.Builds[len(result.Builds)-1] } // attributesSnapshot reduces a payload_attributes event to the stored @@ -459,23 +539,50 @@ func fillBidDetail(attempt *BidAttempt, signedBid *eth2all.SignedExecutionPayloa func (t *Tracker) handleBuildStarted(event *payload_builder.PayloadBuildStartedEvent) { t.upsert(event.Slot, func(result *SlotResult) { - // Never regress a ready/failed outcome to started (events may race). + outcome := &BuildOutcome{ + Status: BuildStatusStarted, + Candidate: event.Candidate, + At: event.StartedAt, + } + + // Track per-candidate progress; a candidate already past started + // (ready/failed) is never regressed. + for _, existing := range result.Builds { + if existing.Candidate == event.Candidate && existing.Status != BuildStatusStarted { + return + } + } + + upsertBuildOutcome(result, outcome) + + // Never regress the primary ready/failed outcome to started (events + // may race). if result.Build != nil && result.Build.Status != BuildStatusWaitingAttributes && result.Build.Status != BuildStatusNoAttributes { return } - result.Build = &BuildOutcome{Status: BuildStatusStarted, At: event.StartedAt} + result.Build = outcome }) } func (t *Tracker) handleBuildFailed(event *payload_builder.PayloadBuildFailedEvent) { t.upsert(event.Slot, func(result *SlotResult) { - result.Build = &BuildOutcome{ - Status: BuildStatusFailed, - Error: event.Error, - At: event.FailedAt, + outcome := &BuildOutcome{ + Status: BuildStatusFailed, + Candidate: event.Candidate, + Error: event.Error, + At: event.FailedAt, } + + upsertBuildOutcome(result, outcome) + + // Another candidate's ready payload keeps the primary slot outcome. + if result.Build != nil && result.Build.Status == BuildStatusReady { + return + } + + result.Build = outcome }) } diff --git a/pkg/slot_results/tracker_test.go b/pkg/slot_results/tracker_test.go index 5a53671..88b60df 100644 --- a/pkg/slot_results/tracker_test.go +++ b/pkg/slot_results/tracker_test.go @@ -475,7 +475,8 @@ func TestArtifactStoreBufferWithoutDB(t *testing.T) { GasLimit: 1, GasUsed: 1, Timestamp: 1, BlockNumber: 1, } - require.NoError(t, store.StorePayload(700, version.DataVersionFulu, payload)) + _, err := store.StorePayload(700, version.DataVersionFulu, payload, PayloadArtifactMeta{}) + require.NoError(t, err) artifact, err := store.Get(700, ArtifactKindPayload, 0) require.NoError(t, err) @@ -484,7 +485,8 @@ func TestArtifactStoreBufferWithoutDB(t *testing.T) { // Buffer bound: newest 64 distinct slots. for slot := phase0.Slot(701); slot <= 800; slot++ { - require.NoError(t, store.StorePayload(slot, version.DataVersionFulu, payload)) + _, slotErr := store.StorePayload(slot, version.DataVersionFulu, payload, PayloadArtifactMeta{}) + require.NoError(t, slotErr) } evicted, err := store.Get(700, ArtifactKindPayload, 0) @@ -511,7 +513,8 @@ func TestArtifactSSZRoundTrip(t *testing.T) { Timestamp: 1234, } - require.NoError(t, store.StorePayload(900, version.DataVersionFulu, original)) + _, err := store.StorePayload(900, version.DataVersionFulu, original, PayloadArtifactMeta{}) + require.NoError(t, err) artifact, err := store.Get(900, ArtifactKindPayload, 0) require.NoError(t, err) diff --git a/pkg/slot_results/types.go b/pkg/slot_results/types.go index 4d04a7e..308356d 100644 --- a/pkg/slot_results/types.go +++ b/pkg/slot_results/types.go @@ -94,6 +94,14 @@ type BuildOutcome struct { Status BuildStatus `json:"status"` SkipReason string `json:"skip_reason,omitempty"` // action_plan.BuildSkipReason* when skipped + // Candidate classifies which build-parent candidate this outcome belongs + // to (parent_full, parent_empty, grandparent_full, grandparent_empty; + // empty = unclassified or single-build slot). + Candidate string `json:"candidate,omitempty"` + // ArtifactIdx is the per-slot payload artifact index of this build's + // captured payload (nil when no artifact was captured). + ArtifactIdx *int `json:"artifact_idx,omitempty"` + BlockHash string `json:"block_hash,omitempty"` BlockValueWei string `json:"block_value_wei,omitempty"` NumTransactions int `json:"num_transactions,omitempty"` @@ -226,7 +234,11 @@ type SlotResult struct { // AppliedPlan is the frozen plan snapshot the slot executed under. AppliedPlan *action_plan.FrozenPlan `json:"applied_plan,omitempty"` + // Build is the slot's primary build outcome (the most canonical ready + // candidate, or the single lifecycle record). Builds lists every + // candidate build the slot produced when more than one ran. Build *BuildOutcome `json:"build,omitempty"` + Builds []*BuildOutcome `json:"builds,omitempty"` Bids []BidAttempt `json:"bids,omitempty"` BlockSubmissions []BlockSubmission `json:"block_submissions,omitempty"` RevealAttempts []RevealAttempt `json:"reveal_attempts,omitempty"` @@ -249,9 +261,27 @@ func (r *SlotResult) Clone() *SlotResult { if r.Build != nil { build := *r.Build + if r.Build.ArtifactIdx != nil { + idx := *r.Build.ArtifactIdx + build.ArtifactIdx = &idx + } + c.Build = &build } + if r.Builds != nil { + c.Builds = make([]*BuildOutcome, len(r.Builds)) + for i, build := range r.Builds { + clone := *build + if build.ArtifactIdx != nil { + idx := *build.ArtifactIdx + clone.ArtifactIdx = &idx + } + + c.Builds[i] = &clone + } + } + if r.Inclusion != nil { inclusion := *r.Inclusion c.Inclusion = &inclusion diff --git a/pkg/webui/handlers/api/action_plan_test.go b/pkg/webui/handlers/api/action_plan_test.go index caca3df..73b7fd7 100644 --- a/pkg/webui/handlers/api/action_plan_test.go +++ b/pkg/webui/handlers/api/action_plan_test.go @@ -319,7 +319,9 @@ func TestArtifactEndpointsNegotiation(t *testing.T) { BlockNumber: 42, GasLimit: 30_000_000, } - require.NoError(t, env.tracker.Artifacts().StorePayload(2000, version.DataVersionFulu, payload)) + _, storeErr := env.tracker.Artifacts().StorePayload( + 2000, version.DataVersionFulu, payload, slot_results.PayloadArtifactMeta{}) + require.NoError(t, storeErr) newRequest := func(accept string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/api/buildoor/slot-results/2000/payload", nil) diff --git a/pkg/webui/handlers/api/artifacts.go b/pkg/webui/handlers/api/artifacts.go index ebae10d..1ecb995 100644 --- a/pkg/webui/handlers/api/artifacts.go +++ b/pkg/webui/handlers/api/artifacts.go @@ -188,9 +188,61 @@ func parseArtifactSlot(w http.ResponseWriter, r *http.Request) (phase0.Slot, boo // @Failure 400 {object} map[string]string "Bad Request" // @Failure 404 {object} map[string]string "No artifact for this slot" // @Failure 406 {object} map[string]string "No acceptable content type" +// @Param candidate query string false "Build-parent candidate key (parent_full, parent_empty, grandparent_full, grandparent_empty)" // @Router /api/buildoor/slot-results/{slot}/payload [get] func (h *APIHandler) GetSlotPayloadArtifact(w http.ResponseWriter, r *http.Request) { - h.serveArtifact(w, r, slot_results.ArtifactKindPayload, 0) + idx := 0 + + // A slot may hold one payload per build-parent candidate; ?candidate= + // selects one of them, defaulting to the first stored payload. + if candidate := r.URL.Query().Get("candidate"); candidate != "" { + if h.resultTracker == nil { + writeError(w, http.StatusNotFound, "artifact not found") + return + } + + slot, ok := parseArtifactSlot(w, r) + if !ok { + return + } + + resolved, found := h.resultTracker.Artifacts().PayloadIndexForCandidate(slot, candidate) + if !found { + writeError(w, http.StatusNotFound, "no payload artifact for candidate "+candidate) + return + } + + idx = resolved + } + + h.serveArtifact(w, r, slot_results.ArtifactKindPayload, idx) +} + +// GetSlotPayloadArtifactByIndex godoc +// @Id getSlotPayloadArtifactByIndex +// @Summary Get one of a slot's built candidate payloads +// @Tags ActionPlan +// @Description Returns one of the execution payloads built for the slot by +// @Description artifact index (a slot may build several candidate payloads on +// @Description different parents; the slot result's build entries carry each +// @Description build's artifact index). Content negotiation as with the +// @Description default payload artifact endpoint. +// @Produce json,application/octet-stream +// @Param slot path int true "Slot" +// @Param index path int true "Payload artifact index" +// @Success 200 {object} map[string]any "Versioned payload (or raw SSZ)" +// @Failure 400 {object} map[string]string "Bad Request" +// @Failure 404 {object} map[string]string "No artifact for this slot/index" +// @Failure 406 {object} map[string]string "No acceptable content type" +// @Router /api/buildoor/slot-results/{slot}/payload/{index} [get] +func (h *APIHandler) GetSlotPayloadArtifactByIndex(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(mux.Vars(r)["index"]) + if err != nil || idx < 0 { + writeError(w, http.StatusBadRequest, "invalid index: must be a non-negative number") + return + } + + h.serveArtifact(w, r, slot_results.ArtifactKindPayload, idx) } // GetSlotEnvelopeArtifact godoc diff --git a/pkg/webui/handlers/api/events.go b/pkg/webui/handlers/api/events.go index 4c047bd..0dfe3ce 100644 --- a/pkg/webui/handlers/api/events.go +++ b/pkg/webui/handlers/api/events.go @@ -98,6 +98,7 @@ type SlotStartEvent struct { // the payload is ready, so the WebUI can render the build as in-progress. type PayloadBuildStartedStreamEvent struct { Slot uint64 `json:"slot"` + Candidate string `json:"candidate,omitempty"` StartedAt int64 `json:"started_at"` } @@ -123,18 +124,26 @@ type PayloadAttributesStreamEvent struct { // PayloadBuildFailedStreamEvent is sent when a payload build fails, so the WebUI // can mark the in-progress build as failed. type PayloadBuildFailedStreamEvent struct { - Slot uint64 `json:"slot"` - Error string `json:"error"` - FailedAt int64 `json:"failed_at"` + Slot uint64 `json:"slot"` + Candidate string `json:"candidate,omitempty"` + Error string `json:"error"` + FailedAt int64 `json:"failed_at"` } // PayloadReadyStreamEvent is sent when a payload becomes available. type PayloadReadyStreamEvent struct { - Slot uint64 `json:"slot"` - BlockHash string `json:"block_hash"` - ParentBlockHash string `json:"parent_block_hash"` - BlockValue string `json:"block_value"` - ReadyAt int64 `json:"ready_at"` + Slot uint64 `json:"slot"` + Candidate string `json:"candidate,omitempty"` + BlockHash string `json:"block_hash"` + // Parent tuple this payload was built on: the execution parent (hash and, + // where known, its block number) and the beacon parent (root and its + // slot), so the UI can show what the payload actually extends. + ParentBlockHash string `json:"parent_block_hash"` + ParentBlockNumber uint64 `json:"parent_block_number,omitempty"` + ParentBlockRoot string `json:"parent_block_root,omitempty"` + ParentSlot uint64 `json:"parent_slot,omitempty"` + BlockValue string `json:"block_value"` + ReadyAt int64 `json:"ready_at"` // Full built-payload properties (list fields aggregated to counts). BlockNumber uint64 `json:"block_number,omitempty"` @@ -1029,6 +1038,7 @@ func (m *EventStreamManager) handlePayloadBuildStarted(event *payload_builder.Pa Timestamp: time.Now().UnixMilli(), Data: PayloadBuildStartedStreamEvent{ Slot: uint64(event.Slot), + Candidate: event.Candidate, StartedAt: event.StartedAt.UnixMilli(), }, }) @@ -1061,23 +1071,44 @@ func (m *EventStreamManager) handlePayloadBuildFailed(event *payload_builder.Pay Type: EventTypePayloadBuildFailed, Timestamp: time.Now().UnixMilli(), Data: PayloadBuildFailedStreamEvent{ - Slot: uint64(event.Slot), - Error: event.Error, - FailedAt: event.FailedAt.UnixMilli(), + Slot: uint64(event.Slot), + Candidate: event.Candidate, + Error: event.Error, + FailedAt: event.FailedAt.UnixMilli(), }, }) } // payloadReadyStreamEvent assembles the full payload_ready wire event from a // built payload (list fields aggregated to counts). -func payloadReadyStreamEvent(slot phase0.Slot, event *payload_builder.Payload) PayloadReadyStreamEvent { +func (m *EventStreamManager) payloadReadyStreamEvent( + slot phase0.Slot, event *payload_builder.Payload, +) PayloadReadyStreamEvent { data := PayloadReadyStreamEvent{ - Slot: uint64(slot), - BlockHash: fmt.Sprintf("0x%x", event.BlockHash[:]), - ParentBlockHash: fmt.Sprintf("0x%x", event.Attributes.ParentBlockHash[:]), - BlockValue: event.BlockValue.String(), - ReadyAt: event.ReadyAt.UnixMilli(), - FeeRecipient: event.FeeRecipient.Hex(), + Slot: uint64(slot), + Candidate: string(event.Candidate), + BlockHash: fmt.Sprintf("0x%x", event.BlockHash[:]), + ParentBlockHash: fmt.Sprintf("0x%x", event.Attributes.ParentBlockHash[:]), + ParentBlockNumber: event.Attributes.ParentBlockNumber, + ParentBlockRoot: fmt.Sprintf("0x%x", event.Attributes.ParentBlockRoot[:]), + BlockValue: event.BlockValue.String(), + ReadyAt: event.ReadyAt.UnixMilli(), + FeeRecipient: event.FeeRecipient.Hex(), + } + + // The beacon parent's slot makes the payload's position in the chain + // readable; cache-only so event assembly never blocks. + if m.chainSvc != nil { + if tracker := m.chainSvc.GetHeadTracker(); tracker != nil { + if parent, ok := tracker.LookupBlock(event.Attributes.ParentBlockRoot); ok { + data.ParentSlot = uint64(parent.Slot) + + if data.ParentBlockNumber == 0 && + parent.ExecutionBlockHash == event.Attributes.ParentBlockHash { + data.ParentBlockNumber = parent.ExecutionBlockNumber + } + } + } } if ep := event.ExecutionPayload; ep != nil { @@ -1113,7 +1144,7 @@ func (m *EventStreamManager) handlePayloadReady(event *payload_builder.Payload) m.broadcastForSlot(slot, &StreamEvent{ Type: EventTypePayloadReady, Timestamp: time.Now().UnixMilli(), - Data: payloadReadyStreamEvent(slot, event), + Data: m.payloadReadyStreamEvent(slot, event), }) // Update slot state diff --git a/pkg/webui/src/components/BuildDelayLine.tsx b/pkg/webui/src/components/BuildDelayLine.tsx index 4c1bb21..c278ea0 100644 --- a/pkg/webui/src/components/BuildDelayLine.tsx +++ b/pkg/webui/src/components/BuildDelayLine.tsx @@ -11,6 +11,8 @@ interface BuildDelayLineProps { // Base CSS class; "-active" is appended while in progress. // Defaults to the payload build span style. className?: string; + // Overrides the line color (build candidate identity). + lineColor?: string; } // BuildDelayLine renders the payload build span on the slot timeline. @@ -27,7 +29,8 @@ export const BuildDelayLine: React.FC = ({ endAt, expectedEndAt, onClick, - className = 'build-delay-line' + className = 'build-delay-line', + lineColor }) => { const ref = useRef(null); const animationRef = useRef(0); @@ -81,6 +84,7 @@ export const BuildDelayLine: React.FC = ({
); diff --git a/pkg/webui/src/components/BuilderConfigPanel.tsx b/pkg/webui/src/components/BuilderConfigPanel.tsx index e0f1d39..739e94e 100644 --- a/pkg/webui/src/components/BuilderConfigPanel.tsx +++ b/pkg/webui/src/components/BuilderConfigPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useAuthContext } from '../context/AuthContext'; +import { CandidatePolicySection } from './CandidatePolicySection'; import type { Config, ScheduleConfig } from '../types'; interface BuilderConfigPanelProps { @@ -197,6 +198,9 @@ export const BuilderConfigPanel: React.FC = ({ config } )} + {/* Build candidate policy */} + + {/* Schedule Section */}
Schedule
diff --git a/pkg/webui/src/components/CandidatePolicySection.tsx b/pkg/webui/src/components/CandidatePolicySection.tsx new file mode 100644 index 0000000..74a5dc7 --- /dev/null +++ b/pkg/webui/src/components/CandidatePolicySection.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from 'react'; +import { useAuthContext } from '../context/AuthContext'; +import type { BuildConfig, Config } from '../types'; + +interface CandidatePolicySectionProps { + config: Config | null; +} + +// The build-parent candidates, in canonical order. A slot may build one +// payload per candidate so bids and Builder API requests can be answered on +// whichever parent the proposer ends up on. +const CANDIDATES: Array<{ key: keyof BuildConfig; label: string; hint: string }> = [ + { + key: 'candidate_parent_full', + label: 'Parent (full)', + hint: 'The normal build: head block and its revealed payload.', + }, + { + key: 'candidate_parent_empty', + label: 'Parent (empty payload)', + hint: 'Head block, but on the payload it built upon — the Gloas payload-miss case.', + }, + { + key: 'candidate_grandparent_full', + label: 'Grandparent (reorg)', + hint: 'Head block’s parent: the proposer reorgs the head block out.', + }, + { + key: 'candidate_grandparent_empty', + label: 'Grandparent (empty payload)', + hint: 'Reorg combined with a withheld grandparent payload (rare).', + }, +]; + +const MODE_LABELS: Record = { + auto: 'auto (chain signals)', + always: 'always', + never: 'never', +}; + +const MODE_SHORT: Record = { + auto: 'auto', + always: 'always', + never: 'never', +}; + +const DEFAULT_FORM: BuildConfig = { + candidate_parent_full: 'always', + candidate_parent_empty: 'auto', + candidate_grandparent_full: 'auto', + candidate_grandparent_empty: 'never', + parallel: false, + speculative_build_time_ms: 0, + auto_weak_head_pct: 40, + enforce_bid_gas_limit: false, +}; + +// CandidatePolicySection is the global build-candidate policy, rendered as a +// section of the Payload Builder card: which parent candidates a slot builds +// payloads for, how they are sequenced, and the signals auto mode reacts to. +// Per-slot overrides live in the action plan; edits here go through the +// generic path-based settings endpoint with build.* keys. +export const CandidatePolicySection: React.FC = ({ config }) => { + const { isLoggedIn, getAuthHeader } = useAuthContext(); + const [editing, setEditing] = useState(false); + + const build = config?.build; + + const [form, setForm] = useState(DEFAULT_FORM); + + useEffect(() => { + if (!editing && build) { + setForm({ ...DEFAULT_FORM, ...build }); + } + }, [build, editing]); + + const postSettings = async (settings: Record): Promise => { + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + const authToken = await getAuthHeader(); + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + try { + const response = await fetch('/api/config/settings', { + method: 'POST', + headers, + body: JSON.stringify(settings), + }); + const result = await response.json(); + if (result.error) { + alert('Failed to update: ' + result.error); + return false; + } + return true; + } catch (err) { + alert('Error: ' + err); + return false; + } + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + const ok = await postSettings({ + 'build.candidate_parent_full': form.candidate_parent_full, + 'build.candidate_parent_empty': form.candidate_parent_empty, + 'build.candidate_grandparent_full': form.candidate_grandparent_full, + 'build.candidate_grandparent_empty': form.candidate_grandparent_empty, + 'build.parallel': form.parallel, + 'build.speculative_build_time_ms': form.speculative_build_time_ms, + 'build.auto_weak_head_pct': form.auto_weak_head_pct, + 'build.enforce_bid_gas_limit': form.enforce_bid_gas_limit, + }); + if (ok) setEditing(false); + }; + + // How many candidates can produce a payload at all (never = off). + const activeCount = CANDIDATES.filter( + (c) => (build?.[c.key] as string | undefined) !== 'never' + ).length; + + return ( + <> +
+
+ Build Candidates {activeCount} enabled +
+ {isLoggedIn && !editing && ( + + )} +
+
+ A slot can build one payload per parent candidate, so a bid or + Builder API request can be answered on whichever parent the + proposer ends up on after a reorg or a missed payload. + auto builds a candidate only when the chain + suggests it is needed (parent payload withheld, or a weakly + attested head), so a healthy chain normally produces just the + parent (full) payload. +
+ + {!editing ? ( +
+ {CANDIDATES.map((candidate) => ( +
+
+
{candidate.label}
+
+ {MODE_SHORT[(build?.[candidate.key] as string) ?? ''] ?? + (build?.[candidate.key] as string) ?? '—'} +
+
+
+ ))} +
+
+
Weak Head Below
+
+ {build?.auto_weak_head_pct ? `${build.auto_weak_head_pct}%` : 'off'} +
+
+
+
+
+
Engine Builds
+
+ {build?.parallel ? 'parallel' : 'sequential'} +
+
+
+
+
+
Speculative Time
+
+ {build?.speculative_build_time_ms + ? `${build.speculative_build_time_ms} ms` + : 'same as build time'} +
+
+
+
+
+
Enforce Bid Gas Limit
+
+ {build?.enforce_bid_gas_limit ? 'on' : 'off'} +
+
+
+
+ ) : ( +
+ {CANDIDATES.map((candidate) => ( +
+ + +
{candidate.hint}
+
+ ))} + +
+ + + setForm({ ...form, auto_weak_head_pct: parseInt(e.target.value) || 0 }) + } + required + /> +
+ Head-vote participation below which the head counts as + contested, arming the auto-mode reorg candidates. 0 disables + the signal. +
+
+ +
+ + + setForm({ ...form, speculative_build_time_ms: parseInt(e.target.value) || 0 }) + } + required + /> +
+ Only used when parallel building is off: shortens the + speculative candidate builds so they fit alongside the + canonical one, which always builds first. 0 uses the global + payload build time. +
+
+ +
+ setForm({ ...form, parallel: e.target.checked })} + /> + +
+ Builds every selected candidate concurrently, each with its + own payload ID, so they all complete within the designated + build window. Turn off to serialize them (canonical first). +
+
+ +
+ setForm({ ...form, enforce_bid_gas_limit: e.target.checked })} + /> + +
+ Rewrites the built payload's gas limit to the exact value + the bid gossip rules require when the EL ignored the + proposer's target. +
+
+ +
+ + +
+
+ )} + + ); +}; diff --git a/pkg/webui/src/components/Legend.tsx b/pkg/webui/src/components/Legend.tsx index 1b78381..1654ba8 100644 --- a/pkg/webui/src/components/Legend.tsx +++ b/pkg/webui/src/components/Legend.tsx @@ -19,7 +19,11 @@ export const Legend: React.FC = () => { Threshold Met
- Bidder: + Candidates: + parent (full) + parent (empty) + grandparent + Bidder: Bid Submitted Bid Failed Reveal diff --git a/pkg/webui/src/components/Popover.tsx b/pkg/webui/src/components/Popover.tsx index bda75e9..5e1333b 100644 --- a/pkg/webui/src/components/Popover.tsx +++ b/pkg/webui/src/components/Popover.tsx @@ -6,9 +6,22 @@ export interface PopoverItem { copyValue?: string; // Full value to copy (if different from display value) } +// A popover tab: one variant of the same event (e.g. the payload built for +// each build-parent candidate), with its own rows and artifact. +export interface PopoverTab { + key: string; + label: string; + color?: string; + items: PopoverItem[]; + artifact?: { url: string; filename: string }; +} + export interface PopoverData { title: string; items: PopoverItem[]; + // Optional tabs rendered above the rows; the active tab replaces items + // and artifact. The first tab is selected initially. + tabs?: PopoverTab[]; // Use the wide popover variant (long values like extra data / content // summaries would otherwise line-break). wide?: boolean; @@ -44,6 +57,12 @@ interface PopoverProps { export const Popover: React.FC = ({ data, x, y, onClose, children }) => { const popoverRef = useRef(null); const [copiedIndex, setCopiedIndex] = useState(null); + const [activeTab, setActiveTab] = useState(0); + + const tabs = data.tabs; + const active = tabs?.[Math.min(activeTab, tabs.length - 1)]; + const items = active?.items ?? data.items; + const artifact = active?.artifact ?? data.artifact; useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -80,9 +99,26 @@ export const Popover: React.FC = ({ data, x, y, onClose, children onClick={(e) => e.stopPropagation()} >
{data.title}
+ {tabs && tabs.length > 1 && ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ )} - {data.items.map((item, index) => ( + {items.map((item, index) => (
{item.label} @@ -105,10 +141,10 @@ export const Popover: React.FC = ({ data, x, y, onClose, children ))}
- {data.artifact && ( + {artifact && (
= ({ data, x, y, onClose, children type="button" className="btn btn-outline-secondary ap-artifact-btn" onClick={() => { - downloadSSZ(data.artifact!.url, data.artifact!.filename) + downloadSSZ(artifact.url, artifact.filename) .catch((err) => console.error('artifact download failed:', err)); }} > diff --git a/pkg/webui/src/components/SlotGraph.tsx b/pkg/webui/src/components/SlotGraph.tsx index e2e3742..0a53f69 100644 --- a/pkg/webui/src/components/SlotGraph.tsx +++ b/pkg/webui/src/components/SlotGraph.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { SlotState, Config, ChainInfo, OurBid, ExternalBid, HeadVoteDataPoint, ServiceStatus } from '../types'; import { formatGwei, isSlotScheduled, calculateSlotTiming, calculatePosition } from '../utils'; -import { Popover, PopoverData } from './Popover'; +import { Popover, PopoverData, type PopoverItem } from './Popover'; import { HeadVoteHeatmap } from './HeadVoteHeatmap'; import { BidArtifactLinks } from './BidArtifactLinks'; import { CurrentTimeIndicator } from './CurrentTimeIndicator'; @@ -186,6 +186,144 @@ const buildHeadVotesPaths = ( }; }; + +// One fixed color per build-parent candidate key, consistent across the UI +// (parent_full keeps the classic build color). +const CANDIDATE_COLORS: Record = { + parent_full: '#61e392', + parent_empty: '#e6a23c', + grandparent_full: '#a78bfa', + grandparent_empty: '#8a8f98' +}; + +const CANDIDATE_LABELS: Record = { + parent_full: 'parent (full)', + parent_empty: 'parent (empty payload)', + grandparent_full: 'grandparent (reorg)', + grandparent_empty: 'grandparent (empty payload)' +}; + +const candidateColor = (candidate?: string): string | undefined => + candidate ? CANDIDATE_COLORS[candidate] : undefined; + +const candidateLabel = (candidate?: string): string => + (candidate && CANDIDATE_LABELS[candidate]) || 'unclassified'; + + + +// Short tab labels and ordering for the per-candidate payload tabs. +const CANDIDATE_SHORT: Record = { + parent_full: 'parent', + parent_empty: 'parent (empty)', + grandparent_full: 'grandparent', + grandparent_empty: 'grandparent (empty)' +}; + +const CANDIDATE_ORDER: Record = { + parent_full: 0, + parent_empty: 1, + grandparent_full: 2, + grandparent_empty: 3 +}; + +const candidateShortLabel = (candidate?: string): string => + (candidate && CANDIDATE_SHORT[candidate]) || 'unclassified'; + +const candidateOrder = (candidate?: string): number => + (candidate ? CANDIDATE_ORDER[candidate] : undefined) ?? 9; + +// candidatePayloadItems renders one candidate payload's rows for its tab in +// the payload-created popover. +const candidatePayloadItems = ( + build: import('../types').CandidateBuild, + slotStartTime: number +): PopoverItem[] => { + if (build.failed) { + return [ + { label: 'Status', value: 'build failed' }, + { label: 'Error', value: build.error ?? 'unknown error' } + ]; + } + + if (build.readyAt === undefined) { + return [{ label: 'Status', value: 'building…' }]; + } + + const d = build.detail; + + return [ + { label: 'Ready', value: `${build.readyAt - slotStartTime}ms` }, + ...(build.blockHash + ? [{ label: 'Block Hash', value: truncateHashStr(build.blockHash), copyValue: build.blockHash }] + : []), + ...parentRows(build.parentBlockRoot, build.parentSlot, build.parentBlockHash, build.parentBlockNumber), + ...(build.blockValueGwei !== undefined + ? [{ label: 'Block Value', value: formatGwei(build.blockValueGwei) }] + : []), + ...(d?.blockNumber !== undefined ? [{ label: 'Block #', value: `${d.blockNumber}` }] : []), + ...(d?.gasUsed !== undefined + ? [{ + label: 'Gas', + value: `${d.gasUsed.toLocaleString()} / ${(d.gasLimit ?? 0).toLocaleString()}` + }] + : []), + ...(d + ? [{ + label: 'Contents', + value: `${d.numTransactions ?? 0} txs, ${d.numBlobs ?? 0} blobs, ` + + `${d.numWithdrawals ?? 0} wdrls, ${d.numExecRequests ?? 0} reqs` + }] + : []) + ]; +}; + +// candidateRows labels which built candidate payload an event committed to, +// matched by the payload's block hash. +const candidateRows = ( + builds: import('../types').CandidateBuild[], + blockHash?: string +): PopoverItem[] => { + if (!blockHash || builds.length < 2) { + return []; + } + + const match = builds.find( + (build) => build.blockHash && build.blockHash.toLowerCase() === blockHash.toLowerCase() + ); + + return match ? [{ label: 'Payload', value: candidateShortLabel(match.candidate) }] : []; +}; + +// parentRows renders the parent tuple a payload was built on: the beacon +// parent (root + its slot) and the execution parent (hash + its block +// number), so the payload's position in the chain is readable. +const parentRows = ( + parentRoot?: string, + parentSlot?: number, + parentHash?: string, + parentNumber?: number +): PopoverItem[] => [ + ...(parentRoot + ? [{ + label: parentSlot !== undefined ? `Parent Block (slot ${parentSlot})` : 'Parent Block', + value: truncateHashStr(parentRoot), + copyValue: parentRoot + }] + : []), + ...(parentHash + ? [{ + label: parentNumber ? `Parent Payload (#${parentNumber})` : 'Parent Payload', + value: truncateHashStr(parentHash), + copyValue: parentHash + }] + : []) +]; + +// truncateHashStr shortens a hash for popover display (module scope twin of +// the component-local helper). +const truncateHashStr = (hash: string, len = 16): string => + hash.length <= len + 3 ? hash : hash.substring(0, len) + '...'; + export const SlotGraph: React.FC = ({ slot, state, @@ -305,6 +443,14 @@ export const SlotGraph: React.FC = ({ ? slotStartTime + buildStartMs + payloadBuildTime : undefined; + // Candidate builds: the primary payload keeps the classic build line; every + // other candidate renders as a thin colored mini-bar below it, and a badge + // with a popover lists them all. One fixed color per candidate key, + // consistent across the UI. + const candidateBuilds = state.candidateBuilds ?? []; + const multiCandidate = candidateBuilds.length > 1; + const primaryCandidate = state.payloadCandidate + ?? (candidateBuilds.length === 1 ? candidateBuilds[0].candidate : undefined); const truncateHash = (hash: string, len = 16) => { if (hash.length <= len + 3) return hash; return hash.substring(0, len) + '...'; @@ -540,8 +686,9 @@ export const SlotGraph: React.FC = ({ totalRange={totalRange} endAt={buildEndAt} expectedEndAt={expectedBuildEndAt} + lineColor={candidateColor(primaryCandidate)} onClick={(e) => showPopover(e, { - title: 'Build Delay', + title: `Build Delay${primaryCandidate ? ` (${candidateLabel(primaryCandidate)})` : ''}`, items: state.payloadCreatedAt ? [ { label: 'Build Start', value: `${buildStartMs}ms` }, @@ -556,6 +703,7 @@ export const SlotGraph: React.FC = ({ /> )} + {/* Build failed — red dot at the failure time (falls back to build start) with error details */} {epbsConfig && buildFailed && genesisTime > 0 && renderEventDot( 'build-failed', @@ -622,17 +770,33 @@ export const SlotGraph: React.FC = ({ `payload-attributes-${i}` ))} - {/* Payload created */} + {/* Payload created — one tab per built candidate payload */} {state.payloadCreatedAt && genesisTime > 0 && renderEventDot( 'payload-created', state.payloadCreatedAt - slotStartTime, { - title: 'Payload Created', + title: multiCandidate ? 'Payloads Created' : 'Payload Created', wide: true, artifact: { url: `/api/buildoor/slot-results/${slot}/payload`, filename: `slot-${slot}-payload.ssz` }, + tabs: multiCandidate + ? [...candidateBuilds] + .sort((a, b) => candidateOrder(a.candidate) - candidateOrder(b.candidate)) + .map(build => ({ + key: build.candidate || 'unclassified', + label: candidateShortLabel(build.candidate), + color: candidateColor(build.candidate), + items: candidatePayloadItems(build, slotStartTime), + artifact: build.candidate + ? { + url: `/api/buildoor/slot-results/${slot}/payload?candidate=${build.candidate}`, + filename: `slot-${slot}-payload-${build.candidate}.ssz` + } + : undefined + })) + : undefined, items: [ { label: 'Time', value: `${state.payloadCreatedAt - slotStartTime}ms` }, ...(state.payloadBlockHash ? [{ @@ -644,6 +808,13 @@ export const SlotGraph: React.FC = ({ label: 'Block Value', value: formatGwei(state.payloadBlockValue) }] : []), + ...(() => { + const primary = candidateBuilds.find(b => b.candidate === primaryCandidate) + ?? candidateBuilds[0]; + + return parentRows(primary?.parentBlockRoot, primary?.parentSlot, + primary?.parentBlockHash, primary?.parentBlockNumber); + })(), ...(state.payloadDetail?.blockNumber !== undefined ? [{ label: 'Block #', value: `${state.payloadDetail.blockNumber}` @@ -753,7 +924,12 @@ export const SlotGraph: React.FC = ({ } ] : []) ] : []), - ...(won ? [{ label: 'Result', value: 'Our payload was included in this block' }] : []) + ...(won + ? [ + { label: 'Result', value: 'Our payload was included in this block' }, + ...candidateRows(candidateBuilds, state.payloadBlockHash) + ] + : []) ] }; return ( @@ -896,6 +1072,7 @@ export const SlotGraph: React.FC = ({ value: truncateHash(bid.blockHash), copyValue: bid.blockHash }] : []), + ...candidateRows(candidateBuilds, bid.blockHash), ...(bid.parentBlockHash ? [{ label: 'Parent Hash', value: truncateHash(bid.parentBlockHash), @@ -1053,7 +1230,8 @@ export const SlotGraph: React.FC = ({ label: 'Block Hash', value: truncateHash(state.getHeaderBlockHash), copyValue: state.getHeaderBlockHash - }] : []) + }] : []), + ...candidateRows(candidateBuilds, state.getHeaderBlockHash) ] }, 'builder-api-get-header' @@ -1099,7 +1277,8 @@ export const SlotGraph: React.FC = ({ label: 'Block Hash', value: truncateHash(state.getBidBlockHash), copyValue: state.getBidBlockHash - }] : []) + }] : []), + ...candidateRows(candidateBuilds, state.getBidBlockHash) ] }, 'builder-api-get-bid' diff --git a/pkg/webui/src/components/actionplan/SlotCell.tsx b/pkg/webui/src/components/actionplan/SlotCell.tsx index 440bc40..9cacc61 100644 --- a/pkg/webui/src/components/actionplan/SlotCell.tsx +++ b/pkg/webui/src/components/actionplan/SlotCell.tsx @@ -102,6 +102,7 @@ const SlotCellInner: React.FC = ({ const payloadStatus = derivePayloadStatus(result); const reorgParent = plan?.build?.reorg_parent_payload === true; + const candidateOverrides = Object.keys(plan?.build?.candidates ?? {}).length > 0; const t = plan?.transforms; const hasTransform = !!(t && (t.payload || t.bid || t.envelope)); @@ -114,6 +115,7 @@ const SlotCellInner: React.FC = ({ if (plan?.builder_api) titleParts.push(`builder api: ${plan.builder_api.mode}`); if (plan?.reveal) titleParts.push(`reveal: ${plan.reveal.mode}`); if (reorgParent) titleParts.push('build: reorg parent (n-2)'); + if (candidateOverrides) titleParts.push('build: candidate overrides'); if (hasTransform) { const targets = ['payload', 'bid', 'envelope'].filter((k) => t?.[k as keyof typeof t]); titleParts.push(`jq transform: ${targets.join(', ')}`); @@ -137,6 +139,7 @@ const SlotCellInner: React.FC = ({ {plan?.builder_api && A} {plan?.reveal && R} {reorgParent && P} + {candidateOverrides && C} {hasTransform && jq} diff --git a/pkg/webui/src/components/actionplan/SlotEditModal.tsx b/pkg/webui/src/components/actionplan/SlotEditModal.tsx index 4587c1c..b5280d6 100644 --- a/pkg/webui/src/components/actionplan/SlotEditModal.tsx +++ b/pkg/webui/src/components/actionplan/SlotEditModal.tsx @@ -706,6 +706,16 @@ export const SlotEditModal: React.FC = ({ bulk ? 'unchanged' : effectivePlan?.build?.reorg_parent_payload ? 'on' : 'off' ); + // Per-candidate build policy overrides ('' = inherit the global policy). + // Single-slot editing only: a bulk category replace would clobber unrelated + // build settings on every targeted slot. + const [buildCandidates, setBuildCandidates] = useState>(() => ({ + parent_full: (!bulk && effectivePlan?.build?.candidates?.parent_full) || '', + parent_empty: (!bulk && effectivePlan?.build?.candidates?.parent_empty) || '', + grandparent_full: (!bulk && effectivePlan?.build?.candidates?.grandparent_full) || '', + grandparent_empty: (!bulk && effectivePlan?.build?.candidates?.grandparent_empty) || '', + })); + // Transforms (modeless jq expressions). In bulk mode we start empty and only // send the ones the operator fills in. const [transforms, setTransforms] = useState(() => ({ @@ -795,8 +805,29 @@ export const SlotEditModal: React.FC = ({ } } - // Build (modeless single flag), resolved as a fine-grained set path. - if (buildReorg !== 'unchanged') { + // Build (modeless). Candidate policy overrides need a full category + // replace (map-valued member); the plain reorg flag alone keeps the + // fine-grained set path (bulk mode only edits the flag). + const candidateEntries = Object.entries(buildCandidates).filter(([, mode]) => mode !== ''); + const initialCandidates = initialPlan?.build?.candidates ?? {}; + const candidatesChanged = isSingle && ( + candidateEntries.length !== Object.keys(initialCandidates).length || + candidateEntries.some(([key, mode]) => initialCandidates[key] !== mode) + ); + + if (candidatesChanged) { + const buildObj: Record = {}; + const wantReorg = buildReorg === 'on'; + + if (wantReorg) buildObj.reorg_parent_payload = true; + + if (candidateEntries.length > 0) { + buildObj.candidates = Object.fromEntries(candidateEntries); + } + + updateRec['build'] = Object.keys(buildObj).length > 0 ? buildObj : null; + hasChange = true; + } else if (buildReorg !== 'unchanged') { const initialReorg = initialPlan?.build?.reorg_parent_payload === true; const wantOn = buildReorg === 'on'; // In single mode skip a no-op; in bulk always write so every targeted @@ -945,6 +976,35 @@ export const SlotEditModal: React.FC = ({ disabled={formDisabled} onChange={setBuildReorg} /> + {!bulk && ( +
+
Build candidates
+
+ Which candidate payloads the slot builds (parent/grandparent block, + full/empty parent payload). Inherit uses the global policy. +
+ {Object.entries(buildCandidates).map(([key, mode]) => ( +
+
{key}
+
+ +
+
+ ))} +
+ )}
Recurring rules
diff --git a/pkg/webui/src/components/actionplan/planForms.tsx b/pkg/webui/src/components/actionplan/planForms.tsx index f7757c1..d961675 100644 --- a/pkg/webui/src/components/actionplan/planForms.tsx +++ b/pkg/webui/src/components/actionplan/planForms.tsx @@ -29,12 +29,20 @@ export const BID_FIELDS: FieldDef[] = [ { key: 'bid_interval', label: 'Bid Interval', unit: 'ms' }, { key: 'bid_subsidy', label: 'Bid Subsidy', unit: 'gwei' }, { key: 'bid_value_gwei', label: 'Bid Value Override', unit: 'gwei' }, + { + key: 'bid_candidate', label: 'Bid Candidate', unit: '', + options: ['auto', 'all', 'parent_full', 'parent_empty', 'grandparent_full', 'grandparent_empty'], + }, ]; export const BUILDER_API_FIELDS: FieldDef[] = [ { key: 'value_subsidy_gwei', label: 'Value Subsidy', unit: 'gwei' }, { key: 'total_value_override_gwei', label: 'Total Value Override', unit: 'gwei' }, { key: 'response_delay_ms', label: 'Response Delay', unit: 'ms' }, + { + key: 'serve_candidates', label: 'Serve Candidates', unit: '', + options: ['all', 'canonical_only', 'parent_full', 'parent_empty', 'grandparent_full', 'grandparent_empty'], + }, ]; export const REVEAL_FIELDS: FieldDef[] = [ diff --git a/pkg/webui/src/hooks/useEventStream.ts b/pkg/webui/src/hooks/useEventStream.ts index fdc3a7f..f994416 100644 --- a/pkg/webui/src/hooks/useEventStream.ts +++ b/pkg/webui/src/hooks/useEventStream.ts @@ -163,6 +163,42 @@ export function useEventStream(): UseEventStreamResult { })); }; + // Candidate builds: rank for primary selection (most canonical first; + // unclassified last) and per-candidate list upsert. + const candidateRank = (candidate?: string): number => { + switch (candidate) { + case 'parent_full': return 0; + case 'parent_empty': return 1; + case 'grandparent_full': return 2; + case 'grandparent_empty': return 3; + default: return 4; + } + }; + + const updateCandidateBuild = ( + slot: number, + candidate: string, + patch: Partial, + primaryUpdates?: (state: SlotState) => Partial + ) => { + setSlotStates(prev => { + const state = prev[slot] || { slot }; + const list = state.candidateBuilds ? [...state.candidateBuilds] : []; + const idx = list.findIndex(b => b.candidate === candidate); + + if (idx >= 0) { + list[idx] = { ...list[idx], ...patch, candidate: candidate as import('../types').CandidateKey }; + } else { + list.push({ candidate: candidate as import('../types').CandidateKey, ...patch }); + } + + const merged: SlotState = { ...state, slot, candidateBuilds: list }; + const extra = primaryUpdates ? primaryUpdates(merged) : undefined; + + return { ...prev, [slot]: extra ? { ...merged, ...extra } : merged }; + }); + }; + const handleEvent = (event: { type: string; timestamp: number; seq?: number; data: unknown }) => { // Drop already-processed events from a reconnect replay. Events // without a seq (per-client initial-state snapshots) always pass. @@ -288,20 +324,36 @@ export function useEventStream(): UseEventStreamResult { } case 'payload_build_started': { - const data = event.data as { slot: number; started_at: number }; - addEvent('payload_build_started', `Payload build started for slot ${data.slot}`, event.timestamp); - updateSlotState(data.slot, { payloadBuildStartedAt: data.started_at }); + const data = event.data as { slot: number; candidate?: string; started_at: number }; + addEvent('payload_build_started', + `Payload build started for slot ${data.slot}${data.candidate ? ` (${data.candidate})` : ''}`, + event.timestamp); + updateCandidateBuild(data.slot, data.candidate ?? '', { startedAt: data.started_at }, + state => ( + // The primary line spans the primary candidate's own build; the + // first start seeds it until a payload becomes primary. + state.payloadBuildStartedAt === undefined + ? { payloadBuildStartedAt: data.started_at } + : {} + )); break; } case 'payload_build_failed': { - const data = event.data as { slot: number; error: string; failed_at: number }; - addEvent('payload_build_failed', `Payload build failed for slot ${data.slot}: ${data.error}`, event.timestamp); - updateSlotState(data.slot, { - payloadBuildFailed: true, - payloadBuildFailedAt: data.failed_at, - payloadBuildError: data.error - }); + const data = event.data as { slot: number; candidate?: string; error: string; failed_at: number }; + addEvent('payload_build_failed', + `Payload build failed for slot ${data.slot}${data.candidate ? ` (${data.candidate})` : ''}: ${data.error}`, + event.timestamp); + updateCandidateBuild(data.slot, data.candidate ?? '', + { failed: true, failedAt: data.failed_at, error: data.error }, + state => (state.payloadReady + // Another candidate already delivered — keep the primary intact. + ? {} + : { + payloadBuildFailed: true, + payloadBuildFailedAt: data.failed_at, + payloadBuildError: data.error + })); break; } @@ -309,31 +361,60 @@ export function useEventStream(): UseEventStreamResult { // block_value is the EL's MEV value as a wei decimal string; convert to // gwei so it matches the gwei-based formatGwei display used elsewhere. const data = event.data as { - slot: number; block_hash: string; block_value: string; ready_at: number; + slot: number; candidate?: string; block_hash: string; block_value: string; ready_at: number; + parent_block_hash?: string; parent_block_number?: number; + parent_block_root?: string; parent_slot?: number; block_number?: number; fee_recipient?: string; gas_limit?: number; gas_used?: number; base_fee_per_gas?: string; extra_data?: string; blob_gas_used?: number; excess_blob_gas?: number; num_transactions?: number; num_withdrawals?: number; num_blobs?: number; num_exec_requests?: number; }; addEvent('payload_ready', `Payload ready for slot ${data.slot} (hash: ${data.block_hash})`, event.timestamp); - updateSlotState(data.slot, { + const detail = { + blockNumber: data.block_number, + feeRecipient: data.fee_recipient, + gasLimit: data.gas_limit, + gasUsed: data.gas_used, + baseFeePerGas: data.base_fee_per_gas, + extraData: data.extra_data, + blobGasUsed: data.blob_gas_used, + excessBlobGas: data.excess_blob_gas, + numTransactions: data.num_transactions, + numWithdrawals: data.num_withdrawals, + numBlobs: data.num_blobs, + numExecRequests: data.num_exec_requests + }; + + updateCandidateBuild(data.slot, data.candidate ?? '', { + readyAt: data.ready_at, + failed: false, + blockHash: data.block_hash, + blockValueGwei: data.block_value ? Number(data.block_value) / 1e9 : 0, + parentBlockHash: data.parent_block_hash, + parentBlockNumber: data.parent_block_number, + parentBlockRoot: data.parent_block_root, + parentSlot: data.parent_slot, + detail + }, state => { + // A less canonical candidate never displaces the primary payload. + if (state.payloadReady && + candidateRank(data.candidate) > candidateRank(state.payloadCandidate)) { + return {}; + } + + // The primary line spans this candidate's own build, so the start + // moves with the primary rather than covering every candidate. + const own = state.candidateBuilds?.find(b => b.candidate === (data.candidate ?? '')); + + return { payloadReady: true, + payloadBuildFailed: false, + payloadCandidate: (data.candidate ?? '') as import('../types').CandidateKey, + payloadBuildStartedAt: own?.startedAt ?? state.payloadBuildStartedAt, payloadCreatedAt: data.ready_at, payloadBlockHash: data.block_hash, payloadBlockValue: data.block_value ? Number(data.block_value) / 1e9 : 0, - payloadDetail: { - blockNumber: data.block_number, - feeRecipient: data.fee_recipient, - gasLimit: data.gas_limit, - gasUsed: data.gas_used, - baseFeePerGas: data.base_fee_per_gas, - extraData: data.extra_data, - blobGasUsed: data.blob_gas_used, - excessBlobGas: data.excess_blob_gas, - numTransactions: data.num_transactions, - numWithdrawals: data.num_withdrawals, - numBlobs: data.num_blobs, - numExecRequests: data.num_exec_requests - } + payloadDetail: detail + }; }); break; } diff --git a/pkg/webui/src/styles.css b/pkg/webui/src/styles.css index bc91efd..768768a 100644 --- a/pkg/webui/src/styles.css +++ b/pkg/webui/src/styles.css @@ -1334,3 +1334,48 @@ body:has(#panda-menu-root) header.header-bar > nav.navbar { font-size: 11px; line-height: 16px; } + +/* Per-candidate payload tabs inside the payload-created popover. */ +.popover-tabs { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-bottom: 6px; +} + +.popover-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + font-size: 10px; + line-height: 16px; + color: #aab2bd; + background: rgba(70, 80, 95, 0.4); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 3px; + cursor: pointer; +} + +.popover-tab:hover { + background: rgba(90, 100, 118, 0.6); + color: #dfe4ea; +} + +.popover-tab.active { + background: rgba(110, 122, 142, 0.85); + color: #fff; + border-color: rgba(255, 255, 255, 0.3); +} + +.popover-tab-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; +} + +.ap-chip-candidates { + background: #7c5cbf; + color: #fff; +} diff --git a/pkg/webui/src/types.ts b/pkg/webui/src/types.ts index cc2a35f..bc83084 100644 --- a/pkg/webui/src/types.ts +++ b/pkg/webui/src/types.ts @@ -23,6 +23,7 @@ export interface Config { schedule: ScheduleConfig; epbs: EPBSConfig; reveal?: RevealConfig; + build?: BuildConfig; deposit_amount: number; topup_threshold: number; topup_amount: number; @@ -30,6 +31,19 @@ export interface Config { extra_data?: string; } +// Build-candidate policy: which parent candidates a slot builds payloads for +// (auto | always | never per candidate) and how the engine builds run. +export interface BuildConfig { + candidate_parent_full?: string; + candidate_parent_empty?: string; + candidate_grandparent_full?: string; + candidate_grandparent_empty?: string; + parallel?: boolean; + speculative_build_time_ms?: number; + auto_weak_head_pct?: number; + enforce_bid_gas_limit?: boolean; +} + // Payload reveal config (own section, shared by the p2p bidder and Builder // API flows). export interface RevealConfig { @@ -284,6 +298,34 @@ export interface PayloadDetail { numExecRequests?: number; } +// Build-parent candidate keys: which beacon block / execution payload a +// candidate payload extends (see the backend chain.CandidateKey). +export type CandidateKey = + | 'parent_full' + | 'parent_empty' + | 'grandparent_full' + | 'grandparent_empty' + | ''; + +// CandidateBuild tracks one candidate payload build of a slot (a slot may +// build several payloads on different parents for reorg preparedness). +export interface CandidateBuild { + candidate: CandidateKey; + startedAt?: number; + readyAt?: number; + failedAt?: number; + failed?: boolean; + error?: string; + blockHash?: string; + blockValueGwei?: number; + // Parent tuple this candidate payload extends. + parentBlockHash?: string; + parentBlockNumber?: number; + parentBlockRoot?: string; + parentSlot?: number; + detail?: PayloadDetail; +} + // UI State types export interface SlotState { slot: number; @@ -297,6 +339,10 @@ export interface SlotState { payloadCreatedAt?: number; payloadBlockHash?: string; payloadBlockValue?: number; + // Candidate of the primary payload (the most canonical ready build). + payloadCandidate?: CandidateKey; + // Every candidate build of the slot (present when candidates ran). + candidateBuilds?: CandidateBuild[]; blockReceivedAt?: number; blockRoot?: string; bidsClosed?: boolean; @@ -498,6 +544,7 @@ export interface BidPlan { bid_subsidy?: number; // gwei bid_value_gwei?: number; // absolute bid base value ignore_missing_prefs?: boolean; + bid_candidate?: string; // auto | all | candidate key } export interface BuilderAPIPlan { @@ -505,6 +552,7 @@ export interface BuilderAPIPlan { value_subsidy_gwei?: number; total_value_override_gwei?: number; response_delay_ms?: number; + serve_candidates?: string; // all | canonical_only | key list } export interface RevealPlan { @@ -519,6 +567,8 @@ export interface RevealPlan { // slot's payload is built when a build happens. export interface BuildPlan { reorg_parent_payload?: boolean; + // Per-slot candidate policy overrides: candidate key -> auto/always/never. + candidates?: Record; } // The transforms category has no mode: each field is a jq expression applied @@ -607,6 +657,7 @@ export interface ResolvedBuildSettings { plan_involved?: boolean; build_start_time_ms: number; reorg_parent_payload?: boolean; + candidate_modes?: Record; } export interface ResolvedBidSettings { @@ -618,6 +669,7 @@ export interface ResolvedBidSettings { subsidy_gwei: number; value_gwei?: number; ignore_missing_prefs?: boolean; + bid_candidate?: string; forced?: boolean; } @@ -625,6 +677,7 @@ export interface ResolvedBuilderAPISettings { subsidy_gwei: number; total_value_gwei?: number; delay_ms?: number; + serve_candidates?: string; forced?: boolean; } @@ -683,6 +736,8 @@ export type RevealAttemptStatus = 'suppressed' | 'published' | 'failed' | 'skipp export interface BuildOutcome { status: BuildStatus; skip_reason?: string; + candidate?: string; // build-parent candidate key ("" / absent = unclassified) + artifact_idx?: number; // payload artifact index of this build block_hash?: string; block_value_wei?: string; num_transactions?: number; @@ -782,6 +837,9 @@ export interface SlotResult { fork: string; applied_plan?: FrozenPlan; build?: BuildOutcome; + // Every candidate build of the slot (present when more than one ran); + // build stays the primary outcome. + builds?: BuildOutcome[]; bids?: SlotBidAttempt[]; block_submissions?: SlotBlockSubmission[]; reveal_attempts?: SlotRevealAttempt[]; diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go index 998801e..af93b61 100644 --- a/pkg/webui/webui.go +++ b/pkg/webui/webui.go @@ -95,6 +95,7 @@ func StartHttpServer(frontendConfig *types.FrontendConfig, settingsSvc *config.S apiRouter.HandleFunc("/buildoor/action-plan/test-transform", apiHandler.TestTransform).Methods(http.MethodPost) apiRouter.HandleFunc("/buildoor/slot-results", apiHandler.GetSlotResults).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/payload", apiHandler.GetSlotPayloadArtifact).Methods(http.MethodGet) + apiRouter.HandleFunc("/buildoor/slot-results/{slot}/payload/{index:[0-9]+}", apiHandler.GetSlotPayloadArtifactByIndex).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/bids", apiHandler.GetSlotBidArtifacts).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/bids/{index}", apiHandler.GetSlotBidArtifact).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/envelope", apiHandler.GetSlotEnvelopeArtifact).Methods(http.MethodGet)