From 3f5e23f6536526d9e60b12fa57b8d77932eff0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 30 Jul 2026 14:41:26 +0200 Subject: [PATCH 01/12] exporter: serve the pre-fork portion of fork-straddling trace ranges validateValidatorRequest evaluated the committee-duty fork gate at request.To, so a validator-traces window whose tail crossed the Boole fork was rejected wholesale - a dashboard polling a fixed window starts 400-ing the moment its tail crosses the fork, losing the still-servable pre-fork slots. Evaluate the gate at request.From instead, and report each post-fork committee-duty slot lacking pubkeys/indices as a non-fatal per-slot note (ErrPostForkCommitteeDutyNote) in the response Errors instead of silently returning nothing for it. The HTTP handler treats a response whose only errors are such notes as a partial 200 - without that, a straddling range with zero pre-fork traces would 500. Item 1 of #2968. --- api/handlers/exporter/exporter_test.go | 78 +++++++++++++++++++++- api/handlers/exporter/validator_http.go | 25 ++++++- exporter/validator.go | 22 ++++++- exporter/validator_test.go | 88 ++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 9 deletions(-) diff --git a/api/handlers/exporter/exporter_test.go b/api/handlers/exporter/exporter_test.go index 991197eeaf..f24a1a50fd 100644 --- a/api/handlers/exporter/exporter_test.go +++ b/api/handlers/exporter/exporter_test.go @@ -2613,7 +2613,7 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { } for _, role := range roles { - t.Run(role.name+" without filters requires pubkeys/indices", func(t *testing.T) { + t.Run(role.name+" without filters returns a partial response with post-fork notes", func(t *testing.T) { exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), &netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ @@ -2624,7 +2624,20 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - require.Error(t, exp.ValidatorTraces(rec, req)) + // the pre-fork portion of the range legitimately yields zero traces (no + // mock data), so the post-fork "requires pubkeys/indices" notes must not + // be treated as a hard failure: expect 200 with empty data and the notes + // surfaced in Errors. + require.NoError(t, exp.ValidatorTraces(rec, req)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp ValidatorTracesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Empty(t, resp.Data) + require.NotEmpty(t, resp.Errors) + for _, msg := range resp.Errors { + require.Contains(t, msg, "committee duty post-fork") + } }) t.Run(role.name+" with indices routes each slot by its own fork state", func(t *testing.T) { @@ -2671,6 +2684,67 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { } } +// TestExporterValidatorTraces_ForkGating_ZeroPreForkTraces covers the fix for a +// fork-straddling AGGREGATOR/SYNC_COMMITTEE_CONTRIBUTION request without +// pubkeys/indices whose pre-fork slots legitimately yield zero traces (e.g. +// sparse aggregator duties): the response must be 200 with empty traces and +// the post-fork notes surfaced, not a 500. A genuine error alongside those +// notes must still yield 500. +func TestExporterValidatorTraces_ForkGating_ZeroPreForkTraces(t *testing.T) { + const booleEpoch = phase0.Epoch(5) + + ssvCopy := *networkconfig.TestNetwork.SSV + ssvCopy.Forks.Boole = booleEpoch + netCfg := *networkconfig.TestNetwork + netCfg.SSV = &ssvCopy + + booleSlot := netCfg.FirstSlotAtEpoch(booleEpoch) + require.GreaterOrEqual(t, uint64(booleSlot), uint64(2), "boole fork slot too low for the range below") + from := uint64(booleSlot) - 2 // pre-Boole + to := uint64(booleSlot) + 2 // post-Boole + + t.Run("only post-fork notes and no pre-fork traces -> 200 with empty data", func(t *testing.T) { + exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), &netCfg) + + req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ + "from": from, + "to": to, + "roles": []string{"AGGREGATOR"}, + })) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + require.NoError(t, exp.ValidatorTraces(rec, req)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp ValidatorTracesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Empty(t, resp.Data) + require.NotEmpty(t, resp.Errors, "expected post-fork notes to surface") + for _, msg := range resp.Errors { + require.Contains(t, msg, "committee duty post-fork") + } + }) + + t.Run("genuine error alongside notes still yields 500", func(t *testing.T) { + store := newMockTraceStore() + store.GetValidatorDutiesFunc = func(role spectypes.BeaconRole, slot phase0.Slot) ([]*traces.ValidatorDutyTrace, error) { + return nil, fmt.Errorf("forced error on GetValidatorDuties") + } + exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), &netCfg) + + req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ + "from": from, + "to": to, + "roles": []string{"AGGREGATOR"}, + })) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + require.Error(t, exp.ValidatorTraces(rec, req)) + }) +} + // mockValidatorStore is a simple in-memory ValidatorStore implementation for tests. type mockValidatorStore struct { byIndex map[phase0.ValidatorIndex]*ssvtypes.SSVShare diff --git a/api/handlers/exporter/validator_http.go b/api/handlers/exporter/validator_http.go index 000d76aeab..d957914f66 100644 --- a/api/handlers/exporter/validator_http.go +++ b/api/handlers/exporter/validator_http.go @@ -1,9 +1,13 @@ package exporter import ( + "errors" "net/http" + "github.com/hashicorp/go-multierror" + "github.com/ssvlabs/ssv/api" + exportercore "github.com/ssvlabs/ssv/exporter" ) // ValidatorTraces godoc @@ -39,8 +43,10 @@ func (e *Exporter) ValidatorTraces(w http.ResponseWriter, r *http.Request) error return toApiError(e.logger, r, "validator_traces", http.StatusBadRequest, request, underlyingValidationError(errs)) } - // if we don't have a single valid result and we have at least one meaningful error, return an error - if len(result.Traces) == 0 && errs.ErrorOrNil() != nil { + // if we don't have a single valid result and we have at least one meaningful error, return an error. + // post-fork committee-duty notes are expected on fork-straddling ranges whose pre-fork slots + // yield no traces (e.g. sparse aggregator duties), so they don't count as a hard failure here. + if len(result.Traces) == 0 && errs.ErrorOrNil() != nil && !onlyPostForkCommitteeDutyNotes(errs) { return toApiError(e.logger, r, "validator_traces", http.StatusInternalServerError, request, errs.ErrorOrNil()) } @@ -48,3 +54,18 @@ func (e *Exporter) ValidatorTraces(w http.ResponseWriter, r *http.Request) error response := toValidatorTraceResponse(result, errs) return api.Render(w, r, response) } + +// onlyPostForkCommitteeDutyNotes reports whether every error in errs is (or wraps) +// exportercore.ErrPostForkCommitteeDutyNote, i.e. the errors are non-fatal notes +// rather than genuine processing failures. +func onlyPostForkCommitteeDutyNotes(errs *multierror.Error) bool { + if errs.ErrorOrNil() == nil { + return false + } + for _, err := range errs.Errors { + if !errors.Is(err, exportercore.ErrPostForkCommitteeDutyNote) { + return false + } + } + return true +} diff --git a/exporter/validator.go b/exporter/validator.go index 8d73858e01..183fa667d9 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -1,6 +1,7 @@ package exporter import ( + "errors" "fmt" "slices" @@ -16,6 +17,12 @@ import ( ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" ) +// ErrPostForkCommitteeDutyNote marks the non-fatal note appended when a +// fork-straddling request reaches a post-fork slot/role pair without +// pubkeys/indices. It lets callers (e.g. the HTTP layer) tell this expected, +// partial-coverage note apart from genuine processing failures. +var ErrPostForkCommitteeDutyNote = errors.New("committee duty post-fork requires pubkeys or indices") + // ValidatorTracesCore contains the core logic for ValidatorTraces without any HTTP concerns. func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*ValidatorTracesResult, *multierror.Error) { if err := e.validateValidatorRequest(request); err != nil { @@ -36,6 +43,14 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato for s := request.From; s <= request.To; s++ { slot := phase0.Slot(s) for _, role := range request.Roles { + if e.isCommitteeDutyAtSlot(role, slot) && len(indices) == 0 { + // request validation only gates on 'from': a window whose tail crosses + // Boole reaches here for its post-fork slots without pubkeys/indices, + // so report the gap as a non-fatal note instead of silently skipping it. + errs = multierror.Append(errs, fmt.Errorf("%w: slot %d: role %s is a committee duty post-fork, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", ErrPostForkCommitteeDutyNote, slot, role.String())) + continue + } + providerFunc := e.getValidatorDutiesForRoleAndSlot if e.isCommitteeDutyAtSlot(role, slot) { providerFunc = e.getValidatorCommitteeDutiesForRoleAndSlot @@ -66,11 +81,12 @@ func (e *Exporter) validateValidatorRequest(request *ValidatorTracesQuery) error } // either PubKeys or Indices are required for committee duty roles. - // Fork state is evaluated at the range's upper bound: if any slot in - // [from, to] is post-Boole, the 'to' slot is too. + // Fork state is evaluated at the range's lower bound so that a window + // whose tail crosses Boole still serves its pre-fork portion; the + // post-fork tail is reported as a non-fatal note in the per-slot loop. if len(request.PubKeys) == 0 && len(request.Indices) == 0 { for _, role := range request.Roles { - if e.isCommitteeDutyAtSlot(role, phase0.Slot(request.To)) { + if e.isCommitteeDutyAtSlot(role, phase0.Slot(request.From)) { return fmt.Errorf("role %s is a committee duty, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", role.String()) } } diff --git a/exporter/validator_test.go b/exporter/validator_test.go index af59da94e4..b1e9d3d939 100644 --- a/exporter/validator_test.go +++ b/exporter/validator_test.go @@ -1,6 +1,7 @@ package exporter import ( + "fmt" "testing" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -10,6 +11,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/ssvlabs/ssv/exporter/rolemask" estore "github.com/ssvlabs/ssv/exporter/store" "github.com/ssvlabs/ssv/exporter/traces" "github.com/ssvlabs/ssv/networkconfig" @@ -36,6 +38,18 @@ func postBooleNetwork() *networkconfig.Network { return &netCfg } +// straddlingNetwork returns a *networkconfig.Network clone whose Boole fork +// activates one epoch after "now", so a slot range that starts before the +// fork epoch and ends after it genuinely straddles the fork boundary. It +// never mutates the shared TestNetwork. +func straddlingNetwork() *networkconfig.Network { + ssvCopy := *networkconfig.TestNetwork.SSV + ssvCopy.Forks.Boole = networkconfig.TestNetwork.EstimatedCurrentEpoch() + 1 + netCfg := *networkconfig.TestNetwork + netCfg.SSV = &ssvCopy + return &netCfg +} + func TestIsCommitteeDutyAtSlot(t *testing.T) { preFork := preBooleNetwork() postFork := postBooleNetwork() @@ -73,9 +87,11 @@ func TestIsCommitteeDutyAtSlot(t *testing.T) { func TestValidateValidatorRequest(t *testing.T) { preFork := preBooleNetwork() postFork := postBooleNetwork() + straddle := straddlingNetwork() preForkSlot := uint64(preFork.FirstSlotAtEpoch(1)) postForkSlot := uint64(postFork.FirstSlotAtEpoch(1)) + straddleBoundarySlot := uint64(straddle.FirstSlotAtEpoch(straddle.SSV.Forks.Boole)) tests := []struct { name string @@ -162,7 +178,7 @@ func TestValidateValidatorRequest(t *testing.T) { wantErr: true, }, { - name: "range straddling the fork resolves on the 'to' bound: pre-fork 'to' is accepted", + name: "range straddling the fork resolves on the 'from' bound: pre-fork 'to' is accepted", netCfg: preFork, request: &ValidatorTracesQuery{ From: 1, @@ -172,7 +188,7 @@ func TestValidateValidatorRequest(t *testing.T) { wantErr: false, }, { - name: "range straddling the fork resolves on the 'to' bound: post-fork 'to' is rejected", + name: "range straddling the fork resolves on the 'from' bound: post-fork 'to' is rejected", netCfg: postFork, request: &ValidatorTracesQuery{ From: 1, @@ -181,6 +197,26 @@ func TestValidateValidatorRequest(t *testing.T) { }, wantErr: true, }, + { + name: "range genuinely straddling the fork boundary is accepted (gate evaluated at 'from', not 'to')", + netCfg: straddle, + request: &ValidatorTracesQuery{ + From: straddleBoundarySlot - 1, + To: straddleBoundarySlot + 10, + Roles: []spectypes.BeaconRole{spectypes.BNRoleAggregator}, + }, + wantErr: false, + }, + { + name: "range whose 'from' is already post-fork is rejected even if narrower than 'to'", + netCfg: straddle, + request: &ValidatorTracesQuery{ + From: straddleBoundarySlot, + To: straddleBoundarySlot + 10, + Roles: []spectypes.BeaconRole{spectypes.BNRoleAggregator}, + }, + wantErr: true, + }, } for _, tt := range tests { @@ -196,6 +232,54 @@ func TestValidateValidatorRequest(t *testing.T) { } } +// mockCoreTraceStore is a minimal dutyTraceStore implementation for exercising +// ValidatorTracesCore end-to-end over a fork-straddling slot range. +type mockCoreTraceStore struct { + dutyTraceStore +} + +func (m *mockCoreTraceStore) GetValidatorDuties(_ spectypes.BeaconRole, _ phase0.Slot) ([]*traces.ValidatorDutyTrace, error) { + return nil, nil +} + +func (m *mockCoreTraceStore) GetScheduled(_ phase0.Slot) (map[phase0.ValidatorIndex]rolemask.Mask, error) { + return map[phase0.ValidatorIndex]rolemask.Mask{}, nil +} + +func TestValidatorTracesCore_StraddlingFork(t *testing.T) { + straddle := straddlingNetwork() + boundarySlot := straddle.FirstSlotAtEpoch(straddle.SSV.Forks.Boole) + + e := &Exporter{ + traceStore: &mockCoreTraceStore{}, + logger: zap.NewNop(), + networkConfig: straddle, + } + + request := &ValidatorTracesQuery{ + From: uint64(boundarySlot) - 1, + To: uint64(boundarySlot) + 1, + Roles: []spectypes.BeaconRole{spectypes.BNRoleAggregator}, + } + + result, errs := e.ValidatorTracesCore(request) + require.NotNil(t, result) + + // no *ValidationError: the request is accepted despite its tail crossing Boole. + for _, err := range errs.Errors { + var valErr *ValidationError + assert.NotErrorAs(t, err, &valErr) + } + + // the two post-fork slots (boundarySlot, boundarySlot+1) are reported as + // non-fatal notes since no pubkeys/indices were supplied to filter the + // now-committee-backed AGGREGATOR duty. + require.Len(t, errs.Errors, 2) + assert.Contains(t, errs.Errors[0].Error(), fmt.Sprintf("slot %d", boundarySlot)) + assert.Contains(t, errs.Errors[0].Error(), "committee duty") + assert.Contains(t, errs.Errors[1].Error(), fmt.Sprintf("slot %d", boundarySlot+1)) +} + // mockValidatorTraceStore is a minimal dutyTraceStore implementation for // exercising getValidatorCommitteeDutiesForRoleAndSlot's signer-bucket gating. type mockValidatorTraceStore struct { From 52ae1298ba6a2b5e1f11793ff3638ecb5a8c26f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 30 Jul 2026 14:41:26 +0200 Subject: [PATCH 02/12] api/exporter: document the /committee per-role trace cardinality Without a roles filter the endpoint returns one trace per (slot, committeeID, role) - up to two rows per (slot, committeeID) post-fork, distinguished by the role field. Additive-field back-compat holds; cardinality back-compat does not, so state it in the OpenAPI description (regenerated, not hand-edited). Item 2 of #2968. --- api/handlers/exporter/committee_http.go | 2 +- docs/api/ssvnode.openapi.json | 4 ++-- docs/api/ssvnode.openapi.yaml | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/api/handlers/exporter/committee_http.go b/api/handlers/exporter/committee_http.go index 5ac2f97a5c..bfb64f3519 100644 --- a/api/handlers/exporter/committee_http.go +++ b/api/handlers/exporter/committee_http.go @@ -10,7 +10,7 @@ import ( // CommitteeTraces godoc // @Summary Retrieve committee duty traces -// @Description Returns consensus and post-consensus traces for requested committees. +// @Description Returns consensus and post-consensus traces for requested committees. Without a 'roles' filter, the response contains one trace per (slot, committeeID, role) - up to two rows per (slot, committeeID), distinguished by the 'role' field. // @Tags Exporter // @Accept json // @Produce json diff --git a/docs/api/ssvnode.openapi.json b/docs/api/ssvnode.openapi.json index 7f978c997f..b56d5e3877 100644 --- a/docs/api/ssvnode.openapi.json +++ b/docs/api/ssvnode.openapi.json @@ -229,7 +229,7 @@ }, "/v1/exporter/traces/committee": { "get": { - "description": "Returns consensus and post-consensus traces for requested committees.", + "description": "Returns consensus and post-consensus traces for requested committees. Without a 'roles' filter, the response contains one trace per (slot, committeeID, role) - up to two rows per (slot, committeeID), distinguished by the 'role' field.", "consumes": [ "application/json" ], @@ -318,7 +318,7 @@ } }, "post": { - "description": "Returns consensus and post-consensus traces for requested committees.", + "description": "Returns consensus and post-consensus traces for requested committees. Without a 'roles' filter, the response contains one trace per (slot, committeeID, role) - up to two rows per (slot, committeeID), distinguished by the 'role' field.", "consumes": [ "application/json" ], diff --git a/docs/api/ssvnode.openapi.yaml b/docs/api/ssvnode.openapi.yaml index 63727a86db..02e88c08b1 100644 --- a/docs/api/ssvnode.openapi.yaml +++ b/docs/api/ssvnode.openapi.yaml @@ -742,6 +742,9 @@ paths: consumes: - application/json description: Returns consensus and post-consensus traces for requested committees. + Without a 'roles' filter, the response contains one trace per (slot, committeeID, + role) - up to two rows per (slot, committeeID), distinguished by the 'role' + field. parameters: - collectionFormat: csv description: CommitteeIDs is a comma-separated list of committee IDs (hex, @@ -805,6 +808,9 @@ paths: consumes: - application/json description: Returns consensus and post-consensus traces for requested committees. + Without a 'roles' filter, the response contains one trace per (slot, committeeID, + role) - up to two rows per (slot, committeeID), distinguished by the 'role' + field. parameters: - collectionFormat: csv description: CommitteeIDs is a comma-separated list of committee IDs (hex, From 82535ffdbd4e41773037dab844ebbb29996937b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Fri, 7 Aug 2026 11:09:54 +0200 Subject: [PATCH 03/12] exporter: aggregate post-fork committee-duty notes into one per role A fork-straddling range without pubkeys/indices previously appended one non-fatal note per post-fork slot per role, growing the response linearly with the range size. Fork state is monotonic in slot, so the skipped tail is contiguous: record where it starts and emit a single note per role covering the whole post-fork range instead. --- exporter/validator.go | 23 +++++++++++++++++++---- exporter/validator_test.go | 11 +++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/exporter/validator.go b/exporter/validator.go index 183fa667d9..17d24c5153 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -40,14 +40,20 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato return nil, multierror.Append(nil, &ValidationError{Err: indicesErr}) } + // request validation only gates on 'from': a window whose tail crosses + // Boole reaches post-fork slots without pubkeys/indices. Fork state is + // monotonic in slot, so that tail is one contiguous range per role — + // record where it starts and report it as a single non-fatal note per + // role below, rather than allocating one note per skipped slot. + postForkNoteFrom := map[spectypes.BeaconRole]phase0.Slot{} + for s := request.From; s <= request.To; s++ { slot := phase0.Slot(s) for _, role := range request.Roles { if e.isCommitteeDutyAtSlot(role, slot) && len(indices) == 0 { - // request validation only gates on 'from': a window whose tail crosses - // Boole reaches here for its post-fork slots without pubkeys/indices, - // so report the gap as a non-fatal note instead of silently skipping it. - errs = multierror.Append(errs, fmt.Errorf("%w: slot %d: role %s is a committee duty post-fork, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", ErrPostForkCommitteeDutyNote, slot, role.String())) + if _, ok := postForkNoteFrom[role]; !ok { + postForkNoteFrom[role] = slot + } continue } @@ -62,6 +68,15 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato } } + for _, role := range request.Roles { + noteFrom, ok := postForkNoteFrom[role] + if !ok { + continue + } + delete(postForkNoteFrom, role) // guard against duplicate roles in the request + errs = multierror.Append(errs, fmt.Errorf("%w: slots %d-%d: role %s is a committee duty post-fork, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", ErrPostForkCommitteeDutyNote, noteFrom, request.To, role.String())) + } + // by design, not found duties are expected and not considered as API errors errs = filterOutDutyNotFoundErrors(errs) diff --git a/exporter/validator_test.go b/exporter/validator_test.go index b1e9d3d939..fb439911cf 100644 --- a/exporter/validator_test.go +++ b/exporter/validator_test.go @@ -271,13 +271,12 @@ func TestValidatorTracesCore_StraddlingFork(t *testing.T) { assert.NotErrorAs(t, err, &valErr) } - // the two post-fork slots (boundarySlot, boundarySlot+1) are reported as - // non-fatal notes since no pubkeys/indices were supplied to filter the - // now-committee-backed AGGREGATOR duty. - require.Len(t, errs.Errors, 2) - assert.Contains(t, errs.Errors[0].Error(), fmt.Sprintf("slot %d", boundarySlot)) + // the two post-fork slots (boundarySlot, boundarySlot+1) are reported as a + // single aggregated non-fatal note per role since no pubkeys/indices were + // supplied to filter the now-committee-backed AGGREGATOR duty. + require.Len(t, errs.Errors, 1) + assert.Contains(t, errs.Errors[0].Error(), fmt.Sprintf("slots %d-%d", boundarySlot, boundarySlot+1)) assert.Contains(t, errs.Errors[0].Error(), "committee duty") - assert.Contains(t, errs.Errors[1].Error(), fmt.Sprintf("slot %d", boundarySlot+1)) } // mockValidatorTraceStore is a minimal dutyTraceStore implementation for From 8e3d98498350175abd68c0b236f01fe7ae987481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 09:18:54 +0200 Subject: [PATCH 04/12] exporter: evaluate isCommitteeDutyAtSlot once per (slot, role) --- exporter/validator.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exporter/validator.go b/exporter/validator.go index 17d24c5153..478b10ad55 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -50,7 +50,8 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato for s := request.From; s <= request.To; s++ { slot := phase0.Slot(s) for _, role := range request.Roles { - if e.isCommitteeDutyAtSlot(role, slot) && len(indices) == 0 { + isCommittee := e.isCommitteeDutyAtSlot(role, slot) + if isCommittee && len(indices) == 0 { if _, ok := postForkNoteFrom[role]; !ok { postForkNoteFrom[role] = slot } @@ -58,7 +59,7 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato } providerFunc := e.getValidatorDutiesForRoleAndSlot - if e.isCommitteeDutyAtSlot(role, slot) { + if isCommittee { providerFunc = e.getValidatorCommitteeDutiesForRoleAndSlot } From 9de8c0c361191fd357c85a7fcef68f305f1663c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 09:19:37 +0200 Subject: [PATCH 05/12] api/exporter: fix stale upper-bound doc comments, pin 500 in genuine-error test The fork gate moved from the range's upper bound to the lower bound; two test doc comments still described the old behavior. Also assert the exact status code and surfaced message in the 'genuine error alongside notes' subtest, so a future misroute to the 400 branch can't pass silently (require.Error alone was satisfied by either branch). --- api/handlers/exporter/exporter_test.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/api/handlers/exporter/exporter_test.go b/api/handlers/exporter/exporter_test.go index f24a1a50fd..0e89612cbe 100644 --- a/api/handlers/exporter/exporter_test.go +++ b/api/handlers/exporter/exporter_test.go @@ -2485,7 +2485,7 @@ func TestExporterValidatorTraces_ForkGating(t *testing.T) { } // TestExporterValidatorTraces_ForkGating_ValidationSymmetric proves that validateValidatorRequest -// (via isCommitteeDutyAtSlot at the range's upper bound) mirrors the same fork-gated routing decision for aggregator-family +// (via isCommitteeDutyAtSlot at the range's lower bound) mirrors the same fork-gated routing decision for aggregator-family // roles: pre-Boole no pubkeys/indices are required, post-Boole they are (mirroring committee duties). func TestExporterValidatorTraces_ForkGating_ValidationSymmetric(t *testing.T) { tests := []struct { @@ -2567,9 +2567,9 @@ func TestExporterValidatorTraces_ForkGating_ValidationSymmetric(t *testing.T) { // TestExporterValidatorTraces_ForkGating_CrossForkRange proves the behavior of a slot range // straddling the Boole fork boundary (from pre-Boole, to post-Boole): validation is evaluated -// at the range's upper bound, so aggregator-family roles require pubkeys/indices, and with -// indices provided each slot routes independently — validator path before the boundary, -// committee path from it onward. +// at the range's lower bound, so unfiltered aggregator-family requests are accepted and served +// partially — post-fork slots are reported as non-fatal notes — and with indices provided each +// slot routes independently: validator path before the boundary, committee path from it onward. func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { const booleEpoch = phase0.Epoch(5) idx := phase0.ValidatorIndex(1) @@ -2741,7 +2741,15 @@ func TestExporterValidatorTraces_ForkGating_ZeroPreForkTraces(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - require.Error(t, exp.ValidatorTraces(rec, req)) + err := exp.ValidatorTraces(rec, req) + require.Error(t, err) + + var apiErr *api.ErrorResponse + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusInternalServerError, apiErr.Code, + "a genuine store failure must not be masked by the post-fork note exemption") + require.Contains(t, apiErr.Message, "forced error on GetValidatorDuties", + "the genuine error, not a post-fork note, must surface to the caller") }) } From 32c9d96f689f607b22eab372c97f595fcabc5761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 09:20:34 +0200 Subject: [PATCH 06/12] api/exporter: document the /traces/validator partial-coverage contract A fork-straddling range without pubkeys/indices used to be rejected with 400; it now returns 200 with the pre-fork portion and per-role notes in 'errors'. That is a status-code contract change for clients, so spell it out in the OpenAPI description like the /committee cardinality note. --- api/handlers/exporter/validator_http.go | 6 ++++++ docs/api/ssvnode.openapi.json | 4 ++-- docs/api/ssvnode.openapi.yaml | 20 ++++++++++++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/api/handlers/exporter/validator_http.go b/api/handlers/exporter/validator_http.go index d957914f66..b3113f4d47 100644 --- a/api/handlers/exporter/validator_http.go +++ b/api/handlers/exporter/validator_http.go @@ -13,6 +13,12 @@ import ( // ValidatorTraces godoc // @Summary Retrieve validator duty traces // @Description Returns consensus, decided, and message traces for the requested validator duties. +// @Description For AGGREGATOR and SYNC_COMMITTEE_CONTRIBUTION the fork state is evaluated at 'from': a range whose +// @Description 'from' is post-Boole and that supplies no 'pubkeys'/'indices' is rejected with 400, while a range whose +// @Description 'from' is pre-Boole is accepted and served partially — post-Boole slots are omitted from 'data' and +// @Description reported as one note per role in 'errors' with the text "committee duty post-fork". Such a response is +// @Description a 200 even when 'data' is empty; clients must inspect 'errors' to detect partial coverage, and should +// @Description supply 'indices'/'pubkeys' or use /traces/committee to retrieve the post-fork portion. // @Tags Exporter // @Accept json // @Produce json diff --git a/docs/api/ssvnode.openapi.json b/docs/api/ssvnode.openapi.json index b56d5e3877..252f1f9826 100644 --- a/docs/api/ssvnode.openapi.json +++ b/docs/api/ssvnode.openapi.json @@ -409,7 +409,7 @@ }, "/v1/exporter/traces/validator": { "get": { - "description": "Returns consensus, decided, and message traces for the requested validator duties.", + "description": "Returns consensus, decided, and message traces for the requested validator duties.\nFor AGGREGATOR and SYNC_COMMITTEE_CONTRIBUTION the fork state is evaluated at 'from': a range whose\n'from' is post-Boole and that supplies no 'pubkeys'/'indices' is rejected with 400, while a range whose\n'from' is pre-Boole is accepted and served partially — post-Boole slots are omitted from 'data' and\nreported as one note per role in 'errors' with the text \"committee duty post-fork\". Such a response is\na 200 even when 'data' is empty; clients must inspect 'errors' to detect partial coverage, and should\nsupply 'indices'/'pubkeys' or use /traces/committee to retrieve the post-fork portion.", "consumes": [ "application/json" ], @@ -514,7 +514,7 @@ } }, "post": { - "description": "Returns consensus, decided, and message traces for the requested validator duties.", + "description": "Returns consensus, decided, and message traces for the requested validator duties.\nFor AGGREGATOR and SYNC_COMMITTEE_CONTRIBUTION the fork state is evaluated at 'from': a range whose\n'from' is post-Boole and that supplies no 'pubkeys'/'indices' is rejected with 400, while a range whose\n'from' is pre-Boole is accepted and served partially — post-Boole slots are omitted from 'data' and\nreported as one note per role in 'errors' with the text \"committee duty post-fork\". Such a response is\na 200 even when 'data' is empty; clients must inspect 'errors' to detect partial coverage, and should\nsupply 'indices'/'pubkeys' or use /traces/committee to retrieve the post-fork portion.", "consumes": [ "application/json" ], diff --git a/docs/api/ssvnode.openapi.yaml b/docs/api/ssvnode.openapi.yaml index 02e88c08b1..bd94f95e31 100644 --- a/docs/api/ssvnode.openapi.yaml +++ b/docs/api/ssvnode.openapi.yaml @@ -874,8 +874,14 @@ paths: get: consumes: - application/json - description: Returns consensus, decided, and message traces for the requested - validator duties. + description: |- + Returns consensus, decided, and message traces for the requested validator duties. + For AGGREGATOR and SYNC_COMMITTEE_CONTRIBUTION the fork state is evaluated at 'from': a range whose + 'from' is post-Boole and that supplies no 'pubkeys'/'indices' is rejected with 400, while a range whose + 'from' is pre-Boole is accepted and served partially — post-Boole slots are omitted from 'data' and + reported as one note per role in 'errors' with the text "committee duty post-fork". Such a response is + a 200 even when 'data' is empty; clients must inspect 'errors' to detect partial coverage, and should + supply 'indices'/'pubkeys' or use /traces/committee to retrieve the post-fork portion. parameters: - description: From is the starting slot (inclusive). example: 123456 @@ -950,8 +956,14 @@ paths: post: consumes: - application/json - description: Returns consensus, decided, and message traces for the requested - validator duties. + description: |- + Returns consensus, decided, and message traces for the requested validator duties. + For AGGREGATOR and SYNC_COMMITTEE_CONTRIBUTION the fork state is evaluated at 'from': a range whose + 'from' is post-Boole and that supplies no 'pubkeys'/'indices' is rejected with 400, while a range whose + 'from' is pre-Boole is accepted and served partially — post-Boole slots are omitted from 'data' and + reported as one note per role in 'errors' with the text "committee duty post-fork". Such a response is + a 200 even when 'data' is empty; clients must inspect 'errors' to detect partial coverage, and should + supply 'indices'/'pubkeys' or use /traces/committee to retrieve the post-fork portion. parameters: - description: From is the starting slot (inclusive). example: 123456 From 36e901d5322cf5877828c40656b15fbb8dd572aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 12:59:28 +0200 Subject: [PATCH 07/12] exporter: hoist the shared committee-duty filter hint into a const --- exporter/validator.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/exporter/validator.go b/exporter/validator.go index 478b10ad55..ae52e65cb1 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -23,6 +23,11 @@ import ( // partial-coverage note apart from genuine processing failures. var ErrPostForkCommitteeDutyNote = errors.New("committee duty post-fork requires pubkeys or indices") +// committeeDutyFilterHint is the actionable tail shared by the committee-duty +// validation error and the post-fork partial-coverage note, hoisted so the two +// messages cannot drift apart. +const committeeDutyFilterHint = "please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties" + // ValidatorTracesCore contains the core logic for ValidatorTraces without any HTTP concerns. func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*ValidatorTracesResult, *multierror.Error) { if err := e.validateValidatorRequest(request); err != nil { @@ -75,7 +80,7 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato continue } delete(postForkNoteFrom, role) // guard against duplicate roles in the request - errs = multierror.Append(errs, fmt.Errorf("%w: slots %d-%d: role %s is a committee duty post-fork, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", ErrPostForkCommitteeDutyNote, noteFrom, request.To, role.String())) + errs = multierror.Append(errs, fmt.Errorf("%w: slots %d-%d: role %s is a committee duty post-fork, %s", ErrPostForkCommitteeDutyNote, noteFrom, request.To, role.String(), committeeDutyFilterHint)) } // by design, not found duties are expected and not considered as API errors @@ -103,7 +108,7 @@ func (e *Exporter) validateValidatorRequest(request *ValidatorTracesQuery) error if len(request.PubKeys) == 0 && len(request.Indices) == 0 { for _, role := range request.Roles { if e.isCommitteeDutyAtSlot(role, phase0.Slot(request.From)) { - return fmt.Errorf("role %s is a committee duty, please provide either pubkeys or indices to filter the duty for a specific validators subset or use the /committee endpoint to query all the corresponding duties", role.String()) + return fmt.Errorf("role %s is a committee duty, %s", role.String(), committeeDutyFilterHint) } } } From 36f79684acb51e45367cd2fc0f1432fa392a1db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:01:19 +0200 Subject: [PATCH 08/12] exporter: reject 'to' of max uint64 to prevent inclusive-loop wraparound Moving the fork gate from 'to' to 'from' removed the 400 that shielded unfiltered committee-duty requests from the inclusive per-slot loop's uint64 wraparound hang. Reject the guaranteed-hang value in validation; the endpoint-wide range bound remains tracked in #2986. --- exporter/validator.go | 8 ++++++++ exporter/validator_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/exporter/validator.go b/exporter/validator.go index ae52e65cb1..1959a46a75 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -3,6 +3,7 @@ package exporter import ( "errors" "fmt" + "math" "slices" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -97,6 +98,13 @@ func (e *Exporter) validateValidatorRequest(request *ValidatorTracesQuery) error return fmt.Errorf("'from' must be less than or equal to 'to'") } + // the per-slot loop is inclusive of 'to', so the maximum uint64 value + // would wrap the counter and never terminate. An endpoint-wide range + // bound is tracked in #2986; this only rejects the guaranteed hang. + if request.To == math.MaxUint64 { + return fmt.Errorf("'to' must be less than %d", uint64(math.MaxUint64)) + } + if len(request.Roles) == 0 { return fmt.Errorf("at least one role is required") } diff --git a/exporter/validator_test.go b/exporter/validator_test.go index fb439911cf..9297ec2b5a 100644 --- a/exporter/validator_test.go +++ b/exporter/validator_test.go @@ -2,6 +2,7 @@ package exporter import ( "fmt" + "math" "testing" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -217,6 +218,20 @@ func TestValidateValidatorRequest(t *testing.T) { }, wantErr: true, }, + { + // the inclusive per-slot loop would wrap its uint64 counter at the + // maximum 'to' and never terminate; the lower-bound fork gate no + // longer rejects this shape for unfiltered committee-duty roles, + // so validation must. + name: "'to' of max uint64 is rejected regardless of role or filters", + netCfg: preFork, + request: &ValidatorTracesQuery{ + From: 1, + To: math.MaxUint64, + Roles: []spectypes.BeaconRole{spectypes.BNRoleProposer}, + }, + wantErr: true, + }, } for _, tt := range tests { From 5b4c59a1e01fe40804cbadb3d5002b43810841d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:02:33 +0200 Subject: [PATCH 09/12] api/exporter: cover the partial-coverage contract with data and note coexisting The straddling-range 200 tests only asserted empty data with notes; add an unfiltered case whose pre-fork slots return real traces so a non-empty 'data' and the post-fork note are proven to coexist in one response, and pin that the unfiltered validator path is never consulted post-fork. --- api/handlers/exporter/exporter_test.go | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/api/handlers/exporter/exporter_test.go b/api/handlers/exporter/exporter_test.go index 0e89612cbe..f504d64514 100644 --- a/api/handlers/exporter/exporter_test.go +++ b/api/handlers/exporter/exporter_test.go @@ -2640,6 +2640,44 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { } }) + t.Run(role.name+" without filters serves pre-fork data alongside post-fork notes", func(t *testing.T) { + store := newMockTraceStore() + // the unfiltered pre-fork path reads GetValidatorDuties per slot; + // post-fork slots are skipped with a note and must never reach it. + store.GetValidatorDutiesFunc = func(r spectypes.BeaconRole, slot phase0.Slot) ([]*traces.ValidatorDutyTrace, error) { + require.Less(t, uint64(slot), uint64(booleSlot), "unfiltered validator path used at post-Boole slot") + return []*traces.ValidatorDutyTrace{{Slot: slot, Role: r, Validator: idx}}, nil + } + + exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), &netCfg) + + req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ + "from": from, + "to": to, + "roles": []string{role.name}, + })) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + require.NoError(t, exp.ValidatorTraces(rec, req)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp ValidatorTracesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + + // the partial-coverage contract: real pre-fork traces in Data while + // the post-fork tail is reported as a note in Errors, in one response. + require.Len(t, resp.Data, int(uint64(booleSlot)-from), "expected one trace per pre-fork slot") + for _, item := range resp.Data { + assert.Less(t, uint64(item.Slot), uint64(booleSlot), "post-fork slot leaked into data") + assert.Equal(t, role.name, item.Role) + } + require.NotEmpty(t, resp.Errors, "expected the post-fork note to surface alongside data") + for _, msg := range resp.Errors { + require.Contains(t, msg, "committee duty post-fork") + } + }) + t.Run(role.name+" with indices routes each slot by its own fork state", func(t *testing.T) { store := newMockTraceStore() From 3831c7abcf34e828eb1dd7aff0ac0779a705c7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 25 Aug 2026 10:38:56 +0200 Subject: [PATCH 10/12] exporter: share the slot-range guard across validator, committee and decideds validators The 'to == MaxUint64' guard that prevents the inclusive per-slot loop from wrapping only covered /traces/validator; /traces/committee and /decideds run the identical loop and could still be pinned forever. Hoist the range check into validateSlotRange and call it from all three validators. The range-size cap stays in #2986. --- exporter/committee.go | 7 +---- exporter/decided.go | 4 +-- exporter/validation.go | 20 ++++++++++++++ exporter/validation_test.go | 52 +++++++++++++++++++++++++++++++++++++ exporter/validator.go | 12 ++------- 5 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 exporter/validation_test.go diff --git a/exporter/committee.go b/exporter/committee.go index c4c51d7fbf..111aeeeb51 100644 --- a/exporter/committee.go +++ b/exporter/committee.go @@ -1,8 +1,6 @@ package exporter import ( - "fmt" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/hashicorp/go-multierror" "go.uber.org/zap" @@ -40,10 +38,7 @@ func (e *Exporter) CommitteeTracesCore(request *CommitteeTracesQuery) (*Committe } func validateCommitteeRequest(request *CommitteeTracesQuery) error { - if request.From > request.To { - return fmt.Errorf("'from' must be less than or equal to 'to'") - } - return nil + return validateSlotRange(request.From, request.To) } func (e *Exporter) getCommitteeDutiesForSlot(slot phase0.Slot, committeeIDs []spectypes.CommitteeID, roles ...spectypes.RunnerRole) ([]*traces.CommitteeDutyTrace, error) { diff --git a/exporter/decided.go b/exporter/decided.go index 1760987ada..67dfc8ea98 100644 --- a/exporter/decided.go +++ b/exporter/decided.go @@ -119,8 +119,8 @@ func (e *Exporter) DecidedsCore(request *DecidedsQuery) (*TraceDecidedsResult, e } func validateDecidedRequest(request *DecidedsQuery) error { - if request.From > request.To { - return fmt.Errorf("'from' must be less than or equal to 'to'") + if err := validateSlotRange(request.From, request.To); err != nil { + return err } if len(request.Roles) == 0 { diff --git a/exporter/validation.go b/exporter/validation.go index fdda0ed4bd..f8efcf1976 100644 --- a/exporter/validation.go +++ b/exporter/validation.go @@ -1,5 +1,25 @@ package exporter +import ( + "fmt" + "math" +) + +// validateSlotRange checks the inclusive [from, to] slot window shared by +// every exporter range endpoint. The per-slot loops are inclusive of 'to', +// so the maximum uint64 value would wrap the counter and never terminate. +// An endpoint-wide range-size bound is tracked in #2986; this only rejects +// an inverted window and the guaranteed hang. +func validateSlotRange(from, to uint64) error { + if from > to { + return fmt.Errorf("'from' must be less than or equal to 'to'") + } + if to == math.MaxUint64 { + return fmt.Errorf("'to' must be less than %d", uint64(math.MaxUint64)) + } + return nil +} + // ValidationError wraps an underlying error to indicate that a request is semantically invalid. // It allows callers to distinguish validation errors from processing errors using errors.As. type ValidationError struct { diff --git a/exporter/validation_test.go b/exporter/validation_test.go new file mode 100644 index 0000000000..e97ac1b85a --- /dev/null +++ b/exporter/validation_test.go @@ -0,0 +1,52 @@ +package exporter + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" + + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +func TestValidateSlotRange(t *testing.T) { + tests := []struct { + name string + from uint64 + to uint64 + wantErr string + }{ + {name: "single slot", from: 7, to: 7}, + {name: "ascending range", from: 1, to: 100}, + {name: "largest terminating 'to'", from: 1, to: math.MaxUint64 - 1}, + {name: "from greater than to", from: 10, to: 5, wantErr: "'from' must be less than or equal to 'to'"}, + {name: "'to' of max uint64 would wrap the inclusive loop", from: 1, to: math.MaxUint64, wantErr: "'to' must be less than"}, + {name: "'from' and 'to' both max uint64", from: math.MaxUint64, to: math.MaxUint64, wantErr: "'to' must be less than"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSlotRange(tt.from, tt.to) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// The committee and decideds endpoints run the same inclusive per-slot loop as +// /traces/validator, so their validators must share the max-uint64 guard. +func TestValidateCommitteeRequest_SlotRange(t *testing.T) { + require.NoError(t, validateCommitteeRequest(&CommitteeTracesQuery{From: 1, To: 2})) + require.ErrorContains(t, validateCommitteeRequest(&CommitteeTracesQuery{From: 2, To: 1}), "'from' must be less than or equal to 'to'") + require.ErrorContains(t, validateCommitteeRequest(&CommitteeTracesQuery{From: 1, To: math.MaxUint64}), "'to' must be less than") +} + +func TestValidateDecidedRequest_SlotRange(t *testing.T) { + roles := []spectypes.BeaconRole{spectypes.BNRoleProposer} + require.NoError(t, validateDecidedRequest(&DecidedsQuery{From: 1, To: 2, Roles: roles})) + require.ErrorContains(t, validateDecidedRequest(&DecidedsQuery{From: 2, To: 1, Roles: roles}), "'from' must be less than or equal to 'to'") + require.ErrorContains(t, validateDecidedRequest(&DecidedsQuery{From: 1, To: math.MaxUint64, Roles: roles}), "'to' must be less than") +} diff --git a/exporter/validator.go b/exporter/validator.go index 1959a46a75..e1c6d9c65a 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -3,7 +3,6 @@ package exporter import ( "errors" "fmt" - "math" "slices" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -94,15 +93,8 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato } func (e *Exporter) validateValidatorRequest(request *ValidatorTracesQuery) error { - if request.From > request.To { - return fmt.Errorf("'from' must be less than or equal to 'to'") - } - - // the per-slot loop is inclusive of 'to', so the maximum uint64 value - // would wrap the counter and never terminate. An endpoint-wide range - // bound is tracked in #2986; this only rejects the guaranteed hang. - if request.To == math.MaxUint64 { - return fmt.Errorf("'to' must be less than %d", uint64(math.MaxUint64)) + if err := validateSlotRange(request.From, request.To); err != nil { + return err } if len(request.Roles) == 0 { From e22aeb914c1d54db98c53204de773c0bf5e3a012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 25 Aug 2026 10:40:20 +0200 Subject: [PATCH 11/12] api/exporter: collapse the repeated Boole fork fixture into booleNetwork() The clone-and-pin of TestNetwork.SSV.Forks.Boole was inlined four times in this file; the exporter package's pre/post/straddling helpers are not importable here, so add one local helper and use it at all four sites. --- api/handlers/exporter/exporter_test.go | 46 +++++++++++--------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/api/handlers/exporter/exporter_test.go b/api/handlers/exporter/exporter_test.go index f504d64514..11d4064c78 100644 --- a/api/handlers/exporter/exporter_test.go +++ b/api/handlers/exporter/exporter_test.go @@ -625,6 +625,16 @@ func newTestExporterForV2WithNetwork(traceStore *mockTraceStore, validators stor return NewExporter(zap.NewNop(), nil, traceStore, validators, netCfg) } +// booleNetwork returns a clone of TestNetwork with the Boole fork pinned at the +// given epoch. The SSV config is copied too so the shared TestNetwork is never mutated. +func booleNetwork(epoch phase0.Epoch) *networkconfig.Network { + ssvCopy := *networkconfig.TestNetwork.SSV + ssvCopy.Forks.Boole = epoch + netCfg := *networkconfig.TestNetwork + netCfg.SSV = &ssvCopy + return &netCfg +} + func buildJSONBody(t *testing.T, payload map[string]any) *strings.Reader { t.Helper() b, err := json.Marshal(payload) @@ -2450,12 +2460,7 @@ func TestExporterValidatorTraces_ForkGating(t *testing.T) { return &traces.CommitteeDutyTrace{Slot: s, CommitteeID: id}, nil } - ssvCopy := *networkconfig.TestNetwork.SSV - ssvCopy.Forks.Boole = tt.booleEpoch - netCfg := *networkconfig.TestNetwork - netCfg.SSV = &ssvCopy - - exp := newTestExporterForV2WithNetwork(store, validatorStore, &netCfg) + exp := newTestExporterForV2WithNetwork(store, validatorStore, booleNetwork(tt.booleEpoch)) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": uint64(slot), @@ -2539,12 +2544,7 @@ func TestExporterValidatorTraces_ForkGating_ValidationSymmetric(t *testing.T) { } validatorStore := newMockValidatorStore() - ssvCopy := *networkconfig.TestNetwork.SSV - ssvCopy.Forks.Boole = tt.booleEpoch - netCfg := *networkconfig.TestNetwork - netCfg.SSV = &ssvCopy - - exp := newTestExporterForV2WithNetwork(store, validatorStore, &netCfg) + exp := newTestExporterForV2WithNetwork(store, validatorStore, booleNetwork(tt.booleEpoch)) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": uint64(100), @@ -2576,11 +2576,7 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { var committeeID spectypes.CommitteeID committeeID[0] = 7 - ssvCopy := *networkconfig.TestNetwork.SSV - ssvCopy.Forks.Boole = booleEpoch - netCfg := *networkconfig.TestNetwork - netCfg.SSV = &ssvCopy - + netCfg := booleNetwork(booleEpoch) booleSlot := netCfg.FirstSlotAtEpoch(booleEpoch) require.GreaterOrEqual(t, uint64(booleSlot), uint64(10), "boole fork slot too low for the range below") from := uint64(booleSlot) - 10 // pre-Boole @@ -2614,7 +2610,7 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { for _, role := range roles { t.Run(role.name+" without filters returns a partial response with post-fork notes", func(t *testing.T) { - exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), &netCfg) + exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": from, @@ -2649,7 +2645,7 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { return []*traces.ValidatorDutyTrace{{Slot: slot, Role: r, Validator: idx}}, nil } - exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), &netCfg) + exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": from, @@ -2696,7 +2692,7 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { return role.signerDataBuilder(s), nil } - exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), &netCfg) + exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": from, @@ -2731,18 +2727,14 @@ func TestExporterValidatorTraces_ForkGating_CrossForkRange(t *testing.T) { func TestExporterValidatorTraces_ForkGating_ZeroPreForkTraces(t *testing.T) { const booleEpoch = phase0.Epoch(5) - ssvCopy := *networkconfig.TestNetwork.SSV - ssvCopy.Forks.Boole = booleEpoch - netCfg := *networkconfig.TestNetwork - netCfg.SSV = &ssvCopy - + netCfg := booleNetwork(booleEpoch) booleSlot := netCfg.FirstSlotAtEpoch(booleEpoch) require.GreaterOrEqual(t, uint64(booleSlot), uint64(2), "boole fork slot too low for the range below") from := uint64(booleSlot) - 2 // pre-Boole to := uint64(booleSlot) + 2 // post-Boole t.Run("only post-fork notes and no pre-fork traces -> 200 with empty data", func(t *testing.T) { - exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), &netCfg) + exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": from, @@ -2769,7 +2761,7 @@ func TestExporterValidatorTraces_ForkGating_ZeroPreForkTraces(t *testing.T) { store.GetValidatorDutiesFunc = func(role spectypes.BeaconRole, slot phase0.Slot) ([]*traces.ValidatorDutyTrace, error) { return nil, fmt.Errorf("forced error on GetValidatorDuties") } - exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), &netCfg) + exp := newTestExporterForV2WithNetwork(store, newMockValidatorStore(), netCfg) req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{ "from": from, From 440ffb7366d7665b0be1a07544a909e43b80199e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 25 Aug 2026 11:53:44 +0200 Subject: [PATCH 12/12] exporter: trim the duplicated post-fork note text The surfaced note wrapped a sentinel that already read 'committee duty post-fork requires pubkeys or indices' and then repeated both halves in the format string, so a client saw 'committee duty post-fork' twice and 'pubkeys or indices' three times. Trim the sentinel to the bare marker and let the format string carry the actionable hint once. errors.Is matching and the 'committee duty post-fork' assertions are unaffected. --- exporter/validator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exporter/validator.go b/exporter/validator.go index e1c6d9c65a..8e71df9072 100644 --- a/exporter/validator.go +++ b/exporter/validator.go @@ -21,7 +21,7 @@ import ( // fork-straddling request reaches a post-fork slot/role pair without // pubkeys/indices. It lets callers (e.g. the HTTP layer) tell this expected, // partial-coverage note apart from genuine processing failures. -var ErrPostForkCommitteeDutyNote = errors.New("committee duty post-fork requires pubkeys or indices") +var ErrPostForkCommitteeDutyNote = errors.New("committee duty post-fork") // committeeDutyFilterHint is the actionable tail shared by the committee-duty // validation error and the post-fork partial-coverage note, hoisted so the two @@ -80,7 +80,7 @@ func (e *Exporter) ValidatorTracesCore(request *ValidatorTracesQuery) (*Validato continue } delete(postForkNoteFrom, role) // guard against duplicate roles in the request - errs = multierror.Append(errs, fmt.Errorf("%w: slots %d-%d: role %s is a committee duty post-fork, %s", ErrPostForkCommitteeDutyNote, noteFrom, request.To, role.String(), committeeDutyFilterHint)) + errs = multierror.Append(errs, fmt.Errorf("%w: slots %d-%d: role %s, %s", ErrPostForkCommitteeDutyNote, noteFrom, request.To, role.String(), committeeDutyFilterHint)) } // by design, not found duties are expected and not considered as API errors