Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions cl/beacon/beaconhttp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"strconv"
"strings"

"github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/common/ssz"
Expand All @@ -47,13 +48,23 @@ var ErrorCantFindBeaconState = errors.New("Could not find beacon state")
var ErrorSszNotSupported = errors.New("This endpoint does not support SSZ response")

func WrapEndpointError(err error) *EndpointError {
e := &EndpointError{}
if errors.As(err, e) {
return e
// Handlers build these with NewEndpointError, so the pointer form is the one that carries a
// deliberate code; matching only the value form would silently turn it into a 500.
var byPointer *EndpointError
if errors.As(err, &byPointer) {
return byPointer
}
byValue := EndpointError{}
if errors.As(err, &byValue) {
return &byValue
}
if errors.Is(err, fork_graph.ErrStateNotFound) {
return NewEndpointError(http.StatusNotFound, ErrorCantFindBeaconState)
}
// A node without a head state is transiently unavailable, not faulty.
if errors.Is(err, synced_data.ErrNotSynced) {
return NewEndpointError(http.StatusServiceUnavailable, err)
}
return NewEndpointError(http.StatusInternalServerError, err)
}

Expand Down
47 changes: 47 additions & 0 deletions cl/beacon/beaconhttp/api_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package beaconhttp

import (
"errors"
"fmt"
"net/http"
"testing"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph"
)

func TestWrapEndpointErrorStatusCodes(t *testing.T) {
for _, tc := range []struct {
name string
err error
code int
}{
{"not synced", synced_data.ErrNotSynced, http.StatusServiceUnavailable},
{"not synced, wrapped", fmt.Errorf("attester duties: %w", synced_data.ErrNotSynced), http.StatusServiceUnavailable},
{"state not found", fork_graph.ErrStateNotFound, http.StatusNotFound},
{"anything else", errors.New("boom"), http.StatusInternalServerError},
{"explicit code is preserved", NewEndpointError(http.StatusBadRequest, errors.New("bad")), http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.code, WrapEndpointError(tc.err).Code)
})
}
}
4 changes: 2 additions & 2 deletions cl/beacon/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ type ApiHandler struct {
elClientVersion atomic.Pointer[engine_types.ClientVersionV1] // Cached execution client version for default graffiti.
elClientVersionFetching atomic.Bool // Guards a single in-flight background elClientVersion fetch.
syncMessagePool sync_contribution_pool.SyncContributionPool
committeeSub *committee_subscription.CommitteeSubscribeMgmt
committeeSub committee_subscription.CommitteeSubscribe
attestationProducer attestation_producer.AttestationDataProducer
slotWaitedForAttestationProduction *lru.Cache[uint64, struct{}]
aggregatePool aggregation.AggregationPool
Expand Down Expand Up @@ -177,7 +177,7 @@ func NewApiHandler(
attestationProducer attestation_producer.AttestationDataProducer,
engine execution_client.ExecutionEngine,
syncMessagePool sync_contribution_pool.SyncContributionPool,
committeeSub *committee_subscription.CommitteeSubscribeMgmt,
committeeSub committee_subscription.CommitteeSubscribe,
aggregatePool aggregation.AggregationPool,
syncCommitteeMessagesService services.SyncCommitteeMessagesService,
syncContributionAndProofs services.SyncContributionService,
Expand Down
16 changes: 6 additions & 10 deletions cl/beacon/handler/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"time"

"github.com/erigontech/erigon/cl/beacon/beaconhttp"
"github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/gossip"
"github.com/erigontech/erigon/cl/phase1/core/state"
Expand Down Expand Up @@ -67,23 +68,16 @@ func (a *ApiHandler) PostEthV1ValidatorSyncCommitteeSubscriptions(w http.Respons
syncnets = append(syncnets, uint64(i))
}
} else {
// headState, cn := a.syncedData.HeadState()
// defer cn()
// if headState == nil {
// http.Error(w, "head state not available", http.StatusServiceUnavailable)
// return
// }
if err := a.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error {
syncnets, err = subnets.ComputeSubnetsForSyncCommittee(headState, subRequest.ValidatorIndex)
if err != nil {
return err
}
return nil
}); err != nil {
beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
beaconhttp.WrapEndpointError(err).WriteTo(w)
return
}
//cn()
}

// subscribe to subnets
Expand Down Expand Up @@ -118,8 +112,10 @@ func (a *ApiHandler) PostEthV1ValidatorBeaconCommitteeSubscription(w http.Respon
}
for _, sub := range req {
if err := a.committeeSub.AddAttestationSubscription(context.Background(), sub); err != nil {
log.Error("failed to add attestation subscription", "err", err)
beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
if !errors.Is(err, synced_data.ErrNotSynced) {
log.Error("failed to add attestation subscription", "err", err)
}
beaconhttp.WrapEndpointError(err).WriteTo(w)
return
}
}
Expand Down
128 changes: 128 additions & 0 deletions cl/beacon/handler/subscription_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package handler

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/erigontech/erigon/cl/beacon/synced_data"
sync_mock_services "github.com/erigontech/erigon/cl/beacon/synced_data/mock_services"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/validator/committee_subscription/mock_services"
"github.com/erigontech/erigon/common/log/v3"
)

