From cb0724e3258ae15e1f1417dafde674bcfd9aae49 Mon Sep 17 00:00:00 2001 From: Damilola Edwards Date: Mon, 10 Aug 2026 21:15:01 +0100 Subject: [PATCH] Resolve builder index from chain state instead of only lifecycle registration RevealService and the epbs Builder API handler only learned the on-chain builder index through a lifecycle registration callback, which never fires when the process runs without --el-rpc/--wallet-privkey. A builder already registered on-chain in that setup would sign every bid and reveal envelope with index 0, producing consensus-invalid messages with no error surfaced anywhere. Both now resolve the index from chain state directly (GetBuilderByPubkey) at startup, matching the pattern the p2p bidder already uses. The lifecycle callback still updates the index live when registration happens during the run. --- pkg/builderapi/epbs/handler.go | 18 +++++++++-- pkg/builderapi/epbs/handler_test.go | 28 +++++++++++++++++ pkg/payload_bidder/mockchain_test.go | 9 ++++-- pkg/payload_bidder/reveal_service.go | 10 ++++++ pkg/payload_bidder/reveal_service_test.go | 38 +++++++++++++++++++++++ pkg/payload_bidder/signer.go | 5 +++ 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/pkg/builderapi/epbs/handler.go b/pkg/builderapi/epbs/handler.go index 35cdd2a0..9367b814 100644 --- a/pkg/builderapi/epbs/handler.go +++ b/pkg/builderapi/epbs/handler.go @@ -105,7 +105,7 @@ type Handler struct { lastBidMu sync.Mutex lastBids map[phase0.Slot]recordedBid // dedupe of repeated identical bid records - builderIndex atomic.Uint64 // builder index used in Gloas bids; set after lifecycle registration + builderIndex atomic.Uint64 // builder index used in Gloas bids; resolved at construction, refreshed on lifecycle registration enabled atomic.Bool bidsRequested atomic.Uint64 // count of getExecutionPayloadBid requests received blocksAccepted atomic.Uint64 // count of accepted signed beacon blocks @@ -118,7 +118,7 @@ type Handler struct { func NewHandler(cfg *config.BuilderAPIConfig, log logrus.FieldLogger, chainSvc chain.Service, planSvc *action_plan.PlanService, payloadCache *payload_builder.PayloadCache, blsSigner *signer.BLSSigner) *Handler { - return &Handler{ + h := &Handler{ cfg: cfg, log: log.WithField("component", "builderapi-epbs"), chainSvc: chainSvc, @@ -129,6 +129,20 @@ func NewHandler(cfg *config.BuilderAPIConfig, log logrus.FieldLogger, chainSvc c prefsStore: NewBuilderPreferencesStore(), lastBids: make(map[phase0.Slot]recordedBid, maxRecordedBidSlots), } + + // Resolve the builder index from chain state up front, independent of + // lifecycle registration. Without this, a builder already registered + // on-chain before this process started would sign every bid with the + // zero-value index until (if ever) the lifecycle callback fires. + // SetBuilderIndex still overrides this on a live registration event. + if chainSvc != nil && blsSigner != nil { + if builderInfo := chainSvc.GetBuilderByPubkey(blsSigner.PublicKey()); builderInfo != nil { + h.builderIndex.Store(builderInfo.Index) + h.log.WithField("builder_index", builderInfo.Index).Info("Resolved builder index from chain state") + } + } + + return h } // SetResultRecorder wires the optional per-slot result recorder. diff --git a/pkg/builderapi/epbs/handler_test.go b/pkg/builderapi/epbs/handler_test.go index f7e31109..26fea2f1 100644 --- a/pkg/builderapi/epbs/handler_test.go +++ b/pkg/builderapi/epbs/handler_test.go @@ -301,6 +301,34 @@ func postBeaconBlock(h *Handler, body []byte) *httptest.ResponseRecorder { return rec } +// TestNewHandler_ResolvesBuilderIndexFromChainState guards against a builder +// already registered on-chain before this process started signing every bid +// with the zero-value index. NewHandler must resolve the real index from +// chain state itself, without waiting for a lifecycle registration callback +// that may never fire in this process. +func TestNewHandler_ResolvesBuilderIndexFromChainState(t *testing.T) { + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + blsSigner, err := signer.NewBLSSigner("0x0000000000000000000000000000000000000000000000000000000000000001") + require.NoError(t, err) + + chainSvc := &stubChainService{ + genesisTime: time.Now().Add(-4 * time.Second), + slotDuration: 4 * time.Second, + currentFork: version.DataVersionGloas, + builderInfo: &chain.BuilderInfo{Index: 7}, + } + + cfg := &config.Config{} + planSvc := action_plan.NewPlanService(cfg, chainSvc, log) + + h := NewHandler(&cfg.BuilderAPI, log, chainSvc, planSvc, payload_builder.NewPayloadCache(10), blsSigner) + + assert.Equal(t, uint64(7), h.builderIndex.Load(), + "NewHandler must resolve the builder index from chain state, not leave it at the zero-value default") +} + // TestHandleSubmitBeaconBlock_Success broadcasts the block immediately, // returns 202 without publishing anything during the request, and reveals the // envelope exactly once via the RevealService after the reveal time. diff --git a/pkg/payload_bidder/mockchain_test.go b/pkg/payload_bidder/mockchain_test.go index 99ded49a..2049aec0 100644 --- a/pkg/payload_bidder/mockchain_test.go +++ b/pkg/payload_bidder/mockchain_test.go @@ -29,6 +29,7 @@ type stubChainService struct { currentEpoch phase0.Epoch genesis beacon.Genesis headTracker *chain.HeadTracker + builderInfo *chain.BuilderInfo epochStatsDispatch utils.Dispatcher[*chain.EpochStats] } @@ -91,9 +92,11 @@ func (m *stubChainService) GetHeadVoteTracker() *chain.HeadVoteTracker { return 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 } -func (m *stubChainService) GetBuilderByPubkey(phase0.BLSPubKey) *chain.BuilderInfo { return nil } -func (m *stubChainService) GetBuilders() []*chain.BuilderInfo { return nil } +func (m *stubChainService) GetBuilderByIndex(uint64) *chain.BuilderInfo { return nil } +func (m *stubChainService) GetBuilderByPubkey(phase0.BLSPubKey) *chain.BuilderInfo { + return m.builderInfo +} +func (m *stubChainService) GetBuilders() []*chain.BuilderInfo { return nil } func (m *stubChainService) GetValidatorPubkeyByIndex(phase0.ValidatorIndex) *phase0.BLSPubKey { return nil diff --git a/pkg/payload_bidder/reveal_service.go b/pkg/payload_bidder/reveal_service.go index c3faff1c..483739b6 100644 --- a/pkg/payload_bidder/reveal_service.go +++ b/pkg/payload_bidder/reveal_service.go @@ -200,6 +200,16 @@ func NewRevealService( func (s *RevealService) Start(ctx context.Context) error { s.ctx, s.cancel = context.WithCancel(ctx) + // Resolve the builder index from chain state up front, independent of + // lifecycle registration. Without this, a builder already registered + // on-chain before this process started would sign every envelope with + // the zero-value index until (if ever) the lifecycle callback fires. + // SetBuilderIndex still overrides this on a live registration event. + if builderInfo := s.chainSvc.GetBuilderByPubkey(s.signer.PublicKey()); builderInfo != nil { + s.builderIndex.Store(builderInfo.Index) + s.log.WithField("builder_index", builderInfo.Index).Info("Resolved builder index from chain state") + } + s.wg.Add(1) go s.run() diff --git a/pkg/payload_bidder/reveal_service_test.go b/pkg/payload_bidder/reveal_service_test.go index df16036f..c830e5fd 100644 --- a/pkg/payload_bidder/reveal_service_test.go +++ b/pkg/payload_bidder/reveal_service_test.go @@ -196,6 +196,44 @@ func waitForResult(t *testing.T, ch <-chan *RevealResult, timeout time.Duration) } } +// TestRevealService_ResolvesBuilderIndexFromChainStateOnStart guards against +// a builder already registered on-chain before this process started signing +// every envelope with the zero-value index. Start must resolve the real +// index from chain state itself, without waiting for a lifecycle +// registration callback that may never fire in this process. +func TestRevealService_ResolvesBuilderIndexFromChainStateOnStart(t *testing.T) { + log := logrus.New() + log.SetLevel(logrus.PanicLevel) + + blsSigner, err := signer.NewBLSSigner("0x0000000000000000000000000000000000000000000000000000000000000001") + require.NoError(t, err) + + chainSvc := &stubChainService{ + genesisTime: time.Now().Add(-4 * time.Second), + slotDuration: 4 * time.Second, + currentFork: version.DataVersionGloas, + builderInfo: &chain.BuilderInfo{Index: 7}, + } + + builderSvc := newTestBuilderSvc(chainSvc) + payments := NewPaymentTracker(chainSvc, log) + publisher := &mockEnvelopePublisher{} + cfg := &config.Config{} + planSvc := action_plan.NewPlanService(cfg, chainSvc, log) + votes := newStubVoteSource() + + svc := NewRevealService(cfg, NewSigner(blsSigner), publisher, chainSvc, builderSvc, + payments, planSvc, votes, log) + + require.Equal(t, uint64(0), svc.builderIndex.Load(), "precondition: index unset before Start") + + require.NoError(t, svc.Start(context.Background())) + defer svc.Stop() + + assert.Equal(t, uint64(7), svc.builderIndex.Load(), + "Start must resolve the builder index from chain state, not leave it at the zero-value default") +} + func TestRevealService_RevealsAtDueTime(t *testing.T) { env := newRevealTestEnv(t, 4*time.Second, 500) sub := env.svc.SubscribeResults(4, false) diff --git a/pkg/payload_bidder/signer.go b/pkg/payload_bidder/signer.go index 7530025b..bbf5c8d0 100644 --- a/pkg/payload_bidder/signer.go +++ b/pkg/payload_bidder/signer.go @@ -35,6 +35,11 @@ func NewSigner(blsSigner *signer.BLSSigner) *Signer { return &Signer{blsSigner: blsSigner} } +// PublicKey returns the builder's BLS public key. +func (s *Signer) PublicKey() phase0.BLSPubKey { + return s.blsSigner.PublicKey() +} + // SignBid signs an execution payload bid. forkVersion must be the fork version // the consensus client verifies against (the Gloas fork version). func (s *Signer) SignBid(