From c86e80a34908aca7f9c3c44ec63163308f66aef6 Mon Sep 17 00:00:00 2001 From: bar-bera Date: Mon, 20 Apr 2026 11:57:54 +0200 Subject: [PATCH 01/10] health check --- beacon/preconf/server.go | 60 +++++++++++- beacon/preconf/server_test.go | 127 ++++++++++++++++++++++++- beacon/preconf/types.go | 18 ++++ node-core/components/preconf_server.go | 6 ++ 4 files changed, 207 insertions(+), 4 deletions(-) diff --git a/beacon/preconf/server.go b/beacon/preconf/server.go index 2e26ad161d..ef17995413 100644 --- a/beacon/preconf/server.go +++ b/beacon/preconf/server.go @@ -59,6 +59,20 @@ type PayloadProvider interface { GetPayloadBySlot(ctx context.Context, slot math.Slot, parentBlockRoot common.Root) (ctypes.BuiltExecutionPayloadEnv, error) } +// SyncChecker exposes the node's sync status for health checks. +type SyncChecker interface { + // IsAppReady returns nil if the node has committed at least one block. + IsAppReady() error + // GetSyncData returns the latest committed height and the target height being synced to. + GetSyncData() (latestHeight int64, syncToHeight int64) +} + +// ELChecker exposes the execution-layer client's connectivity status. +type ELChecker interface { + // IsConnected returns true if the execution client is reachable. + IsConnected() bool +} + // Server is the preconf API server that serves GetPayload requests from validators. type Server struct { logger log.Logger @@ -66,6 +80,8 @@ type Server struct { whitelist Whitelist preconfProposerTracker ProposerTracker payloadProvider PayloadProvider + syncChecker SyncChecker + elChecker ELChecker port int mu sync.RWMutex @@ -79,6 +95,8 @@ func NewServer( whitelist Whitelist, preconfProposerTracker ProposerTracker, payloadProvider PayloadProvider, + syncChecker SyncChecker, + elChecker ELChecker, port int, ) *Server { return &Server{ @@ -87,6 +105,8 @@ func NewServer( whitelist: whitelist, preconfProposerTracker: preconfProposerTracker, payloadProvider: payloadProvider, + syncChecker: syncChecker, + elChecker: elChecker, port: port, } } @@ -157,13 +177,49 @@ func (s *Server) Stop() error { return server.Shutdown(ctx) } -// handleHealth just sends 200 OK to the health check endpoint. +// handleHealth checks sync status and returns 200 when the sequencer is synced +// and ready to produce blocks, or 503 when it is not. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } - w.WriteHeader(http.StatusOK) + + resp := s.buildHealthResponse() + + w.Header().Set("Content-Type", "application/json") + if !resp.IsReady || resp.IsSyncing || !resp.ELConnected { + w.WriteHeader(http.StatusServiceUnavailable) + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + s.logger.Error("Failed to encode health response", "error", err) + } +} + +// buildHealthResponse inspects the node's sync state and EL connectivity +// and produces a HealthResponse. +func (s *Server) buildHealthResponse() *HealthResponse { + resp := &HealthResponse{ + IsReady: true, + ELConnected: true, + } + + if s.syncChecker != nil { + resp.IsReady = s.syncChecker.IsAppReady() == nil + latestHeight, syncToHeight := s.syncChecker.GetSyncData() + resp.HeadSlot = latestHeight + resp.SyncDistance = syncToHeight - latestHeight + if resp.SyncDistance < 0 { + resp.SyncDistance = 0 + } + resp.IsSyncing = resp.SyncDistance > 0 + } + + if s.elChecker != nil { + resp.ELConnected = s.elChecker.IsConnected() + } + + return resp } // handleGetPayload handles the GetPayload endpoint. diff --git a/beacon/preconf/server_test.go b/beacon/preconf/server_test.go index 11ae69d4e6..e786f6fbf0 100644 --- a/beacon/preconf/server_test.go +++ b/beacon/preconf/server_test.go @@ -32,6 +32,7 @@ import ( "github.com/berachain/beacon-kit/beacon/preconf" "github.com/berachain/beacon-kit/cli/utils/parser" + "github.com/berachain/beacon-kit/errors" ctypes "github.com/berachain/beacon-kit/consensus-types/types" engineprimitives "github.com/berachain/beacon-kit/engine-primitives/engine-primitives" "github.com/berachain/beacon-kit/log/noop" @@ -120,6 +121,8 @@ func TestServer_HandleGetPayload(t *testing.T) { newTestWhitelist(t, pubkeyAHex, pubkeyBHex), tracker, provider, + &mockSyncChecker{ready: true}, + &mockELChecker{connected: true}, 0, ) @@ -198,6 +201,8 @@ func TestServer_ProposerCheck(t *testing.T) { newTestWhitelist(t, pubkeyAHex, pubkeyBHex), tt.setupTracker(), &mockPayloadProvider{hasPayload: true}, + &mockSyncChecker{ready: true}, + &mockELChecker{connected: true}, 0, ) @@ -220,7 +225,7 @@ func TestServer_ProposerCheck(t *testing.T) { func TestServer_RejectsNonPostMethods(t *testing.T) { t.Parallel() - server := preconf.NewServer(noop.NewLogger[any](), nil, nil, nil, nil, 0) + server := preconf.NewServer(noop.NewLogger[any](), nil, nil, nil, nil, nil, nil, 0) for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { req := httptest.NewRequest(method, preconf.PayloadEndpoint, nil) @@ -263,7 +268,7 @@ func TestServer_OnSIGHUP(t *testing.T) { wl, err := preconf.NewWhitelist(tmpFile) require.NoError(t, err) - server := preconf.NewServer(noop.NewLogger[any](), nil, wl, nil, nil, 0) + server := preconf.NewServer(noop.NewLogger[any](), nil, wl, nil, nil, nil, nil, 0) require.True(t, wl.IsWhitelisted(pkA)) require.False(t, wl.IsWhitelisted(pkB)) @@ -314,3 +319,121 @@ func (m *mockPayloadEnvelope) GetEncodedExecutionRequests() []ctypes.EncodedExec } func (m *mockPayloadEnvelope) ShouldOverrideBuilder() bool { return false } + +// mockSyncChecker implements preconf.SyncChecker for tests. +type mockSyncChecker struct { + ready bool + latestHeight int64 + syncToHeight int64 +} + +func (m *mockSyncChecker) IsAppReady() error { + if !m.ready { + return errors.New("app not ready") + } + return nil +} + +func (m *mockSyncChecker) GetSyncData() (int64, int64) { + return m.latestHeight, m.syncToHeight +} + +// mockELChecker implements preconf.ELChecker for tests. +type mockELChecker struct { + connected bool +} + +func (m *mockELChecker) IsConnected() bool { + return m.connected +} + +func TestServer_HealthEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + syncChecker *mockSyncChecker + elChecker *mockELChecker + wantStatus int + wantReady bool + wantSync bool + wantELConn bool + }{ + { + name: "healthy - synced, ready, EL connected", + syncChecker: &mockSyncChecker{ready: true, latestHeight: 100, syncToHeight: 100}, + elChecker: &mockELChecker{connected: true}, + wantStatus: http.StatusOK, + wantReady: true, + wantSync: false, + wantELConn: true, + }, + { + name: "unhealthy - still syncing", + syncChecker: &mockSyncChecker{ready: true, latestHeight: 50, syncToHeight: 100}, + elChecker: &mockELChecker{connected: true}, + wantStatus: http.StatusServiceUnavailable, + wantReady: true, + wantSync: true, + wantELConn: true, + }, + { + name: "unhealthy - app not ready", + syncChecker: &mockSyncChecker{ready: false, latestHeight: 0, syncToHeight: 0}, + elChecker: &mockELChecker{connected: true}, + wantStatus: http.StatusServiceUnavailable, + wantReady: false, + wantSync: false, + wantELConn: true, + }, + { + name: "unhealthy - EL disconnected", + syncChecker: &mockSyncChecker{ready: true, latestHeight: 100, syncToHeight: 100}, + elChecker: &mockELChecker{connected: false}, + wantStatus: http.StatusServiceUnavailable, + wantReady: true, + wantSync: false, + wantELConn: false, + }, + { + name: "healthy - nil checkers", + wantStatus: http.StatusOK, + wantReady: true, + wantSync: false, + wantELConn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var syncChecker preconf.SyncChecker + if tt.syncChecker != nil { + syncChecker = tt.syncChecker + } + var elChecker preconf.ELChecker + if tt.elChecker != nil { + elChecker = tt.elChecker + } + + server := preconf.NewServer( + noop.NewLogger[any](), nil, nil, nil, nil, + syncChecker, elChecker, 0, + ) + + req := httptest.NewRequest(http.MethodGet, preconf.HealthEndpoint, nil) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + require.Equal(t, tt.wantStatus, rec.Code) + + var resp preconf.HealthResponse + err := json.NewDecoder(rec.Body).Decode(&resp) + require.NoError(t, err) + require.Equal(t, tt.wantReady, resp.IsReady) + require.Equal(t, tt.wantSync, resp.IsSyncing) + require.Equal(t, tt.wantELConn, resp.ELConnected) + }) + } +} diff --git a/beacon/preconf/types.go b/beacon/preconf/types.go index 29290a10ca..7f1d2e3fd0 100644 --- a/beacon/preconf/types.go +++ b/beacon/preconf/types.go @@ -100,6 +100,24 @@ func NewGetPayloadResponseFromEnvelope(env ctypes.BuiltExecutionPayloadEnv) *Get } } +// HealthResponse is the response body for the health endpoint. +type HealthResponse struct { + // IsReady indicates whether the node has committed at least one block. + IsReady bool `json:"is_ready"` + + // IsSyncing indicates whether the node is still catching up with the chain. + IsSyncing bool `json:"is_syncing"` + + // ELConnected indicates whether the execution-layer client is reachable. + ELConnected bool `json:"el_connected"` + + // HeadSlot is the latest committed block height. + HeadSlot int64 `json:"head_slot"` + + // SyncDistance is the number of slots remaining until the node is synced. + SyncDistance int64 `json:"sync_distance"` +} + // ErrorResponse is the error response body. type ErrorResponse struct { // Code is the error code. diff --git a/node-core/components/preconf_server.go b/node-core/components/preconf_server.go index 82b511cc6b..f762eeb2cc 100644 --- a/node-core/components/preconf_server.go +++ b/node-core/components/preconf_server.go @@ -25,7 +25,9 @@ import ( "github.com/berachain/beacon-kit/beacon/preconf" "github.com/berachain/beacon-kit/config" "github.com/berachain/beacon-kit/errors" + "github.com/berachain/beacon-kit/execution/client" "github.com/berachain/beacon-kit/log/phuslu" + "github.com/berachain/beacon-kit/node-core/types" payloadbuilder "github.com/berachain/beacon-kit/payload/builder" ) @@ -38,6 +40,8 @@ type PreconfServerInput struct { Whitelist preconf.Whitelist PreconfProposerTracker preconf.ProposerTracker LocalBuilder *payloadbuilder.PayloadBuilder + ConsensusService types.ConsensusService + EngineClient *client.EngineClient } // ProvidePreconfServer provides the preconf API server for sequencer mode. @@ -84,6 +88,8 @@ func ProvidePreconfServer(in PreconfServerInput) (*preconf.Server, error) { in.Whitelist, in.PreconfProposerTracker, in.LocalBuilder, + in.ConsensusService, + in.EngineClient, cfg.APIPort, ), nil } From 43afcd991ac8a1adc11373c714d3aac2dedb4189 Mon Sep 17 00:00:00 2001 From: bar-bera Date: Mon, 20 Apr 2026 17:22:53 +0200 Subject: [PATCH 02/10] unavailable by default --- beacon/preconf/server.go | 5 +---- beacon/preconf/server_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/beacon/preconf/server.go b/beacon/preconf/server.go index ef17995413..bdb5d56be5 100644 --- a/beacon/preconf/server.go +++ b/beacon/preconf/server.go @@ -199,10 +199,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { // buildHealthResponse inspects the node's sync state and EL connectivity // and produces a HealthResponse. func (s *Server) buildHealthResponse() *HealthResponse { - resp := &HealthResponse{ - IsReady: true, - ELConnected: true, - } + resp := new(HealthResponse) if s.syncChecker != nil { resp.IsReady = s.syncChecker.IsAppReady() == nil diff --git a/beacon/preconf/server_test.go b/beacon/preconf/server_test.go index e786f6fbf0..2f110e5c74 100644 --- a/beacon/preconf/server_test.go +++ b/beacon/preconf/server_test.go @@ -396,11 +396,11 @@ func TestServer_HealthEndpoint(t *testing.T) { wantELConn: false, }, { - name: "healthy - nil checkers", - wantStatus: http.StatusOK, - wantReady: true, + name: "unhealthy - nil checkers", + wantStatus: http.StatusServiceUnavailable, + wantReady: false, wantSync: false, - wantELConn: true, + wantELConn: false, }, } From bc718024ee71dc19292402c6aa6fcec41ec8149b Mon Sep 17 00:00:00 2001 From: bar-bera Date: Mon, 20 Apr 2026 17:53:23 +0200 Subject: [PATCH 03/10] lint --- beacon/preconf/server_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beacon/preconf/server_test.go b/beacon/preconf/server_test.go index 2f110e5c74..d684ce12c4 100644 --- a/beacon/preconf/server_test.go +++ b/beacon/preconf/server_test.go @@ -32,9 +32,9 @@ import ( "github.com/berachain/beacon-kit/beacon/preconf" "github.com/berachain/beacon-kit/cli/utils/parser" - "github.com/berachain/beacon-kit/errors" ctypes "github.com/berachain/beacon-kit/consensus-types/types" engineprimitives "github.com/berachain/beacon-kit/engine-primitives/engine-primitives" + "github.com/berachain/beacon-kit/errors" "github.com/berachain/beacon-kit/log/noop" "github.com/berachain/beacon-kit/primitives/common" "github.com/berachain/beacon-kit/primitives/crypto" @@ -351,13 +351,13 @@ func TestServer_HealthEndpoint(t *testing.T) { t.Parallel() tests := []struct { - name string - syncChecker *mockSyncChecker - elChecker *mockELChecker - wantStatus int - wantReady bool - wantSync bool - wantELConn bool + name string + syncChecker *mockSyncChecker + elChecker *mockELChecker + wantStatus int + wantReady bool + wantSync bool + wantELConn bool }{ { name: "healthy - synced, ready, EL connected", From 57835ec08e70ef9c71692add72e941cfc2180c5b Mon Sep 17 00:00:00 2001 From: bar-bera Date: Tue, 21 Apr 2026 11:04:44 +0200 Subject: [PATCH 04/10] fix sequencer recovery test after health impl --- testing/e2e/preconf/flow_test.go | 50 ++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/testing/e2e/preconf/flow_test.go b/testing/e2e/preconf/flow_test.go index 30f874b364..7f3a56505a 100644 --- a/testing/e2e/preconf/flow_test.go +++ b/testing/e2e/preconf/flow_test.go @@ -23,6 +23,10 @@ package preconf_test import ( + "slices" + "strings" + "time" + "github.com/berachain/beacon-kit/testing/e2e/config" "github.com/berachain/beacon-kit/testing/e2e/suite" ) @@ -40,6 +44,9 @@ const ( // Number of blocks to wait. blocksToWait = 20 blocksAfterFallback = 10 + + // Upper bound for the sequencer to come back up. + sequencerRecoveryTimeout = 90 * time.Second ) // TestSequencerFlow verifies the preconf pathway using ordered subtests. @@ -151,16 +158,8 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { err := s.StartService(sequencerCLService) s.Require().NoError(err, "Should restart sequencer service") - elClient := s.ExecutionClients(0) - - // Wait for enough blocks so each validator has had the chance to propose - // at least once after the monitor detects recovery. - currentBlock, err := elClient.BlockNumber(s.Ctx()) - s.Require().NoError(err, "Should get current block number") - err = s.WaitForFinalizedBlockNumber(currentBlock + blocksAfterFallback) - s.Require().NoError(err, "Network should continue producing blocks after sequencer restarted") - - // Validators should have detected recovery and resumed fetching from the sequencer. + // Each validator must see the sequencer healthy after pod restart + resync. + // Poll the logs so we tolerate variable startup latency. validators := []string{ config.ValidatorConsensusClientName(0), config.ValidatorConsensusClientName(1), @@ -169,18 +168,25 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { config.ValidatorConsensusClientName(4), } for _, validator := range validators { - logs, err := s.GetServiceLogs(validator) - s.Require().NoError(err, "Should get logs for %s", validator) - - s.Require().True(suite.ContainsLogMessage(logs, sequencerRecoveredLog), - "Validator %s health monitor should have detected sequencer recovery. "+ - "Expected log message containing: %q", - validator, sequencerRecoveredLog) - - s.Require().True(suite.ContainsLogMessage(logs, validatorFetchingLog), - "Validator %s should resume fetching from sequencer after recovery. "+ - "Expected log message containing: %q", - validator, validatorFetchingLog) + validator := validator + s.Require().Eventuallyf(func() bool { + logs, logErr := s.GetServiceLogs(validator) + if logErr != nil { + return false + } + + // Require a fetch AFTER recovery: a fetch log preceding the outage + // would otherwise falsely satisfy a flat Contains check. + recoveryIdx := slices.IndexFunc(logs, func(line string) bool { + return strings.Contains(line, sequencerRecoveredLog) + }) + + return recoveryIdx >= 0 && + suite.ContainsLogMessage(logs[recoveryIdx+1:], validatorFetchingLog) + }, sequencerRecoveryTimeout, time.Second, + "Validator %s should detect sequencer recovery and resume fetching. "+ + "Expected log %q followed by %q", + validator, sequencerRecoveredLog, validatorFetchingLog) } }) } From b4889299cac693f40d8fe24f8fb5ecba611bb360 Mon Sep 17 00:00:00 2001 From: bar-bera Date: Tue, 21 Apr 2026 17:28:11 +0200 Subject: [PATCH 05/10] update connected flag on EL requests --- execution/client/client.go | 23 +++++++++-------------- execution/client/engine.go | 3 +++ execution/client/errors.go | 3 +++ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/execution/client/client.go b/execution/client/client.go index 1821961674..9e849e76a8 100644 --- a/execution/client/client.go +++ b/execution/client/client.go @@ -24,7 +24,7 @@ import ( "context" "fmt" "math/big" - "sync" + "sync/atomic" "time" "github.com/berachain/beacon-kit/errors" @@ -49,10 +49,9 @@ type EngineClient struct { metrics *clientMetrics // capabilities is a map of capabilities that the execution client has. capabilities map[string]struct{} - // connected will be set to true when we have successfully connected - // to the execution client. - connectedMu sync.RWMutex - connected bool + // connected reflects live EL reachability: flipped to true on any + // successful Engine API call, and to false on connection-level errors. + connected atomic.Bool } // New creates a new engine client EngineClient. @@ -99,7 +98,6 @@ func New( capabilities: make(map[string]struct{}), eth1ChainID: eth1ChainID, metrics: newClientMetrics(telemetrySink, logger), - connected: false, } } @@ -123,9 +121,10 @@ func (s *EngineClient) Start(ctx context.Context) error { "dial_url", s.cfg.RPCDialURL.String(), ) - // If the connection connection succeeds, we can skip the - // connection initialization loop. + // If the connection succeeds, we can skip the connection + // initialization loop. if err := s.verifyChainIDAndConnection(ctx); err == nil { + s.connected.Store(true) return nil } @@ -147,9 +146,7 @@ func (s *EngineClient) Start(ctx context.Context) error { } continue } - s.connectedMu.Lock() - s.connected = true - s.connectedMu.Unlock() + s.connected.Store(true) return nil } } @@ -160,9 +157,7 @@ func (s *EngineClient) Stop() error { } func (s *EngineClient) IsConnected() bool { - s.connectedMu.RLock() - defer s.connectedMu.RUnlock() - return s.connected + return s.connected.Load() } func (s *EngineClient) HasCapability(capability string) bool { diff --git a/execution/client/engine.go b/execution/client/engine.go index dc7eb62f55..7a34c9ffef 100644 --- a/execution/client/engine.go +++ b/execution/client/engine.go @@ -56,6 +56,7 @@ func (s *EngineClient) NewPayload( } return nil, s.handleRPCError(err) } + s.connected.Store(true) if result == nil { return nil, engineerrors.ErrNilPayloadStatus } @@ -107,6 +108,7 @@ func (s *EngineClient) ForkchoiceUpdated( } return nil, s.handleRPCError(err) } + s.connected.Store(true) if result == nil { return nil, engineerrors.ErrNilForkchoiceResponse } @@ -144,6 +146,7 @@ func (s *EngineClient) GetPayload( } return result, s.handleRPCError(err) } + s.connected.Store(true) if result == nil { // Engine API returns the Unknown Payload (-38001) error if a nil result is returned. return result, engineerrors.ErrUnknownPayload diff --git a/execution/client/errors.go b/execution/client/errors.go index c52169034d..40060f05ae 100644 --- a/execution/client/errors.go +++ b/execution/client/errors.go @@ -70,6 +70,9 @@ func (s *EngineClient) handleRPCError( var e jsonrpc.Error ok := errors.As(err, &e) if !ok || e == nil { + // No JSON-RPC response at all — the EL is unreachable. Mark the + // client as disconnected so the preconf health endpoint reflects it. + s.connected.Store(false) return errors.Join(ErrBadConnection, err) } From 4b0a2be6bd51c2f9d1b2e88f81e253be195ff018 Mon Sep 17 00:00:00 2001 From: bar-bera Date: Tue, 21 Apr 2026 17:40:58 +0200 Subject: [PATCH 06/10] remove unchecked fields - useless --- beacon/preconf/server.go | 10 +++------- beacon/preconf/types.go | 8 ++------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/beacon/preconf/server.go b/beacon/preconf/server.go index bdb5d56be5..5843e2aee1 100644 --- a/beacon/preconf/server.go +++ b/beacon/preconf/server.go @@ -188,7 +188,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := s.buildHealthResponse() w.Header().Set("Content-Type", "application/json") - if !resp.IsReady || resp.IsSyncing || !resp.ELConnected { + healthy := resp.IsReady && !resp.IsSyncing && resp.ELConnected + if !healthy { w.WriteHeader(http.StatusServiceUnavailable) } if err := json.NewEncoder(w).Encode(resp); err != nil { @@ -204,12 +205,7 @@ func (s *Server) buildHealthResponse() *HealthResponse { if s.syncChecker != nil { resp.IsReady = s.syncChecker.IsAppReady() == nil latestHeight, syncToHeight := s.syncChecker.GetSyncData() - resp.HeadSlot = latestHeight - resp.SyncDistance = syncToHeight - latestHeight - if resp.SyncDistance < 0 { - resp.SyncDistance = 0 - } - resp.IsSyncing = resp.SyncDistance > 0 + resp.IsSyncing = syncToHeight > latestHeight } if s.elChecker != nil { diff --git a/beacon/preconf/types.go b/beacon/preconf/types.go index 7f1d2e3fd0..c9cf1e84c6 100644 --- a/beacon/preconf/types.go +++ b/beacon/preconf/types.go @@ -101,6 +101,8 @@ func NewGetPayloadResponseFromEnvelope(env ctypes.BuiltExecutionPayloadEnv) *Get } // HealthResponse is the response body for the health endpoint. +// For richer sync metadata (head slot, sync distance), see the +// standard /eth/v1/node/syncing endpoint. type HealthResponse struct { // IsReady indicates whether the node has committed at least one block. IsReady bool `json:"is_ready"` @@ -110,12 +112,6 @@ type HealthResponse struct { // ELConnected indicates whether the execution-layer client is reachable. ELConnected bool `json:"el_connected"` - - // HeadSlot is the latest committed block height. - HeadSlot int64 `json:"head_slot"` - - // SyncDistance is the number of slots remaining until the node is synced. - SyncDistance int64 `json:"sync_distance"` } // ErrorResponse is the error response body. From 31d83f98a6ecfb59d25058bebfd16292d963f8cd Mon Sep 17 00:00:00 2001 From: bar-bera Date: Thu, 23 Apr 2026 11:31:19 +0200 Subject: [PATCH 07/10] merge fix --- beacon/preconf/server_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beacon/preconf/server_test.go b/beacon/preconf/server_test.go index f0436d612f..45a5501fc6 100644 --- a/beacon/preconf/server_test.go +++ b/beacon/preconf/server_test.go @@ -35,7 +35,6 @@ import ( "github.com/berachain/beacon-kit/cli/utils/parser" ctypes "github.com/berachain/beacon-kit/consensus-types/types" engineprimitives "github.com/berachain/beacon-kit/engine-primitives/engine-primitives" - "github.com/berachain/beacon-kit/errors" "github.com/berachain/beacon-kit/log/noop" "github.com/berachain/beacon-kit/node-core/components/metrics" payloadbuilder "github.com/berachain/beacon-kit/payload/builder" @@ -416,6 +415,8 @@ func TestServer_MetricsLabels(t *testing.T) { newTestWhitelist(t, pubkeyAHex, pubkeyBHex), tracker, &mockPayloadProvider{hasPayload: tt.hasPayload, returnErr: tt.providerErr}, + &mockSyncChecker{ready: true}, + &mockELChecker{connected: true}, 0, sink, ) @@ -538,7 +539,7 @@ func TestServer_HealthEndpoint(t *testing.T) { server := preconf.NewServer( noop.NewLogger[any](), nil, nil, nil, nil, - syncChecker, elChecker, 0, + syncChecker, elChecker, 0, metrics.NewNoOpTelemetrySink(), ) req := httptest.NewRequest(http.MethodGet, preconf.HealthEndpoint, nil) From ef25538e6dcd593f8734e8099629fe8f9b42651d Mon Sep 17 00:00:00 2001 From: bar-bera Date: Thu, 23 Apr 2026 11:36:25 +0200 Subject: [PATCH 08/10] lint --- beacon/preconf/server_test.go | 2 +- node-core/components/preconf_server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon/preconf/server_test.go b/beacon/preconf/server_test.go index 45a5501fc6..efff681b91 100644 --- a/beacon/preconf/server_test.go +++ b/beacon/preconf/server_test.go @@ -556,4 +556,4 @@ func TestServer_HealthEndpoint(t *testing.T) { require.Equal(t, tt.wantELConn, resp.ELConnected) }) } -} \ No newline at end of file +} diff --git a/node-core/components/preconf_server.go b/node-core/components/preconf_server.go index f5d3810a9d..cdc09f329e 100644 --- a/node-core/components/preconf_server.go +++ b/node-core/components/preconf_server.go @@ -27,8 +27,8 @@ import ( "github.com/berachain/beacon-kit/errors" "github.com/berachain/beacon-kit/execution/client" "github.com/berachain/beacon-kit/log/phuslu" - "github.com/berachain/beacon-kit/node-core/types" "github.com/berachain/beacon-kit/node-core/components/metrics" + "github.com/berachain/beacon-kit/node-core/types" payloadbuilder "github.com/berachain/beacon-kit/payload/builder" ) From 068b8f0a0e756868e9b9ac3ad134a7e38cdda10d Mon Sep 17 00:00:00 2001 From: bar-bera Date: Mon, 27 Apr 2026 11:41:16 +0200 Subject: [PATCH 09/10] apply snapshot approach to log counting for tests --- testing/e2e/preconf/flow_test.go | 49 +++++++++++++++----------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/testing/e2e/preconf/flow_test.go b/testing/e2e/preconf/flow_test.go index 7f3a56505a..c246502a88 100644 --- a/testing/e2e/preconf/flow_test.go +++ b/testing/e2e/preconf/flow_test.go @@ -55,6 +55,17 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { err := s.WaitForFinalizedBlockNumber(blocksToWait) s.Require().NoError(err, "Network should reach finalized blocks") + allValidators := []string{ + config.ValidatorConsensusClientName(0), + config.ValidatorConsensusClientName(1), + config.ValidatorConsensusClientName(2), + config.ValidatorConsensusClientName(3), + config.ValidatorConsensusClientName(4), + } + + // Pre-stop circuit-breaker counts. + baselineOfflineCounts := make(map[string]int, len(allValidators)) + // Verify sequencer is serving payloads. s.Run("SequencerServesPayloads", func() { logs, err := s.GetServiceLogs(sequencerCLService) @@ -69,14 +80,7 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { // Verify validators fetch from sequencer. s.Run("ValidatorFetches", func() { - validators := []string{ - config.ValidatorConsensusClientName(0), - config.ValidatorConsensusClientName(1), - config.ValidatorConsensusClientName(2), - config.ValidatorConsensusClientName(3), - config.ValidatorConsensusClientName(4), - } - for _, validator := range validators { + for _, validator := range allValidators { validator := validator // capture for closure s.Run(validator, func() { logs, err := s.GetServiceLogs(validator) @@ -95,6 +99,13 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { s.Run("SequencerFallback", func() { elClient := s.ExecutionClients(0) + // Snapshot the offline-log counts before stopping the sequencer. + for _, validator := range allValidators { + logs, logErr := s.GetServiceLogs(validator) + s.Require().NoError(logErr, "Should get logs for %s", validator) + baselineOfflineCounts[validator] = suite.CountLogMessages(logs, sequencerDownLog) + } + // Get current block before stopping sequencer. currentBlock, err := elClient.BlockNumber(s.Ctx()) s.Require().NoError(err, "Should get current block number") @@ -133,20 +144,13 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { err = s.WaitForFinalizedBlockNumber(currentBlock + blocksAfterFallback) s.Require().NoError(err, "Network should continue producing blocks") - validators := []string{ - config.ValidatorConsensusClientName(0), - config.ValidatorConsensusClientName(1), - config.ValidatorConsensusClientName(2), - config.ValidatorConsensusClientName(3), - config.ValidatorConsensusClientName(4), - } - for _, validator := range validators { + for _, validator := range allValidators { logs, err := s.GetServiceLogs(validator) s.Require().NoError(err, "Should get logs for %s", validator) - count := suite.CountLogMessages(logs, sequencerDownLog) + count := suite.CountLogMessages(logs, sequencerDownLog) - baselineOfflineCounts[validator] s.Require().LessOrEqual(count, 1, - "Validator %s tripped circuit breaker %d times: should only detect sequencer down once", + "Validator %s tripped circuit breaker %d times after sequencer stop: should only detect once", validator, count) } }) @@ -160,14 +164,7 @@ func (s *PreconfE2ESuite) TestSequencerFlow() { // Each validator must see the sequencer healthy after pod restart + resync. // Poll the logs so we tolerate variable startup latency. - validators := []string{ - config.ValidatorConsensusClientName(0), - config.ValidatorConsensusClientName(1), - config.ValidatorConsensusClientName(2), - config.ValidatorConsensusClientName(3), - config.ValidatorConsensusClientName(4), - } - for _, validator := range validators { + for _, validator := range allValidators { validator := validator s.Require().Eventuallyf(func() bool { logs, logErr := s.GetServiceLogs(validator) From ec21af03031b24fbf74c3d8838655fd60e687df4 Mon Sep 17 00:00:00 2001 From: bar-bera Date: Mon, 27 Apr 2026 12:13:14 +0200 Subject: [PATCH 10/10] nit - clear IsAppReady perplexities --- beacon/preconf/server.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beacon/preconf/server.go b/beacon/preconf/server.go index d43b3a0797..bde8b7e4b0 100644 --- a/beacon/preconf/server.go +++ b/beacon/preconf/server.go @@ -62,8 +62,10 @@ type PayloadProvider interface { } // SyncChecker exposes the node's sync status for health checks. +// Keeps the original signatures from the cometbft service interface. type SyncChecker interface { - // IsAppReady returns nil if the node has committed at least one block. + // IsAppReady returns nil if the chain is ready (at least one block has been committed). + // In case of error we set the server as not available. IsAppReady() error // GetSyncData returns the latest committed height and the target height being synced to. GetSyncData() (latestHeight int64, syncToHeight int64)