Skip to content
2 changes: 1 addition & 1 deletion api/handlers/exporter/committee_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 88 additions & 6 deletions api/handlers/exporter/exporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Comment thread
iurii-ssv marked this conversation as resolved.
exp := newTestExporterForV2WithNetwork(newMockTraceStore(), newMockValidatorStore(), &netCfg)

req := httptest.NewRequest(http.MethodPost, "/traces/validator", buildJSONBody(t, map[string]any{
Expand All @@ -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) {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: every 200 case in these fork tests asserts require.Empty(t, resp.Data) (here, the CrossForkRange "without filters" subtest, and TestValidatorTracesCore_StraddlingFork). The shape that is never exercised is the actual partial-coverage contract - an unfiltered straddling range whose pre-fork slots return real traces and whose post-fork tail emits a note, i.e. resp.Data non-empty while resp.Errors carries the note. The "with indices" subtest hits the committee path but via the filtered route (which never emits a note) and only asserts routing. Worth one case that seeds pre-fork trace data so data + note coexist.

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
Expand Down
31 changes: 29 additions & 2 deletions api/handlers/exporter/validator_http.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -39,12 +49,29 @@ 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())
}

// otherwise return a partial response with valid duties
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
}
8 changes: 4 additions & 4 deletions docs/api/ssvnode.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
],
Expand Down
26 changes: 22 additions & 4 deletions docs/api/ssvnode.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions exporter/validator.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package exporter

import (
"errors"
"fmt"
"slices"

Expand All @@ -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 {
Expand All @@ -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
}

Expand All @@ -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()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The tail of this note ("please provide either pubkeys or indices to filter the duty ... or use the /committee endpoint ...") is duplicated verbatim from the 400 message in validateValidatorRequest (the role %s is a committee duty return just below). Consider hoisting the shared sentence into a package-level const so the note and the validation error can't drift apart.

}

// by design, not found duties are expected and not considered as API errors
errs = filterOutDutyNotFoundErrors(errs)

Expand All @@ -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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-on to the note-amplification thread / #2986 — not re-raising the endpoint-wide range bound, but one nuance that disposition doesn't capture: moving this gate from request.To to request.From regresses a specific input class from a clean 400 into a hang.

An unfiltered committee-duty request (roles:[AGGREGATOR], no indices/pubkeys) with a pre-Boole from and to = math.MaxUint64 used to be rejected here with 400 (the old gate evaluated to, which is post-Boole). It's now accepted and falls into the inclusive for s := request.From; s <= request.To; s++ loop, where s++ wraps past MaxUint64 and s <= request.To stays true forever — an infinite loop pinning a goroutine at 100% CPU, with no ctx/timeout escape.

The DoS itself is pre-existing (already reachable via any filtered request, or an unfiltered PROPOSER, with to = MaxUint64), so this is low-severity and non-blocking — leaving the general fix to #2986 is fine. But since this PR is what removes the 400 that shielded the unfiltered path, a cheap local guard in validateValidatorRequest (reject to == math.MaxUint64, or to - from above a sane cap, next to the existing from > to check) would stop this PR from regressing the case while the endpoint-wide fix waits.

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())
}
}
Expand Down
Loading