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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b4c59a.
Added an unfiltered straddling subtest (both roles) that seeds pre-fork traces via GetValidatorDuties, asserting non-empty data alongside the post-fork note in errors, plus a guard that the unfiltered validator path is never consulted at post-Boole slots.

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.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call.
Hoisted the shared sentence into a committeeDutyFilterHint const used by both the note and the validation error.

Fixed in 36e901d.

}

// 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.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point.
Even though the hang is pre-existing on the filtered paths, this PR did remove the 400 that shielded the unfiltered one. Fixed in 36f7968: validateValidatorRequest now rejects to == MaxUint64 next to the existing from > to check, with a test.

The endpoint-wide range cap stays with #2986.

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