Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
177 changes: 108 additions & 69 deletions core/validatorapi/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
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
Expand Down Expand Up @@ -213,11 +216,12 @@
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,
},
Expand Down Expand Up @@ -320,18 +324,20 @@
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",
Expand Down Expand Up @@ -387,7 +393,7 @@

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...)
}
Expand Down Expand Up @@ -430,12 +436,13 @@
}

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

Check failure on line 445 in core/validatorapi/router.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ObolNetwork_charon&issues=AaDyi751LEDsUG9PyTUj&open=AaDyi751LEDsUG9PyTUj&pullRequest=4733
wrap := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = log.WithTopic(ctx, "vapi")
Expand Down Expand Up @@ -517,7 +524,7 @@
return
}

writeResponse(ctx, w, endpoint, res, headers)
writeResponse(ctx, w, endpoint, res, negotiateResponseType(r.Header, responseEncodings), headers)
}

return http.HandlerFunc(wrap)
Expand All @@ -526,32 +533,74 @@
// 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)
}
Expand Down Expand Up @@ -1195,7 +1244,9 @@
}
}

// 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"`
Expand All @@ -1204,6 +1255,16 @@
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.
Expand Down Expand Up @@ -1943,16 +2004,31 @@

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) {
Expand All @@ -1974,7 +2050,7 @@
}
}

var msgs []*gloas.PayloadAttestationMessage
var msgs eth2v1gloas.PayloadAttestationMessages

err = unmarshal(typ, body, &msgs)
if err != nil {
Expand Down Expand Up @@ -2045,14 +2121,9 @@
}
}

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")
}
Expand All @@ -2066,38 +2137,6 @@
}
}

// 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.
Expand Down Expand Up @@ -2416,7 +2455,7 @@
err := unmarshaller.UnmarshalSSZ(body)
if err != nil {
return apiError{
StatusCode: http.StatusUnsupportedMediaType,
StatusCode: http.StatusBadRequest,
Message: "failed parsing ssz request body",
Err: err,
}
Expand Down
Loading
Loading