From 0642dce204225fd6a3b7b766147c0c7a55192c1e Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:56:44 +0300 Subject: [PATCH 1/2] core/validatorapi: serve ssz propose block v4 responses --- core/validatorapi/router.go | 95 +++++++-- core/validatorapi/router_internal_test.go | 226 +++++++++++++++++++++- 2 files changed, 297 insertions(+), 24 deletions(-) diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go index 2cd4c4256..42c309cf1 100644 --- a/core/validatorapi/router.go +++ b/core/validatorapi/router.go @@ -117,6 +117,9 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { Handler handlerFunc Methods []string Encodings []contentType + // ResponseEncodings are the supported response content types, negotiated via the + // request Accept header; json only if empty. + ResponseEncodings []contentType // MaxBody limits the request body size in bytes, enforced at the read boundary // before the body is buffered; zero means unlimited. MaxBody int64 @@ -213,11 +216,12 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, }, { - Name: "propose_block_v4", - Path: "/eth/v4/validator/blocks/{slot}", - Handler: proposeBlockV4(h), - Methods: []string{http.MethodPost}, - Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, + Name: "propose_block_v4", + Path: "/eth/v4/validator/blocks/{slot}", + Handler: proposeBlockV4(h), + Methods: []string{http.MethodPost}, + Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, + ResponseEncodings: []contentType{contentTypeJSON, contentTypeSSZ}, // The request body carries the VC builder config, see maxBuilderConfigBody. MaxBody: maxBuilderConfigBody, }, @@ -387,7 +391,7 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { r := mux.NewRouter() for _, e := range endpoints { - handler := r.Handle(e.Path, wrap(e.Name, e.Handler, e.Encodings, e.MaxBody)) + handler := r.Handle(e.Path, wrap(e.Name, e.Handler, e.Encodings, e.ResponseEncodings, e.MaxBody)) if len(e.Methods) != 0 { handler.Methods(e.Methods...) } @@ -430,12 +434,13 @@ func badRequestError(msg string, err error) error { } // handlerFunc is a convenient handler function providing a context, parsed path parameters, -// the request body, and returning the response struct or an error. +// the request body, and returning the response struct or an error. The response is encoded +// as json, or as ssz if negotiated (see writeResponse). type handlerFunc func(ctx context.Context, params map[string]string, header http.Header, query url.Values, typ contentType, body []byte) (res any, headers http.Header, err error) // wrap adapts the handler function returning a standard http handler. // It does tracing, metrics and response and error writing. -func wrap(endpoint string, handler handlerFunc, encodings []contentType, maxBody int64) http.Handler { +func wrap(endpoint string, handler handlerFunc, encodings []contentType, responseEncodings []contentType, maxBody int64) http.Handler { wrap := func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ctx = log.WithTopic(ctx, "vapi") @@ -517,7 +522,7 @@ func wrap(endpoint string, handler handlerFunc, encodings []contentType, maxBody return } - writeResponse(ctx, w, endpoint, res, headers) + writeResponse(ctx, w, endpoint, res, negotiateResponseType(r.Header, responseEncodings), headers) } return http.HandlerFunc(wrap) @@ -526,32 +531,74 @@ func wrap(endpoint string, handler handlerFunc, encodings []contentType, maxBody // noContentResponse is a handler response resulting in 204 No Content without a body. type noContentResponse struct{} -// writeResponse writes the 200 OK response and json response body. -func writeResponse(ctx context.Context, w http.ResponseWriter, endpoint string, response any, headers http.Header) { +// negotiateResponseType returns the response content type for the request: ssz if the Accept +// header asks for it and the endpoint supports it, json otherwise. Quality weights (q=) in the +// Accept header are deliberately ignored: validator clients send a single media type or list +// ssz first, so any mention of ssz is treated as a preference for it. +func negotiateResponseType(header http.Header, supported []contentType) contentType { + if strings.Contains(header.Get("Accept"), string(contentTypeSSZ)) && slices.Contains(supported, contentTypeSSZ) { + return contentTypeSSZ + } + + return contentTypeJSON +} + +// sszMarshaler is a response that can be encoded as ssz. +type sszMarshaler interface { + MarshalSSZ() ([]byte, error) +} + +// writeResponse writes the 200 OK response with the response struct encoded as the provided content type. +func writeResponse(ctx context.Context, w http.ResponseWriter, endpoint string, response any, typ contentType, headers http.Header) { if response == nil { return } + // A no-content response has no body to encode, regardless of the negotiated type. if _, ok := response.(noContentResponse); ok { w.WriteHeader(http.StatusNoContent) return } - b, err := json.Marshal(response) - if err != nil { - writeError(ctx, w, endpoint, errors.Wrap(err, "marshal response body")) + var b []byte + + switch typ { + case contentTypeJSON: + var err error + + b, err = json.Marshal(response) + if err != nil { + writeError(ctx, w, endpoint, errors.Wrap(err, "marshal json response body")) + return + } + case contentTypeSSZ: + marshaller, ok := response.(sszMarshaler) + if !ok { + writeError(ctx, w, endpoint, errors.New("response type doesn't support ssz marshalling")) + return + } + + var err error + + b, err = marshaller.MarshalSSZ() + if err != nil { + writeError(ctx, w, endpoint, errors.Wrap(err, "marshal ssz response body")) + return + } + default: + writeError(ctx, w, endpoint, errors.New("unsupported response content type", z.Str("type", string(typ)))) return } - w.Header().Set("Content-Type", "application/json") - for name, values := range headers { for _, val := range values { w.Header().Add(name, val) } } - if _, err = w.Write(b); err != nil { + w.Header().Set("Content-Type", string(typ)) + + if _, err := w.Write(b); err != nil { // Too late to also try to writeError at this point, so just log. log.Error(ctx, "Failed to write API response to client. Connection may have been closed", err) } @@ -1195,7 +1242,9 @@ func proposeBlockV4(p eth2client.EPBSProposalProvider) handlerFunc { } } -// proposeBlockV4Response is the response body of the v4 propose block endpoint. +// proposeBlockV4Response is the response body of the v4 propose block endpoint. The json body +// wraps the block data in this envelope, while the ssz body is the block data alone with the +// envelope fields provided as response headers. type proposeBlockV4Response struct { Version string `json:"version"` ExecutionPayloadIncluded bool `json:"execution_payload_included"` @@ -1204,6 +1253,16 @@ type proposeBlockV4Response struct { Data any `json:"data"` } +// MarshalSSZ encodes the block data as ssz, see proposeBlockV4Response. +func (r proposeBlockV4Response) MarshalSSZ() ([]byte, error) { + data, ok := r.Data.(sszMarshaler) + if !ok { + return nil, errors.New("block data doesn't support ssz marshalling") + } + + return data.MarshalSSZ() +} + // weiString returns the value as a decimal Wei string, or "0" when unset. The value // headers are required in the response, but the eth2 client leaves a proposal value // nil when the bid it commits to cannot be attributed. diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go index 6b9fe3784..99a51cb2a 100644 --- a/core/validatorapi/router_internal_test.go +++ b/core/validatorapi/router_internal_test.go @@ -28,6 +28,7 @@ import ( eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella" eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" eth2http "github.com/attestantio/go-eth2-client/http" eth2mock "github.com/attestantio/go-eth2-client/mock" eth2spec "github.com/attestantio/go-eth2-client/spec" @@ -445,6 +446,115 @@ func TestRawRouter(t *testing.T) { testRawRouter(t, handler, callback) }) + t.Run("ssz unsupported route answers json", func(t *testing.T) { + handler := testHandler{ + NodeVersionFunc: func(context.Context, *eth2api.NodeVersionOpts) (*eth2api.Response[string], error) { + return wrapResponse("charon/test"), nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/eth/v1/node/version", nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/octet-stream") + + res, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer res.Body.Close() + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "application/json", res.Header.Get("Content-Type")) + } + + testRawRouter(t, handler, callback) + }) + + t.Run("propose block v4 ssz with payload", func(t *testing.T) { + proposal := testutil.RandomGloasCoreVersionedEPBSProposalWithPayload() + + handler := testHandler{ + EPBSProposalFunc: func(context.Context, *eth2api.EPBSProposalOpts) (*eth2api.Response[*eth2api.VersionedEPBSProposal], error) { + return wrapResponse(proposal.EPBS), nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + url := fmt.Sprintf("%s/eth/v4/validator/blocks/456?randao_reveal=%#x&graffiti=%#x&include_payload=true", baseURL, testutil.RandomEth2Signature(), testutil.RandomBytes32()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}"))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/octet-stream") + + res, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer res.Body.Close() + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "application/octet-stream", res.Header.Get("Content-Type")) + require.Equal(t, "gloas", res.Header.Get("Eth-Consensus-Version")) + require.Equal(t, "true", res.Header.Get("Eth-Execution-Payload-Included")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var contents apiv1gloas.BlockContents + require.NoError(t, contents.UnmarshalSSZ(body)) + + expectedRoot, err := proposal.EPBS.GloasContents.HashTreeRoot() + require.NoError(t, err) + actualRoot, err := contents.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, expectedRoot, actualRoot) + } + + testRawRouter(t, handler, callback) + }) + + t.Run("propose block v4 ssz without payload", func(t *testing.T) { + proposal := testutil.RandomGloasCoreVersionedEPBSProposal() + + handler := testHandler{ + EPBSProposalFunc: func(context.Context, *eth2api.EPBSProposalOpts) (*eth2api.Response[*eth2api.VersionedEPBSProposal], error) { + return wrapResponse(proposal.EPBS), nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + url := fmt.Sprintf("%s/eth/v4/validator/blocks/456?randao_reveal=%#x&graffiti=%#x&include_payload=false", baseURL, testutil.RandomEth2Signature(), testutil.RandomBytes32()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}"))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/octet-stream") + + res, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer res.Body.Close() + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "application/octet-stream", res.Header.Get("Content-Type")) + require.Equal(t, "false", res.Header.Get("Eth-Execution-Payload-Included")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var block gloas.BeaconBlock + require.NoError(t, block.UnmarshalSSZ(body)) + + expectedRoot, err := proposal.EPBS.Gloas.HashTreeRoot() + require.NoError(t, err) + actualRoot, err := block.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, expectedRoot, actualRoot) + } + + testRawRouter(t, handler, callback) + }) + t.Run("proposer duties v2", func(t *testing.T) { const ( epoch = 4 @@ -2620,33 +2730,33 @@ func (h testHandler) newBeaconHandler(t *testing.T) http.Handler { mux.HandleFunc("/eth/v1/beacon/genesis", func(w http.ResponseWriter, r *http.Request) { res, err := mock.Genesis(ctx, ð2api.GenesisOpts{}) require.NoError(t, err) - writeResponse(ctx, w, "", res.Data, nil) + writeResponse(ctx, w, "", res.Data, contentTypeJSON, nil) }) mux.HandleFunc("/eth/v1/config/spec", func(w http.ResponseWriter, r *http.Request) { res := map[string]any{ "SLOTS_PER_EPOCH": strconv.Itoa(slotsPerEpoch), "ELECTRA_FORK_EPOCH": strconv.Itoa(electraForkEpoch), } - writeResponse(ctx, w, "", nest(res, "data"), nil) + writeResponse(ctx, w, "", nest(res, "data"), contentTypeJSON, nil) }) mux.HandleFunc("/eth/v1/config/deposit_contract", func(w http.ResponseWriter, r *http.Request) { res, err := mock.DepositContract(ctx, ð2api.DepositContractOpts{}) require.NoError(t, err) - writeResponse(ctx, w, "", res.Data, nil) + writeResponse(ctx, w, "", res.Data, contentTypeJSON, nil) }) mux.HandleFunc("/eth/v1/config/fork_schedule", func(w http.ResponseWriter, r *http.Request) { res, err := mock.ForkSchedule(ctx, ð2api.ForkScheduleOpts{}) require.NoError(t, err) - writeResponse(ctx, w, "", nest(res.Data, "data"), nil) + writeResponse(ctx, w, "", nest(res.Data, "data"), contentTypeJSON, nil) }) mux.HandleFunc("/eth/v2/debug/beacon/states/head", func(w http.ResponseWriter, r *http.Request) { res := testutil.RandomBeaconState(t) w.Header().Add(versionHeader, res.Version.String()) - writeResponse(ctx, w, "", nest(res.Capella, "data"), nil) + writeResponse(ctx, w, "", nest(res.Capella, "data"), contentTypeJSON, nil) }) mux.HandleFunc("/eth/v1/node/syncing", func(w http.ResponseWriter, r *http.Request) { - writeResponse(ctx, w, "", nest(map[string]any{"is_syncing": false, "head_slot": "1", "sync_distance": "1"}, "data"), nil) + writeResponse(ctx, w, "", nest(map[string]any{"is_syncing": false, "head_slot": "1", "sync_distance": "1"}, "data"), contentTypeJSON, nil) }) if h.ProxyFunc != nil { @@ -2796,6 +2906,110 @@ func TestAttestationDataRoutes(t *testing.T) { testRawRouter(t, handler, callback) } +func TestNegotiateResponseType(t *testing.T) { + both := []contentType{contentTypeJSON, contentTypeSSZ} + jsonOnly := []contentType{contentTypeJSON} + + tests := []struct { + name string + accept string + supported []contentType + want contentType + }{ + {name: "ssz requested and supported", accept: "application/octet-stream", supported: both, want: contentTypeSSZ}, + {name: "ssz preferred over json", accept: "application/octet-stream;q=1.0,application/json;q=0.9", supported: both, want: contentTypeSSZ}, + {name: "ssz requested but unsupported", accept: "application/octet-stream", supported: jsonOnly, want: contentTypeJSON}, + {name: "ssz requested, no encodings declared", accept: "application/octet-stream", supported: nil, want: contentTypeJSON}, + {name: "json requested", accept: "application/json", supported: both, want: contentTypeJSON}, + {name: "no accept header", accept: "", supported: both, want: contentTypeJSON}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + header := http.Header{} + if test.accept != "" { + header.Set("Accept", test.accept) + } + + require.Equal(t, test.want, negotiateResponseType(header, test.supported)) + }) + } +} + +// TestProposeBlockV4ResponseMarshalSSZ asserts that the ssz encoding of the response is the +// bare block data, without the json envelope fields (which travel in the response headers). +func TestProposeBlockV4ResponseMarshalSSZ(t *testing.T) { + t.Run("propose block v4 with payload", func(t *testing.T) { + contents := testutil.RandomGloasCoreVersionedEPBSProposalWithPayload().EPBS.GloasContents + expected, err := contents.MarshalSSZ() + require.NoError(t, err) + + actual, err := proposeBlockV4Response{Version: "gloas", ExecutionPayloadIncluded: true, Data: contents}.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("propose block v4 without payload", func(t *testing.T) { + block := testutil.RandomGloasCoreVersionedEPBSProposal().EPBS.Gloas + expected, err := block.MarshalSSZ() + require.NoError(t, err) + + actual, err := proposeBlockV4Response{Version: "gloas", Data: block}.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("propose block v4 unsupported data", func(t *testing.T) { + _, err := proposeBlockV4Response{Data: "not a block"}.MarshalSSZ() + require.Error(t, err) + }) +} + +func TestWriteResponse(t *testing.T) { + ctx := context.Background() + + t.Run("json", func(t *testing.T) { + w := httptest.NewRecorder() + writeResponse(ctx, w, "test", struct { + A string `json:"a"` + }{A: "b"}, contentTypeJSON, http.Header{"X-Test": []string{"1"}}) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/json", w.Header().Get("Content-Type")) + require.Equal(t, "1", w.Header().Get("X-Test")) + require.JSONEq(t, `{"a":"b"}`, w.Body.String()) + }) + + t.Run("ssz", func(t *testing.T) { + data := testutil.RandomPayloadAttestationData() + expected, err := data.MarshalSSZ() + require.NoError(t, err) + + w := httptest.NewRecorder() + writeResponse(ctx, w, "test", data, contentTypeSSZ, http.Header{"X-Test": []string{"1"}}) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, []string{"application/octet-stream"}, w.Header().Values("Content-Type"), "content type set exactly once") + require.Equal(t, "1", w.Header().Get("X-Test")) + require.Equal(t, expected, w.Body.Bytes()) + }) + + t.Run("ssz response not marshallable", func(t *testing.T) { + w := httptest.NewRecorder() + writeResponse(ctx, w, "test", struct{}{}, contentTypeSSZ, nil) + + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("no body", func(t *testing.T) { + w := httptest.NewRecorder() + writeResponse(ctx, w, "test", nil, contentTypeJSON, nil) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, w.Body.Bytes()) + }) +} + func TestPayloadAttestationRoutes(t *testing.T) { t.Run("payload_attestation_data", func(t *testing.T) { expected := testutil.RandomPayloadAttestationData() From e38d512a18206cd0b9b8e7d86bb5f87147acfaa4 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:57:24 +0300 Subject: [PATCH 2/2] core/validatorapi: ssz for payload attestation endpoints --- core/validatorapi/router.go | 82 ++++----- core/validatorapi/router_internal_test.go | 198 +++++++++++++++++++++- go.mod | 2 +- go.sum | 4 +- 4 files changed, 225 insertions(+), 61 deletions(-) diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go index 42c309cf1..10e448c9f 100644 --- a/core/validatorapi/router.go +++ b/core/validatorapi/router.go @@ -324,18 +324,20 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { Encodings: []contentType{contentTypeJSON}, }, { - Name: "payload_attestation_data", - Path: "/eth/v1/validator/payload_attestation_data", - Handler: payloadAttestationData(h), - Methods: []string{http.MethodGet}, - Encodings: []contentType{contentTypeJSON}, + Name: "payload_attestation_data", + Path: "/eth/v1/validator/payload_attestation_data", + Handler: payloadAttestationData(h), + Methods: []string{http.MethodGet}, + Encodings: []contentType{contentTypeJSON}, + ResponseEncodings: []contentType{contentTypeJSON, contentTypeSSZ}, }, { Name: "submit_payload_attestations", Path: "/eth/v1/beacon/pool/payload_attestations", Handler: submitPayloadAttestationMessages(h), Methods: []string{http.MethodPost}, - Encodings: []contentType{contentTypeJSON}, + Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, + MaxBody: maxPayloadAttestationsBody, }, { Name: "submit_execution_payload_envelope", @@ -2002,16 +2004,31 @@ func payloadAttestationData(p eth2client.PayloadAttestationDataProvider) handler version := data.Version.String() - return struct { - Version string `json:"version"` - Data *gloas.PayloadAttestationData `json:"data"` - }{ + return payloadAttestationDataResponse{ Version: version, Data: data.Gloas, }, http.Header{versionHeader: []string{version}}, nil } } +// payloadAttestationDataResponse is the response body of the payload attestation data endpoint. +// The json body wraps the data in this envelope, while the ssz body is the data alone with the +// version provided as response header. +type payloadAttestationDataResponse struct { + Version string `json:"version"` + Data *gloas.PayloadAttestationData `json:"data"` +} + +// MarshalSSZ encodes the payload attestation data as ssz, see payloadAttestationDataResponse. +func (r payloadAttestationDataResponse) MarshalSSZ() ([]byte, error) { + return r.Data.MarshalSSZ() +} + +// maxPayloadAttestationsBody is a sanity cap on the payload attestation messages request body, +// enforced by the route's MaxBody at the read boundary. It is far above the encoded size of the +// spec list limit (PTC_SIZE items), which the api list type enforces when decoding. +const maxPayloadAttestationsBody = 1 << 20 // 1MB + // submitPayloadAttestationMessages returns a handler function for the payload attestation pool submission endpoint. func submitPayloadAttestationMessages(s eth2client.PayloadAttestationMessagesSubmitter) handlerFunc { return func(ctx context.Context, _ map[string]string, header http.Header, _ url.Values, typ contentType, body []byte) (any, http.Header, error) { @@ -2033,7 +2050,7 @@ func submitPayloadAttestationMessages(s eth2client.PayloadAttestationMessagesSub } } - var msgs []*gloas.PayloadAttestationMessage + var msgs eth2v1gloas.PayloadAttestationMessages err = unmarshal(typ, body, &msgs) if err != nil { @@ -2104,14 +2121,9 @@ func submitProposerPreferences(h Handler) handlerFunc { } } - var prefs []*gloas.SignedProposerPreferences - - if typ == contentTypeSSZ { - prefs, err = unmarshalProposerPreferencesSSZ(body) - } else { - err = unmarshal(typ, body, &prefs) - } + var prefs eth2v1gloas.SignedProposerPreferencesList + err = unmarshal(typ, body, &prefs) if err != nil { return nil, nil, errors.Wrap(err, "unmarshal proposer preferences") } @@ -2125,38 +2137,6 @@ func submitProposerPreferences(h Handler) handlerFunc { } } -// unmarshalProposerPreferencesSSZ decodes an SSZ List[SignedProposerPreferences]. The element -// type is fixed-size, so the list is encoded as plain concatenation without an offset table. -// An empty body is a valid empty list, matching the JSON `[]` behavior. -func unmarshalProposerPreferencesSSZ(body []byte) ([]*gloas.SignedProposerPreferences, error) { - itemSize := (&gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}}).SizeSSZ() - - if len(body)%itemSize != 0 { - return nil, apiError{ - StatusCode: http.StatusBadRequest, - Message: "invalid ssz proposer preferences list length", - Err: errors.New("invalid ssz list length", z.Int("length", len(body)), z.Int("item_size", itemSize)), - } - } - - prefs := make([]*gloas.SignedProposerPreferences, 0, len(body)/itemSize) - - for i := 0; i < len(body); i += itemSize { - pref := new(gloas.SignedProposerPreferences) - if err := pref.UnmarshalSSZ(body[i : i+itemSize]); err != nil { - return nil, apiError{ - StatusCode: http.StatusBadRequest, - Message: "failed parsing ssz proposer preferences", - Err: err, - } - } - - prefs = append(prefs, pref) - } - - return prefs, nil -} - // submitExecutionPayloadEnvelope receives a partially signed execution payload envelope (with its // blobs and KZG proofs) from the validator client and forwards it for threshold aggregation. From // the gloas fork, a self-building proposer reveals its execution payload with this submission. @@ -2475,7 +2455,7 @@ func unmarshal(typ contentType, body []byte, v any) error { err := unmarshaller.UnmarshalSSZ(body) if err != nil { return apiError{ - StatusCode: http.StatusUnsupportedMediaType, + StatusCode: http.StatusBadRequest, Message: "failed parsing ssz request body", Err: err, } diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go index 99a51cb2a..29f7e0a51 100644 --- a/core/validatorapi/router_internal_test.go +++ b/core/validatorapi/router_internal_test.go @@ -2965,6 +2965,31 @@ func TestProposeBlockV4ResponseMarshalSSZ(t *testing.T) { }) } +// TestUnmarshalSSZLists asserts that ssz request bodies decode into the api list types and that +// malformed bodies are rejected as bad requests. +func TestUnmarshalSSZLists(t *testing.T) { + msgs := apiv1gloas.PayloadAttestationMessages{ + testutil.RandomPayloadAttestationMessage(), + testutil.RandomPayloadAttestationMessage(), + } + + body, err := msgs.MarshalSSZ() + require.NoError(t, err) + + var decoded apiv1gloas.PayloadAttestationMessages + + require.NoError(t, unmarshal(contentTypeSSZ, body, &decoded)) + require.Equal(t, msgs, decoded) + + // A body that isn't a whole number of elements is a bad request, not an unsupported media type. + err = unmarshal(contentTypeSSZ, body[:len(body)-1], &decoded) + + var aerr apiError + + require.ErrorAs(t, err, &aerr) + require.Equal(t, http.StatusBadRequest, aerr.StatusCode) +} + func TestWriteResponse(t *testing.T) { ctx := context.Background() @@ -3010,6 +3035,18 @@ func TestWriteResponse(t *testing.T) { }) } +// TestPayloadAttestationDataResponseMarshalSSZ asserts that the ssz encoding of the response +// is the bare payload attestation data, without the json envelope. +func TestPayloadAttestationDataResponseMarshalSSZ(t *testing.T) { + data := testutil.RandomPayloadAttestationData() + expected, err := data.MarshalSSZ() + require.NoError(t, err) + + actual, err := payloadAttestationDataResponse{Version: "gloas", Data: data}.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, expected, actual) +} + func TestPayloadAttestationRoutes(t *testing.T) { t.Run("payload_attestation_data", func(t *testing.T) { expected := testutil.RandomPayloadAttestationData() @@ -3077,6 +3114,152 @@ func TestPayloadAttestationRoutes(t *testing.T) { testRawRouter(t, handler, callback) }) + t.Run("payload_attestation_data_ssz_no_block", func(t *testing.T) { + handler := testHandler{ + PayloadAttestationDataFunc: func(context.Context, *eth2api.PayloadAttestationDataOpts) (*eth2api.Response[*eth2spec.VersionedPayloadAttestationData], error) { + return nil, eth2client.ErrNoPayloadAttestationData + }, + } + + callback := func(ctx context.Context, baseURL string) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/eth/v1/validator/payload_attestation_data?slot=42", nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/octet-stream") + + resp, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + // No block is 204 No Content whichever encoding was requested. + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Empty(t, body) + } + + testRawRouter(t, handler, callback) + }) + + t.Run("payload_attestation_data_ssz", func(t *testing.T) { + expected := testutil.RandomPayloadAttestationData() + expected.Slot = 42 + + handler := testHandler{ + PayloadAttestationDataFunc: func(context.Context, *eth2api.PayloadAttestationDataOpts) (*eth2api.Response[*eth2spec.VersionedPayloadAttestationData], error) { + return wrapResponse(ð2spec.VersionedPayloadAttestationData{ + Version: eth2spec.DataVersionGloas, + Gloas: expected, + }), nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/eth/v1/validator/payload_attestation_data?slot=42", nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/octet-stream") + + resp, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "application/octet-stream", resp.Header.Get("Content-Type")) + require.Equal(t, "gloas", resp.Header.Get(versionHeader)) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var actual gloas.PayloadAttestationData + require.NoError(t, actual.UnmarshalSSZ(body)) + require.Equal(t, expected, &actual) + } + + testRawRouter(t, handler, callback) + }) + + t.Run("submit_payload_attestations_oversized_body", func(t *testing.T) { + var submitted bool + + handler := testHandler{ + SubmitPayloadAttMsgsFunc: func(context.Context, *eth2api.SubmitPayloadAttestationMessagesOpts) error { + submitted = true + + return nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + body := make([]byte, maxPayloadAttestationsBody+1) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set(versionHeader, "gloas") + + resp, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + require.False(t, submitted) + } + + testRawRouter(t, handler, callback) + }) + + t.Run("submit_payload_attestations_ssz", func(t *testing.T) { + msgs := []*gloas.PayloadAttestationMessage{ + testutil.RandomPayloadAttestationMessage(), + testutil.RandomPayloadAttestationMessage(), + } + + var received []*eth2spec.VersionedPayloadAttestationMessage + + handler := testHandler{ + SubmitPayloadAttMsgsFunc: func(_ context.Context, opts *eth2api.SubmitPayloadAttestationMessagesOpts) error { + received = opts.Messages + + return nil + }, + } + + callback := func(ctx context.Context, baseURL string) { + // An SSZ list of fixed size containers is the concatenation of the elements. + var body []byte + + for _, msg := range msgs { + b, err := msg.MarshalSSZ() + require.NoError(t, err) + + body = append(body, b...) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set(versionHeader, "gloas") + + resp, err := new(http.Client).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, received, len(msgs)) + + for i, msg := range msgs { + require.Equal(t, eth2spec.DataVersionGloas, received[i].Version) + require.Equal(t, msg, received[i].Gloas) + } + } + + testRawRouter(t, handler, callback) + }) + t.Run("submit_payload_attestations", func(t *testing.T) { msg := testutil.RandomPayloadAttestationMessage() @@ -3156,19 +3339,20 @@ func TestUnmarshalProposerPreferencesSSZ(t *testing.T) { b2, err := pref2.MarshalSSZ() require.NoError(t, err) - prefs, err := unmarshalProposerPreferencesSSZ(append(b1, b2...)) + var prefs apiv1gloas.SignedProposerPreferencesList + + err = unmarshal(contentTypeSSZ, append(b1, b2...), &prefs) require.NoError(t, err) require.Len(t, prefs, 2) require.Equal(t, pref1, prefs[0]) require.Equal(t, pref2, prefs[1]) - // An empty body is a valid empty list. - prefs, err = unmarshalProposerPreferencesSSZ(nil) - require.NoError(t, err) - require.Empty(t, prefs) + // An empty body is rejected like for json. + err = unmarshal(contentTypeSSZ, nil, &prefs) + require.ErrorContains(t, err, "empty request body") - // Invalid lengths are rejected. - _, err = unmarshalProposerPreferencesSSZ(b1[:len(b1)-1]) + // A truncated element is rejected. + err = unmarshal(contentTypeSSZ, b1[:len(b1)-1], &prefs) require.Error(t, err) } diff --git a/go.mod b/go.mod index 81175bb3c..71a066f7a 100644 --- a/go.mod +++ b/go.mod @@ -290,7 +290,7 @@ require ( replace github.com/coinbase/kryptology => github.com/ObolNetwork/kryptology v0.1.0 // We're replacing go-eth2-client with a branch off our fork. The branch is kept up to date with the latest attestantio versions. -replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.29.0-obol.4-gloas //nolint +replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas //nolint tool ( github.com/bufbuild/buf/cmd/buf diff --git a/go.sum b/go.sum index e44882d0f..6b3d96351 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ObolNetwork/go-eth2-client v0.29.0-obol.4-gloas h1:tRWBLhH9wtCkoZ4je3J5sp6xj6OK1MxeevyR3HJfnp8= -github.com/ObolNetwork/go-eth2-client v0.29.0-obol.4-gloas/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= +github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas h1:JGblooTW3gVhM+KiRgyRBG/AagRKJRdbePjspRqpxAQ= +github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI= github.com/ObolNetwork/kryptology v0.1.0/go.mod h1:/Wl7Js2f676GyXZDTaojf/O+l0fxFPWudbyjdFhkpSA= github.com/OffchainLabs/go-bitfield v0.0.0-20251031151322-f427d04d8506 h1:d/SJkN8/9Ca+1YmuDiUJxAiV4w/a9S8NcsG7GMQSrVI=