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/api/handlers/exporter/exporter_test.go b/api/handlers/exporter/exporter_test.go index 991197eeaf..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) @@ -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,75 @@ 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() + + 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") + }) +} + // 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..b3113f4d47 100644 --- a/api/handlers/exporter/validator_http.go +++ b/api/handlers/exporter/validator_http.go @@ -1,14 +1,24 @@ package exporter import ( + "errors" "net/http" + "github.com/hashicorp/go-multierror" + "github.com/ssvlabs/ssv/api" + exportercore "github.com/ssvlabs/ssv/exporter" ) // 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 @@ -39,8 +49,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 +60,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/docs/api/ssvnode.openapi.json b/docs/api/ssvnode.openapi.json index 7f978c997f..252f1f9826 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" ], @@ -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 63727a86db..bd94f95e31 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, @@ -868,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 @@ -944,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 diff --git a/exporter/validator.go b/exporter/validator.go index 8d73858e01..478b10ad55 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 { @@ -33,11 +40,26 @@ 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 { + isCommittee := e.isCommitteeDutyAtSlot(role, slot) + if isCommittee && len(indices) == 0 { + if _, ok := postForkNoteFrom[role]; !ok { + postForkNoteFrom[role] = slot + } + continue + } + providerFunc := e.getValidatorDutiesForRoleAndSlot - if e.isCommitteeDutyAtSlot(role, slot) { + if isCommittee { providerFunc = e.getValidatorCommitteeDutiesForRoleAndSlot } @@ -47,6 +69,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) @@ -66,11 +97,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..fb439911cf 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,53 @@ 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 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") +} + // mockValidatorTraceStore is a minimal dutyTraceStore implementation for // exercising getValidatorCommitteeDutiesForRoleAndSlot's signer-bucket gating. type mockValidatorTraceStore struct {