func postBeaconCommitteeSubscription(t *testing.T, handler *ApiHandler) int {
t.Helper()

server := httptest.NewServer(handler.mux)
defer server.Close()

body, err := json.Marshal([]*cltypes.BeaconCommitteeSubscription{{
ValidatorIndex: 1,
CommitteeIndex: 0,
CommitteesAtSlot: 1,
Slot: 1,
IsAggregator: false,
}})
require.NoError(t, err)

req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
server.URL+"/eth/v1/validator/beacon_committee_subscriptions", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

resp, err := server.Client().Do(req)
require.NoError(t, err)
defer resp.Body.Close()
return resp.StatusCode
}

// The beacon-APIs spec declares 503 CurrentlySyncing for this endpoint, so a node without a head
// state must not report the condition as an internal error.
func TestBeaconCommitteeSubscriptionIsUnavailableWhileSyncing(t *testing.T) {
ctrl := gomock.NewController(t)
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)

committeeSub := mock_services.NewMockCommitteeSubscribe(ctrl)
committeeSub.EXPECT().AddAttestationSubscription(gomock.Any(), gomock.Any()).
Return(synced_data.ErrNotSynced).AnyTimes()
handler.committeeSub = committeeSub

require.Equal(t, http.StatusServiceUnavailable, postBeaconCommitteeSubscription(t, handler))
}

func TestBeaconCommitteeSubscriptionReportsOtherFailuresAsInternalError(t *testing.T) {
ctrl := gomock.NewController(t)
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)

committeeSub := mock_services.NewMockCommitteeSubscribe(ctrl)
committeeSub.EXPECT().AddAttestationSubscription(gomock.Any(), gomock.Any()).
Return(errors.New("subnet computation blew up")).AnyTimes()
handler.committeeSub = committeeSub

require.Equal(t, http.StatusInternalServerError, postBeaconCommitteeSubscription(t, handler))
}

func TestBeaconCommitteeSubscriptionSucceedsWhenSynced(t *testing.T) {
ctrl := gomock.NewController(t)
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)

committeeSub := mock_services.NewMockCommitteeSubscribe(ctrl)
committeeSub.EXPECT().AddAttestationSubscription(gomock.Any(), gomock.Any()).
Return(nil).AnyTimes()
handler.committeeSub = committeeSub

require.Equal(t, http.StatusOK, postBeaconCommitteeSubscription(t, handler))
}

func TestSyncCommitteeSubscriptionIsUnavailableWhileSyncing(t *testing.T) {
_, _, _, _, _, handler, _, syncedDataMgr, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
syncedDataMgr.(*sync_mock_services.MockSyncedData).EXPECT().ViewHeadState(gomock.Any()).
Return(synced_data.ErrNotSynced).AnyTimes()

server := httptest.NewServer(handler.mux)
defer server.Close()

// Far enough ahead that the subscription has not expired, without overflowing the slot clock.
body, err := json.Marshal([]ValidatorSyncCommitteeSubscriptionsRequest{{
ValidatorIndex: 1,
SyncCommitteeIndicies: []string{"0"},
UntilEpoch: 1_000_000_000,
}})
require.NoError(t, err)

req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
server.URL+"/eth/v1/validator/sync_committee_subscriptions", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

resp, err := server.Client().Do(req)
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (c *CommitteeSubscribeMgmt) AddAttestationSubscription(ctx context.Context,
)

if c.syncedData.Syncing() {
return errors.New("head state not available")
return synced_data.ErrNotSynced
}

log.Trace("Add attestation subscription", "slot", slot, "committeeIndex", cIndex, "isAggregator", p.IsAggregator, "validatorIndex", p.ValidatorIndex)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package committee_subscription

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
)

// The REST layer turns this into 503 CurrentlySyncing by matching the sentinel, so a bare error
// here would be reported as an internal failure instead.
func TestAddAttestationSubscriptionReportsNotSyncedWhileSyncing(t *testing.T) {
cfg := clparams.MainnetBeaconConfig
c := &CommitteeSubscribeMgmt{
beaconConfig: &cfg,
syncedData: synced_data.NewSyncedDataManager(&cfg, true),
}

err := c.AddAttestationSubscription(t.Context(), &cltypes.BeaconCommitteeSubscription{
ValidatorIndex: 1,
CommitteeIndex: 0,
CommitteesAtSlot: 1,
Slot: 1,
})

require.ErrorIs(t, err, synced_data.ErrNotSynced)
}
Loading