From 1f9c45fe3abf68fba6d7d8a9b8fda5d8bdff7b5b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 10 Jun 2026 15:01:23 +0200 Subject: [PATCH 01/11] engineapi: switch Engine API SSZ to execution-apis#793 Replace the execution-apis#764 surface (version-scoped /engine/vN/... endpoints) with the fork-scoped REST surface of execution-apis#793: POST/GET /engine/v2/{fork}/payloads, POST /engine/v2/{fork}/forkchoice, fork-era-scoped bodies endpoints, POST /engine/v2/blobs/v{1..3}, JSON GET /engine/v2/capabilities + /engine/v2/identity, and RFC 7807 problem+json errors. PayloadStatus.validation_error becomes Optional[String]; expectedBlobVersionedHashes is recomputed from the payload transactions; PayloadAttributes SSZ for Electra/Fulu now uses the Cancun shape instead of falling into the Gloas branch. Closes #21600 --- execution/engineapi/engine_api_methods.go | 20 - execution/engineapi/engine_types/ssz.go | 159 ++--- execution/engineapi/sszrest_handler.go | 600 ++++++++++-------- execution/engineapi/sszrest_test.go | 732 ++++++++++++++++------ execution/engineapi/sszrest_wire.go | 648 ++++++++++++++----- 5 files changed, 1410 insertions(+), 749 deletions(-) diff --git a/execution/engineapi/engine_api_methods.go b/execution/engineapi/engine_api_methods.go index 5f28b66c2d6..2892953a055 100644 --- a/execution/engineapi/engine_api_methods.go +++ b/execution/engineapi/engine_api_methods.go @@ -64,26 +64,6 @@ var ourCapabilities = []string{ "engine_getBlobsV1", "engine_getBlobsV2", "engine_getBlobsV3", - "POST /engine/v1/payloads", - "POST /engine/v2/payloads", - "POST /engine/v3/payloads", - "POST /engine/v4/payloads", - "POST /engine/v5/payloads", - "GET /engine/v1/payloads/{payload_id}", - "GET /engine/v2/payloads/{payload_id}", - "GET /engine/v3/payloads/{payload_id}", - "GET /engine/v4/payloads/{payload_id}", - "GET /engine/v5/payloads/{payload_id}", - "GET /engine/v6/payloads/{payload_id}", - "POST /engine/v1/forkchoice", - "POST /engine/v2/forkchoice", - "POST /engine/v3/forkchoice", - "POST /engine/v4/forkchoice", - "POST /engine/v1/blobs", - "POST /engine/v2/blobs", - "POST /engine/v3/blobs", - "POST /engine/v1/client/version", - "POST /engine/v1/capabilities", } // Returns the most recent version of the payload(for the payloadID) at the time of receiving the call diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go index 19697dc469c..5991945fc45 100644 --- a/execution/engineapi/engine_types/ssz.go +++ b/execution/engineapi/engine_types/ssz.go @@ -4,9 +4,8 @@ package engine_types import ( - "encoding/hex" + "encoding/binary" "fmt" - "strings" "github.com/holiman/uint256" @@ -17,12 +16,12 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/clonable" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/ssz" "github.com/erigontech/erigon/execution/types" ) const ( sszMaxBlobHashes = 4096 - sszMaxGetBlobHashes = 128 sszMaxBytesPerTransaction = 0x40000000 sszMaxValidationError = 1024 sszBlobBytes = 0x20000 @@ -192,6 +191,53 @@ func withdrawalsFromList(l *solid.ListSSZ[*cltypes.Withdrawal]) []*types.Withdra return out } +// optionalSSZString is Optional[String] = List[List[byte, MAX_ERROR_BYTES], 1]. +type optionalSSZString struct { + value *string +} + +func (o *optionalSSZString) Static() bool { return false } + +func (o *optionalSSZString) EncodeSSZ(dst []byte) ([]byte, error) { + if o.value == nil { + return dst, nil + } + msg := []byte(*o.value) + if len(msg) > sszMaxValidationError { + msg = msg[:sszMaxValidationError] + } + dst = append(dst, 4, 0, 0, 0) + return append(dst, msg...), nil +} + +func (o *optionalSSZString) DecodeSSZ(buf []byte, _ int) error { + o.value = nil + if len(buf) == 0 { + return nil + } + if len(buf) < 4 { + return ssz.ErrLowBufferSize + } + if binary.LittleEndian.Uint32(buf) != 4 { + return ssz.ErrBadOffset + } + if len(buf)-4 > sszMaxValidationError { + return ssz.ErrTooBigList + } + msg := string(buf[4:]) + o.value = &msg + return nil +} + +func (o *optionalSSZString) EncodingSizeSSZ() int { + if o.value == nil { + return 0 + } + return 4 + min(len(*o.value), sszMaxValidationError) +} + +func (*optionalSSZString) Clone() clonable.Clonable { return &optionalSSZString{} } + func (s *PayloadStatus) Static() bool { return false } func (s *PayloadStatus) EncodeSSZ(dst []byte) ([]byte, error) { @@ -199,30 +245,23 @@ func (s *PayloadStatus) EncodeSSZ(dst []byte) ([]byte, error) { if err != nil { return nil, err } - var latest []common.Hash - if s.LatestValidHash != nil { - latest = []common.Hash{*s.LatestValidHash} - } latestHashes := solid.NewHashList(1) - for _, hash := range latest { - latestHashes.Append(hash) + if s.LatestValidHash != nil { + latestHashes.Append(*s.LatestValidHash) } - errBytes := solid.NewByteListSSZ(sszMaxValidationError) + validationErr := &optionalSSZString{} if s.ValidationError != nil && s.ValidationError.Error() != nil { - msg := []byte(s.ValidationError.Error().Error()) - if len(msg) > sszMaxValidationError { - msg = msg[:sszMaxValidationError] - } - _ = errBytes.SetBytes(msg) + msg := s.ValidationError.Error().Error() + validationErr.value = &msg } - return ssz2.MarshalSSZ(dst, []byte{status}, latestHashes, errBytes) + return ssz2.MarshalSSZ(dst, []byte{status}, latestHashes, validationErr) } func (s *PayloadStatus) DecodeSSZ(buf []byte, version int) error { latest := solid.NewHashList(1) - errBytes := solid.NewByteListSSZ(sszMaxValidationError) + validationErr := &optionalSSZString{} status := []byte{0} - if err := ssz2.UnmarshalSSZ(buf, version, status, latest, errBytes); err != nil { + if err := ssz2.UnmarshalSSZ(buf, version, status, latest, validationErr); err != nil { return err } engineStatus, err := payloadStatusFromByte(status[0]) @@ -234,8 +273,8 @@ func (s *PayloadStatus) DecodeSSZ(buf []byte, version int) error { hash := latest.Get(0) s.LatestValidHash = &hash } - if msg := string(errBytes.Bytes()); msg != "" { - s.ValidationError = NewStringifiedErrorFromString(msg) + if validationErr.value != nil { + s.ValidationError = NewStringifiedErrorFromString(*validationErr.value) } return nil } @@ -311,7 +350,7 @@ func (a *PayloadAttributes) EncodeSSZ(dst []byte) ([]byte, error) { return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:]) case clparams.CapellaVersion: return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals) - case clparams.DenebVersion: + case clparams.DenebVersion, clparams.ElectraVersion, clparams.FuluVersion: return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:]) default: // GloasVersion+ return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:], slot, targetGasLimit) @@ -334,7 +373,7 @@ func (a *PayloadAttributes) DecodeSSZ(buf []byte, version int) error { return err } a.Withdrawals = withdrawalsFromList(withdrawals) - case clparams.DenebVersion: + case clparams.DenebVersion, clparams.ElectraVersion, clparams.FuluVersion: if err := ssz2.UnmarshalSSZ(buf, version, ×tamp, a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:]); err != nil { return err } @@ -369,44 +408,6 @@ func (a *PayloadAttributes) payloadAttributesSSZVersion() clparams.StateVersion return clparams.BellatrixVersion } -func (v *ClientVersionV1) Static() bool { return false } - -func (v *ClientVersionV1) EncodeSSZ(dst []byte) ([]byte, error) { - code := solid.NewByteListSSZ(2) - name := solid.NewByteListSSZ(64) - version := solid.NewByteListSSZ(64) - _ = code.SetBytes([]byte(v.Code)) - _ = name.SetBytes([]byte(v.Name)) - _ = version.SetBytes([]byte(v.Version)) - var commit [4]byte - ch := strings.TrimPrefix(v.Commit, "0x") - if b, err := hex.DecodeString(ch); err == nil && len(b) >= 4 { - copy(commit[:], b[:4]) - } - return ssz2.MarshalSSZ(dst, code, name, version, commit[:]) -} - -func (v *ClientVersionV1) DecodeSSZ(buf []byte, version int) error { - code := solid.NewByteListSSZ(2) - name := solid.NewByteListSSZ(64) - ver := solid.NewByteListSSZ(64) - var commit [4]byte - if err := ssz2.UnmarshalSSZ(buf, version, code, name, ver, commit[:]); err != nil { - return err - } - v.Code = string(code.Bytes()) - v.Name = string(name.Bytes()) - v.Version = string(ver.Bytes()) - v.Commit = "0x" + hex.EncodeToString(commit[:]) - return nil -} - -func (v *ClientVersionV1) EncodingSizeSSZ() int { out, _ := v.EncodeSSZ(nil); return len(out) } -func (v *ClientVersionV1) HashSSZ() ([32]byte, error) { - return [32]byte{}, nil -} -func (*ClientVersionV1) Clone() clonable.Clonable { return &ClientVersionV1{} } - func newKZGCommitment(in []byte) *cltypes.KZGCommitment { var out cltypes.KZGCommitment copy(out[:], in) @@ -550,41 +551,3 @@ func (b *BlobAndProofV2) DecodeSSZ(buf []byte, version int) error { func (b *BlobAndProofV2) EncodingSizeSSZ() int { out, _ := b.EncodeSSZ(nil); return len(out) } func (b *BlobAndProofV2) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } func (*BlobAndProofV2) Clone() clonable.Clonable { return &BlobAndProofV2{} } - -type NullableBlobAndProofV2 struct { - BlobAndProof *BlobAndProofV2 -} - -func NewNullableBlobAndProofV2(blob *BlobAndProofV2) *NullableBlobAndProofV2 { - return &NullableBlobAndProofV2{BlobAndProof: blob} -} - -func (n *NullableBlobAndProofV2) Static() bool { return false } - -func (n *NullableBlobAndProofV2) EncodeSSZ(dst []byte) ([]byte, error) { - blobAndProof := solid.NewDynamicListSSZ[*BlobAndProofV2](1) - if n != nil && n.BlobAndProof != nil { - blobAndProof.Append(n.BlobAndProof) - } - return ssz2.MarshalSSZ(dst, blobAndProof) -} - -func (n *NullableBlobAndProofV2) DecodeSSZ(buf []byte, version int) error { - blobAndProof := solid.NewDynamicListSSZ[*BlobAndProofV2](1) - if err := ssz2.UnmarshalSSZ(buf, version, blobAndProof); err != nil { - return err - } - n.BlobAndProof = nil - if blobAndProof.Len() != 0 { - n.BlobAndProof = blobAndProof.Get(0) - } - return nil -} - -func (n *NullableBlobAndProofV2) EncodingSizeSSZ() int { out, _ := n.EncodeSSZ(nil); return len(out) } -func (n *NullableBlobAndProofV2) HashSSZ() ([32]byte, error) { - return [32]byte{}, nil -} -func (*NullableBlobAndProofV2) Clone() clonable.Clonable { - return &NullableBlobAndProofV2{} -} diff --git a/execution/engineapi/sszrest_handler.go b/execution/engineapi/sszrest_handler.go index 7b6ce3792a4..8fe83d907c9 100644 --- a/execution/engineapi/sszrest_handler.go +++ b/execution/engineapi/sszrest_handler.go @@ -1,27 +1,52 @@ // Copyright 2026 The Erigon Authors // This file is part of Erigon. +// HTTP routing for the Engine API v2 REST transport +// (https://github.com/ethereum/execution-apis/pull/793). + package engineapi import ( - "context" "encoding/hex" + "encoding/json" "errors" + "fmt" "io" + "mime" "net/http" "strconv" "strings" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes/solid" - "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/txnprovider/txpool" +) + +const ( + sszRestContentType = "application/octet-stream" + jsonContentType = "application/json" + problemContentType = "application/problem+json" ) -const sszRestContentType = "application/octet-stream" +// RFC 7807 problem types, written as relative URIs per the spec. +const ( + problemInvalidRequest = "/engine-api/errors/invalid-request" + problemSSZDecodeError = "/engine-api/errors/ssz-decode-error" + problemUnsupportedFork = "/engine-api/errors/unsupported-fork" + problemMethodNotFound = "/engine-api/errors/method-not-found" + problemUnknownPayload = "/engine-api/errors/unknown-payload" + problemInvalidForkchoice = "/engine-api/errors/invalid-forkchoice" + problemReorgTooDeep = "/engine-api/errors/reorg-too-deep" + problemRequestTooLarge = "/engine-api/errors/request-too-large" + problemUnsupportedMediaType = "/engine-api/errors/unsupported-media-type" + problemInvalidBody = "/engine-api/errors/invalid-body" + problemInvalidAttributes = "/engine-api/errors/invalid-attributes" + problemInternal = "/engine-api/errors/internal" +) func (e *EngineServer) SSZRESTHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -30,90 +55,120 @@ func (e *EngineServer) SSZRESTHandler() http.Handler { } func (e *EngineServer) handleSSZREST(w http.ResponseWriter, r *http.Request) { - path := strings.Trim(r.URL.Path, "/") - parts := strings.Split(path, "/") - if len(parts) < 3 || parts[0] != "engine" || !strings.HasPrefix(parts[1], "v") { - http.NotFound(w, nil) + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + // trailing slashes are forbidden, so an empty last segment is method-not-found + if len(parts) < 3 || parts[0] != "engine" || parts[1] != "v2" || parts[len(parts)-1] == "" { + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") return } - version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v")) - if err != nil { - writeSSZError(w, http.StatusNotFound, "invalid engine version") + rest := parts[2:] + switch { + case rest[0] == "capabilities": + if len(rest) == 1 && r.Method == http.MethodGet { + e.handleSSZCapabilities(w) + return + } + case rest[0] == "identity": + if len(rest) == 1 && r.Method == http.MethodGet { + e.handleSSZIdentity(w, r) + return + } + case rest[0] == "blobs": + if len(rest) == 2 && r.Method == http.MethodPost { + e.handleSSZGetBlobs(w, r, rest[1]) + return + } + default: + e.handleSSZForkScoped(w, r, rest) return } + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") +} +func (e *EngineServer) handleSSZForkScoped(w http.ResponseWriter, r *http.Request, rest []string) { + forkName := rest[0] + version, ok := engineForkVersion(forkName) + if !ok || !forkScheduled(e.config, forkName) { + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, fmt.Sprintf("unsupported fork %q", forkName)) + return + } + sub := rest[1:] switch { - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "payloads": - e.handleSSZNewPayload(w, r, version) - case r.Method == http.MethodGet && len(parts) == 4 && parts[2] == "payloads": - e.handleSSZGetPayload(w, r, version, parts[3]) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "forkchoice": - e.handleSSZForkchoice(w, r, version) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "blobs": - e.handleSSZGetBlobs(w, r, version) - case r.Method == http.MethodPost && len(parts) == 4 && parts[2] == "client" && parts[3] == "version" && version == 1: - e.handleSSZClientVersion(w, r) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "capabilities" && version == 1: - e.handleSSZCapabilities(w, r) + case len(sub) == 1 && sub[0] == "payloads" && r.Method == http.MethodPost: + e.handleSSZNewPayload(w, r, forkName, version) + case len(sub) == 2 && sub[0] == "payloads" && r.Method == http.MethodGet: + e.handleSSZGetPayload(w, r, forkName, version, sub[1]) + case len(sub) == 1 && sub[0] == "forkchoice" && r.Method == http.MethodPost: + e.handleSSZForkchoice(w, r, forkName, version) + case len(sub) == 2 && sub[0] == "bodies" && sub[1] == "hash" && r.Method == http.MethodPost: + e.handleSSZBodiesByHash(w, r, forkName, version) + case len(sub) == 1 && sub[0] == "bodies" && r.Method == http.MethodGet: + e.handleSSZBodiesByRange(w, r, forkName, version) default: - writeSSZError(w, http.StatusNotFound, "unknown SSZ-REST Engine API endpoint") + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") } } -func sszNewPayloadVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.ElectraVersion, true - case 5: - return clparams.GloasVersion, true - default: - return 0, false - } +func writeProblem(w http.ResponseWriter, status int, problemType, detail string) { + w.Header().Set("Content-Type", problemContentType) + w.WriteHeader(status) + problem := struct { + Type string `json:"type"` + Detail string `json:"detail,omitempty"` + }{Type: problemType, Detail: detail} + _ = json.NewEncoder(w).Encode(problem) } -func sszGetPayloadVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.ElectraVersion, true - case 5: - return clparams.FuluVersion, true - case 6: - return clparams.GloasVersion, true - default: - return 0, false +func writeEngineProblem(w http.ResponseWriter, err error) { + if err == nil { + return } + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + switch rpcErr.ErrorCode() { + case engine_helpers.UnknownPayloadErr.Code: + writeProblem(w, http.StatusNotFound, problemUnknownPayload, err.Error()) + return + case engine_helpers.InvalidForkchoiceStateErr.Code: + writeProblem(w, http.StatusConflict, problemInvalidForkchoice, err.Error()) + return + case engine_helpers.InvalidPayloadAttributesErr.Code: + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidAttributes, err.Error()) + return + case engine_helpers.TooLargeRequestErr.Code: + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + return + case engine_helpers.ReorgTooDeepErr.Code: + writeProblem(w, http.StatusConflict, problemReorgTooDeep, err.Error()) + return + case (&rpc.UnsupportedForkError{}).ErrorCode(): + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, err.Error()) + return + case (&rpc.InvalidParamsError{}).ErrorCode(): + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidBody, err.Error()) + return + } + } + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) } -func sszForkchoiceVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.GloasVersion, true - default: - return 0, false +func checkSSZContentType(w http.ResponseWriter, r *http.Request) bool { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != sszRestContentType { + writeProblem(w, http.StatusUnsupportedMediaType, problemUnsupportedMediaType, "want "+sszRestContentType) + return false } + return true } -func readSSZBody(r *http.Request) ([]byte, error) { +func readSSZBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { defer r.Body.Close() - return io.ReadAll(http.MaxBytesReader(nil, r.Body, 128<<20)) + body, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, sszMaxRequestBody)) + if err != nil { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + return nil, false + } + return body, true } func writeSSZ(w http.ResponseWriter, obj interface { @@ -121,11 +176,10 @@ func writeSSZ(w http.ResponseWriter, obj interface { }) { out, err := obj.EncodeSSZ(nil) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - w.Header().Set("Content-Type", sszRestContentType) - _, _ = w.Write(out) + writeSSZBytes(w, out) } func writeSSZBytes(w http.ResponseWriter, out []byte) { @@ -133,123 +187,69 @@ func writeSSZBytes(w http.ResponseWriter, out []byte) { _, _ = w.Write(out) } -func writeSSZError(w http.ResponseWriter, code int, msg string) { - http.Error(w, msg, code) -} - -func writeEngineError(w http.ResponseWriter, err error) { - if err == nil { - return - } - var rpcErr rpc.Error - if errors.As(err, &rpcErr) { - switch rpcErr.ErrorCode() { - case engine_helpers.UnknownPayloadErr.Code: - writeSSZError(w, http.StatusNotFound, err.Error()) - return - case engine_helpers.InvalidForkchoiceStateErr.Code: - writeSSZError(w, http.StatusConflict, err.Error()) - return - case engine_helpers.InvalidPayloadAttributesErr.Code: - writeSSZError(w, http.StatusUnprocessableEntity, err.Error()) - return - case engine_helpers.TooLargeRequestErr.Code: - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) - return - } - } - writeSSZError(w, http.StatusInternalServerError, err.Error()) +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", jsonContentType) + _ = json.NewEncoder(w).Encode(v) } -func (e *EngineServer) handleSSZNewPayload(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 5 { - writeSSZError(w, http.StatusNotFound, "unsupported new payload version") +func (e *EngineServer) handleSSZNewPayload(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - sv, _ := sszNewPayloadVersion(version) - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - payload, blobHashes, parentRoot, executionRequests, err := decodeNewPayloadRequest(body, sv) + payload, parentRoot, executionRequests, err := decodeNewPayloadEnvelope(body, version) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } var status *engine_types.PayloadStatus switch version { - case 1: + case clparams.BellatrixVersion: status, err = e.NewPayloadV1(r.Context(), payload) - case 2: + case clparams.CapellaVersion: status, err = e.NewPayloadV2(r.Context(), payload) - case 3: - status, err = e.NewPayloadV3(r.Context(), payload, hashListValues(blobHashes), &parentRoot) - case 4: - status, err = e.NewPayloadV4(r.Context(), payload, hashListValues(blobHashes), &parentRoot, transactionsBytes(executionRequests)) - case 5: - status, err = e.NewPayloadV5(r.Context(), payload, hashListValues(blobHashes), &parentRoot, transactionsBytes(executionRequests)) + case clparams.DenebVersion: + status, err = e.NewPayloadV3(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot) + case clparams.ElectraVersion, clparams.FuluVersion: + status, err = e.NewPayloadV4(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot, executionRequests) + default: + status, err = e.NewPayloadV5(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot, executionRequests) } if err != nil { - writeEngineError(w, err) + writeEngineProblem(w, err) return } - e.logger.Info("[SSZ-REST] handled new payload", "path", r.URL.Path) + e.logger.Debug("[SSZ-REST] handled new payload", "fork", forkName) writeSSZ(w, status) } -func (e *EngineServer) handleSSZGetPayload(w http.ResponseWriter, r *http.Request, version int, payloadID string) { - if version < 1 || version > 6 { - writeSSZError(w, http.StatusNotFound, "unsupported get payload version") +func (e *EngineServer) handleSSZGetPayload(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion, payloadID string) { + id, err := parsePayloadIDPath(payloadID) + if err != nil { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) return } - id, err := parsePayloadIDPath(payloadID) + decodedID, err := decodePayloadID(id) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) return } - switch version { - case 1: - resp, err := e.GetPayloadV1(r.Context(), id) - if err != nil { - writeEngineError(w, err) - return - } - resp.SSZVersion = clparams.BellatrixVersion - e.logger.Info("[SSZ-REST] handled get payload", "path", r.URL.Path) - writeSSZ(w, resp) - default: - resp, err := callGetPayload(r.Context(), e, version, id) - if err != nil { - writeEngineError(w, err) - return - } - sv, _ := sszGetPayloadVersion(version) - out, err := encodeGetPayloadResponse(resp, sv) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return - } - e.logger.Info("[SSZ-REST] handled get payload", "path", r.URL.Path) - writeSSZBytes(w, out) + resp, err := e.getPayload(r.Context(), decodedID, version) + if err != nil { + writeEngineProblem(w, err) + return } -} - -func callGetPayload(ctx context.Context, e *EngineServer, version int, id hexutil.Bytes) (*engine_types.GetPayloadResponse, error) { - switch version { - case 2: - return e.GetPayloadV2(ctx, id) - case 3: - return e.GetPayloadV3(ctx, id) - case 4: - return e.GetPayloadV4(ctx, id) - case 5: - return e.GetPayloadV5(ctx, id) - case 6: - return e.GetPayloadV6(ctx, id) - default: - return nil, &rpc.UnsupportedForkError{Message: "unsupported get payload version"} + out, err := encodeBuiltPayload(resp, version) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return } + e.logger.Debug("[SSZ-REST] handled get payload", "fork", forkName, "payloadId", decodedID) + w.Header().Set("Cache-Control", "no-store") + writeSSZBytes(w, out) } func parsePayloadIDPath(s string) (hexutil.Bytes, error) { @@ -264,157 +264,239 @@ func parsePayloadIDPath(s string) (hexutil.Bytes, error) { return hexutil.Bytes(b), nil } -func (e *EngineServer) handleSSZForkchoice(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 4 { - writeSSZError(w, http.StatusNotFound, "unsupported forkchoice version") +func (e *EngineServer) handleSSZForkchoice(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - sv, _ := sszForkchoiceVersion(version) - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - state, attrs, err := decodeForkchoiceRequest(body, sv) + state, attrs, custodyColumns, err := decodeForkchoiceUpdate(body, version) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - var resp *engine_types.ForkChoiceUpdatedResponse - switch version { - case 1: - resp, err = e.ForkchoiceUpdatedV1(r.Context(), &state, attrs) - case 2: - resp, err = e.ForkchoiceUpdatedV2(r.Context(), &state, attrs) - case 3: - resp, err = e.ForkchoiceUpdatedV3(r.Context(), &state, attrs) - case 4: - resp, err = e.ForkchoiceUpdatedV4(r.Context(), &state, attrs) + if custodyColumns != nil { + e.logger.Debug("[SSZ-REST] ignoring custody columns update", "fork", forkName) } + if attrs != nil { + // the URL fork is load-bearing for payload builds only: it must match + // the fork the new payload would belong to + if attrsFork := forkNameAtTime(e.config, uint64(attrs.Timestamp)); attrsFork != forkName { + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, + fmt.Sprintf("payload attributes timestamp belongs to fork %q, not URL fork %q", attrsFork, forkName)) + return + } + } + resp, err := e.forkchoiceUpdated(r.Context(), &state, attrs, version) if err != nil { - writeEngineError(w, err) + writeEngineProblem(w, err) return } out, err := encodeForkchoiceResponse(resp) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - e.logger.Info("[SSZ-REST] handled forkchoice", "path", r.URL.Path) + e.logger.Debug("[SSZ-REST] handled forkchoice", "fork", forkName) writeSSZBytes(w, out) } -func (e *EngineServer) handleSSZGetBlobs(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 3 { - writeSSZError(w, http.StatusNotFound, "unsupported get blobs version") +func (e *EngineServer) handleSSZBodiesByHash(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - hashes := solid.NewHashList(sszMaxGetBlobHashes) + if len(body) > sszMaxBodiesRequest*32 { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many block hashes") + return + } + hashes := solid.NewHashList(sszMaxBodiesRequest) if err := hashes.DecodeSSZ(body, 0); err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - if hashes.Length() > sszMaxGetBlobHashes { - writeSSZError(w, http.StatusRequestEntityTooLarge, "too many blob hashes") + blockHashes := hashListValues(hashes) + bodies, err := e.chainRW.GetPayloadBodiesByHash(r.Context(), blockHashes) + if err != nil { + writeEngineProblem(w, err) return } - switch version { - case 1: - resp, err := e.GetBlobsV1(r.Context(), hashListValues(hashes)) + // blocks outside the URL fork's time range come back as available=false + entries := make([]*engine_types.ExecutionPayloadBodyV2, len(blockHashes)) + for i := range blockHashes { + if i >= len(bodies) || bodies[i] == nil { + continue + } + header := e.chainRW.GetHeaderByHash(r.Context(), blockHashes[i]) + if header == nil || forkNameAtTime(e.config, header.Time) != forkName { + continue + } + entries[i] = bodies[i] + } + out, err := encodeBodiesResponse(entries, version) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return + } + e.logger.Debug("[SSZ-REST] handled bodies by hash", "fork", forkName, "count", len(blockHashes)) + writeSSZBytes(w, out) +} + +func (e *EngineServer) handleSSZBodiesByRange(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + query := r.URL.Query() + from, errFrom := strconv.ParseUint(query.Get("from"), 10, 64) + count, errCount := strconv.ParseUint(query.Get("count"), 10, 64) + if errFrom != nil || errCount != nil { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, "from and count query parameters are required") + return + } + if count > sszMaxBodiesRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, + fmt.Sprintf("count %d exceeds the limit of %d", count, sszMaxBodiesRequest)) + return + } + if from == 0 || count == 0 { + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidBody, + fmt.Sprintf("invalid from or count, from: %d count: %d", from, count)) + return + } + bodies, err := e.chainRW.GetPayloadBodiesByRange(r.Context(), from, count) + if err != nil { + writeEngineProblem(w, err) + return + } + // blocks outside the URL fork's time range or past the latest known block + // come back as available=false + entries := make([]*engine_types.ExecutionPayloadBodyV2, count) + for i := uint64(0); i < count; i++ { + if i >= uint64(len(bodies)) || bodies[i] == nil { + continue + } + header := e.chainRW.GetHeaderByNumber(r.Context(), from+i) + if header == nil || forkNameAtTime(e.config, header.Time) != forkName { + continue + } + entries[i] = bodies[i] + } + out, err := encodeBodiesResponse(entries, version) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return + } + e.logger.Debug("[SSZ-REST] handled bodies by range", "fork", forkName, "from", from, "count", count) + writeSSZBytes(w, out) +} + +func (e *EngineServer) handleSSZGetBlobs(w http.ResponseWriter, r *http.Request, revision string) { + if revision != "v1" && revision != "v2" && revision != "v3" { + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") + return + } + if !checkSSZContentType(w, r) { + return + } + body, ok := readSSZBody(w, r) + if !ok { + return + } + if len(body) > sszMaxGetBlobHashes*32 { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many blob hashes") + return + } + hashes := solid.NewHashList(sszMaxGetBlobHashes) + if err := hashes.DecodeSSZ(body, 0); err != nil { + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") + return + } + blobHashes := hashListValues(hashes) + + var out []byte + switch revision { + case "v1": + resp, err := e.GetBlobsV1(r.Context(), blobHashes) if err != nil { - writeEngineError(w, err) + writeBlobsProblem(w, err) return } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV1Response(resp) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + if resp == nil { + w.WriteHeader(http.StatusNoContent) return } - writeSSZBytes(w, out) - return - case 2: - resp, err := e.GetBlobsV2(r.Context(), hashListValues(hashes)) + out, err = encodeBlobsV1Response(resp) if err != nil { - writeEngineError(w, err) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV2Response(resp) + case "v2", "v3": + var resp engine_types.BlobsBundleV2 + var err error + if revision == "v2" { + resp, err = e.GetBlobsV2(r.Context(), blobHashes) + } else { + resp, err = e.GetBlobsV3(r.Context(), blobHashes) + } if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeBlobsProblem(w, err) return } - writeSSZBytes(w, out) - return - case 3: - resp, err := e.GetBlobsV3(r.Context(), hashListValues(hashes)) - if err != nil { - writeEngineError(w, err) + // nil signals "cannot serve" (and the all-or-nothing miss on v2) + if resp == nil { + w.WriteHeader(http.StatusNoContent) return } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV3Response(resp) + out, err = encodeBlobsV2Response(resp) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - writeSSZBytes(w, out) - return } + e.logger.Debug("[SSZ-REST] handled get blobs", "revision", revision, "count", len(blobHashes)) + writeSSZBytes(w, out) } -func (e *EngineServer) handleSSZClientVersion(w http.ResponseWriter, r *http.Request) { - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) +func writeBlobsProblem(w http.ResponseWriter, err error) { + if errors.Is(err, txpool.ErrPoolDisabled) { + w.WriteHeader(http.StatusNoContent) return } - clientVersion, err := decodeClientVersionRequest(body) - if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) - return - } - resp, err := e.GetClientVersionV1(r.Context(), clientVersion) - if err != nil { - writeEngineError(w, err) - return - } - e.logger.Info("[SSZ-REST] handled client version", "path", r.URL.Path) - out, err := encodeClientVersionResponse(resp) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return - } - writeSSZBytes(w, out) + writeEngineProblem(w, err) } -func ptr[T any](v T) *T { return &v } +type sszRestCapabilities struct { + SupportedForks []string `json:"supported_forks"` + ForkScopedEndpoints []string `json:"fork_scoped_endpoints"` + IndependentlyVersioned map[string][]string `json:"independently_versioned"` + UnscopedEndpoints []string `json:"unscoped_endpoints"` + Limits map[string]uint64 `json:"limits"` +} -func (e *EngineServer) handleSSZCapabilities(w http.ResponseWriter, r *http.Request) { - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) - return - } - capabilities, err := decodeCapabilities(body, 0) - if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) - return +func (e *EngineServer) handleSSZCapabilities(w http.ResponseWriter) { + writeJSON(w, sszRestCapabilities{ + SupportedForks: supportedForkNames(e.config), + ForkScopedEndpoints: []string{"payloads", "forkchoice", "bodies"}, + IndependentlyVersioned: map[string][]string{"blobs": {"v1", "v2", "v3"}}, + UnscopedEndpoints: []string{"capabilities", "identity"}, + Limits: map[string]uint64{ + "bodies.max_count": sszMaxBodiesRequest, + "blobs.max_versioned_hashes": sszMaxGetBlobHashes, + "payload.max_bytes": sszMaxRequestBody, + }, + }) +} + +func (e *EngineServer) handleSSZIdentity(w http.ResponseWriter, r *http.Request) { + if clientVersion := r.Header.Get("X-Engine-Client-Version"); clientVersion != "" { + e.logger.Debug("[SSZ-REST] client version", "version", clientVersion) } - resp := e.ExchangeCapabilities(capabilities) - e.logger.Info("[SSZ-REST] handled capabilities", "path", r.URL.Path) - out, err := encodeCapabilities(resp) + versions, err := e.GetClientVersionV1(r.Context(), nil) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeEngineProblem(w, err) return } - writeSSZBytes(w, out) + writeJSON(w, versions) } - -func hashesToCommon(in []common.Hash) []common.Hash { return in } diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index e58154ebc97..bfb3175f4e5 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -5,32 +5,259 @@ package engineapi import ( "bytes" + "encoding/binary" + "encoding/json" "errors" "net/http" "net/http/httptest" - "strings" "testing" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/erigontech/erigon/cl/clparams" - "github.com/erigontech/erigon/cl/cltypes/solid" - ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc" ) -func TestSSZRESTCapabilitiesCodecRoundTrip(t *testing.T) { - enc, err := encodeCapabilities([]string{"engine_newPayloadV1", "POST /engine/v1/payloads"}) - require.NoError(t, err) +func allForksConfig() *chain.Config { + return &chain.Config{ + ShanghaiTime: common.NewUint64(100), + CancunTime: common.NewUint64(200), + PragueTime: common.NewUint64(300), + OsakaTime: common.NewUint64(400), + AmsterdamTime: common.NewUint64(500), + } +} + +func newTestEngineServer(cfg *chain.Config, consuming bool) *EngineServer { + return NewEngineServer(log.New(), cfg, nil, nil, false, true, false, consuming, nil, nil, 0, 0) +} + +func newSSZRequest(method, path string, body []byte) *http.Request { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", sszRestContentType) + return req +} + +func problemType(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + require.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + var problem struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &problem)) + return problem.Type +} + +func TestSSZRESTForkRegistry(t *testing.T) { + for name, want := range map[string]clparams.StateVersion{ + "paris": clparams.BellatrixVersion, + "shanghai": clparams.CapellaVersion, + "cancun": clparams.DenebVersion, + "prague": clparams.ElectraVersion, + "osaka": clparams.FuluVersion, + "amsterdam": clparams.GloasVersion, + } { + got, ok := engineForkVersion(name) + require.True(t, ok, name) + require.Equal(t, want, got, name) + } + _, ok := engineForkVersion("bellatrix") + require.False(t, ok) +} + +func TestSSZRESTForkNameAtTime(t *testing.T) { + cfg := allForksConfig() + for ts, want := range map[uint64]string{ + 0: "paris", + 99: "paris", + 100: "shanghai", + 250: "cancun", + 350: "prague", + 450: "osaka", + 500: "amsterdam", + 999: "amsterdam", + } { + require.Equal(t, want, forkNameAtTime(cfg, ts), "ts=%d", ts) + } +} + +func TestSSZRESTSupportedForkNames(t *testing.T) { + require.Equal(t, []string{"paris"}, supportedForkNames(&chain.Config{})) + require.Equal(t, + []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"}, + supportedForkNames(allForksConfig())) + require.Equal(t, + []string{"paris", "shanghai", "cancun"}, + supportedForkNames(&chain.Config{ShanghaiTime: common.NewUint64(0), CancunTime: common.NewUint64(0)})) +} + +func TestSSZRESTNewPayloadEnvelopeRoundTrip(t *testing.T) { + for _, version := range []clparams.StateVersion{ + clparams.BellatrixVersion, + clparams.CapellaVersion, + clparams.DenebVersion, + clparams.ElectraVersion, + clparams.FuluVersion, + clparams.GloasVersion, + } { + t.Run(version.String(), func(t *testing.T) { + payload := engine_types.NewExecutionPayloadSSZ(version) + if version >= clparams.GloasVersion { + slot := hexutil.Uint64(123) + payload.SlotNumber = &slot + payload.BlockAccessList = hexutil.Bytes{0x01, 0x02, 0x03} + } + root := common.HexToHash("0xaa") + requests := []hexutil.Bytes{{0x00, 0x01}, {0x01, 0x02}} + + enc, err := encodeNewPayloadEnvelope(version, payload, root, requests) + require.NoError(t, err) + + outPayload, outRoot, outRequests, err := decodeNewPayloadEnvelope(enc, version) + require.NoError(t, err) + + payloadBytes, err := payload.EncodeSSZ(nil) + require.NoError(t, err) + switch { + case version < clparams.DenebVersion: + // {payload} — just an offset plus the payload bytes + require.Len(t, enc, 4+len(payloadBytes)) + require.Equal(t, common.Hash{}, outRoot) + require.Empty(t, outRequests) + case version < clparams.ElectraVersion: + // {payload, parent_beacon_block_root} + require.Len(t, enc, 4+32+len(payloadBytes)) + require.Equal(t, root, outRoot) + require.Empty(t, outRequests) + default: + // {payload, parent_beacon_block_root, execution_requests} + require.Equal(t, root, outRoot) + require.Equal(t, requests, outRequests) + } + if version >= clparams.GloasVersion { + require.NotNil(t, outPayload.SlotNumber) + require.Equal(t, hexutil.Uint64(123), *outPayload.SlotNumber) + require.Equal(t, hexutil.Bytes{0x01, 0x02, 0x03}, outPayload.BlockAccessList) + } + require.Equal(t, payload.BlockHash, outPayload.BlockHash) + }) + } +} - out, err := decodeCapabilities(enc, 0) +func TestSSZRESTBlobVersionedHashesFromTxs(t *testing.T) { + hashes := blobVersionedHashesFromTxs(nil) + require.NotNil(t, hashes) + require.Empty(t, hashes) + + // undecodable transactions yield an empty (non-nil) list and no panic; + // newPayload re-decodes and reports INVALID itself + hashes = blobVersionedHashesFromTxs([]hexutil.Bytes{{0xff, 0xfe}}) + require.NotNil(t, hashes) + require.Empty(t, hashes) +} + +func TestSSZRESTForkchoiceUpdateRoundTrip(t *testing.T) { + state := engine_types.ForkChoiceState{ + HeadHash: common.HexToHash("0x01"), + SafeBlockHash: common.HexToHash("0x02"), + FinalizedBlockHash: common.HexToHash("0x03"), + } + + t.Run("paris no attributes", func(t *testing.T) { + enc, err := encodeForkchoiceUpdate(clparams.BellatrixVersion, &state, nil, nil) + require.NoError(t, err) + outState, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.BellatrixVersion) + require.NoError(t, err) + require.Equal(t, state, outState) + require.Nil(t, outAttrs) + require.Nil(t, outCustody) + }) + + t.Run("prague attributes use the cancun schema", func(t *testing.T) { + root := common.HexToHash("0x04") + attrs := &engine_types.PayloadAttributes{ + Timestamp: 350, + PrevRandao: common.HexToHash("0x05"), + SuggestedFeeRecipient: common.HexToAddress("0x06"), + Withdrawals: []*types.Withdrawal{{Index: 1, Validator: 2, Address: common.HexToAddress("0x07"), Amount: 3}}, + ParentBeaconBlockRoot: &root, + } + attrs.SSZVersion = clparams.ElectraVersion + // timestamp(8) + prev_randao(32) + fee_recipient(20) + withdrawals offset(4) + root(32) + one withdrawal(44) + attrBytes, err := attrs.EncodeSSZ(nil) + require.NoError(t, err) + require.Len(t, attrBytes, 140) + + enc, err := encodeForkchoiceUpdate(clparams.ElectraVersion, &state, attrs, nil) + require.NoError(t, err) + _, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.ElectraVersion) + require.NoError(t, err) + require.Nil(t, outCustody) + require.NotNil(t, outAttrs) + require.NotNil(t, outAttrs.ParentBeaconBlockRoot) + require.Equal(t, root, *outAttrs.ParentBeaconBlockRoot) + require.Nil(t, outAttrs.SlotNumber) + require.Nil(t, outAttrs.TargetGasLimit) + require.Len(t, outAttrs.Withdrawals, 1) + }) + + t.Run("amsterdam attributes and custody columns", func(t *testing.T) { + root := common.HexToHash("0x04") + slot := hexutil.Uint64(456) + targetGasLimit := hexutil.Uint64(36000000) + attrs := &engine_types.PayloadAttributes{ + Timestamp: 500, + SuggestedFeeRecipient: common.HexToAddress("0x06"), + ParentBeaconBlockRoot: &root, + SlotNumber: &slot, + TargetGasLimit: &targetGasLimit, + } + attrs.SSZVersion = clparams.GloasVersion + custody := bytes.Repeat([]byte{0xab}, 16) + + enc, err := encodeForkchoiceUpdate(clparams.GloasVersion, &state, attrs, custody) + require.NoError(t, err) + outState, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.GloasVersion) + require.NoError(t, err) + require.Equal(t, state, outState) + require.NotNil(t, outAttrs) + require.NotNil(t, outAttrs.SlotNumber) + require.Equal(t, slot, *outAttrs.SlotNumber) + require.NotNil(t, outAttrs.TargetGasLimit) + require.Equal(t, targetGasLimit, *outAttrs.TargetGasLimit) + require.Equal(t, custody, outCustody) + }) + + t.Run("amsterdam without custody columns", func(t *testing.T) { + enc, err := encodeForkchoiceUpdate(clparams.GloasVersion, &state, nil, nil) + require.NoError(t, err) + _, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.GloasVersion) + require.NoError(t, err) + require.Nil(t, outAttrs) + require.Nil(t, outCustody) + }) +} + +// Pin the exact wire example from execution-apis#793: a VALID PayloadStatus +// with latest_valid_hash present and no validation_error is 41 bytes. +func TestSSZRESTPayloadStatusSpecExample(t *testing.T) { + latest := common.HexToHash("0xcd") + status := &engine_types.PayloadStatus{Status: engine_types.ValidStatus, LatestValidHash: &latest} + enc, err := status.EncodeSSZ(nil) require.NoError(t, err) - require.Equal(t, []string{"engine_newPayloadV1", "POST /engine/v1/payloads"}, out) + require.Len(t, enc, 41) + require.Equal(t, byte(0), enc[0]) + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(enc[1:5])) + require.Equal(t, uint32(41), binary.LittleEndian.Uint32(enc[5:9])) + require.Equal(t, latest[:], enc[9:41]) } func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { @@ -42,6 +269,9 @@ func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { } enc, err := wire.EncodeSSZ(nil) require.NoError(t, err) + // validation_error is Optional[String] = List[List[byte, 1024], 1]: + // present means a 4-byte inner offset precedes the message bytes + require.Equal(t, append([]byte{4, 0, 0, 0}, []byte("no")...), enc[41:]) var out engine_types.PayloadStatus require.NoError(t, out.DecodeSSZ(enc, 0)) @@ -49,50 +279,149 @@ func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { require.NotNil(t, out.LatestValidHash) require.Equal(t, latest, *out.LatestValidHash) require.Equal(t, "no", out.ValidationError.Error().Error()) + + // absent validation_error stays distinguishable from an empty message + noErr := &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + enc, err = noErr.EncodeSSZ(nil) + require.NoError(t, err) + var out2 engine_types.PayloadStatus + require.NoError(t, out2.DecodeSSZ(enc, 0)) + require.Equal(t, engine_types.SyncingStatus, out2.Status) + require.Nil(t, out2.LatestValidHash) + require.Nil(t, out2.ValidationError) } -func TestSSZRESTRequestCodecsRoundTrip(t *testing.T) { +func TestSSZRESTForkchoiceResponseRoundTrip(t *testing.T) { + latest := common.HexToHash("0x11") + payloadID := hexutil.Bytes{1, 2, 3, 4, 5, 6, 7, 8} + resp := &engine_types.ForkChoiceUpdatedResponse{ + PayloadStatus: &engine_types.PayloadStatus{Status: engine_types.ValidStatus, LatestValidHash: &latest}, + PayloadId: &payloadID, + } + enc, err := encodeForkchoiceResponse(resp) + require.NoError(t, err) + out, err := decodeForkchoiceResponse(enc) + require.NoError(t, err) + require.Equal(t, engine_types.ValidStatus, out.PayloadStatus.Status) + require.Equal(t, latest, *out.PayloadStatus.LatestValidHash) + require.Equal(t, payloadID, *out.PayloadId) + + resp.PayloadId = nil + resp.PayloadStatus = &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + enc, err = encodeForkchoiceResponse(resp) + require.NoError(t, err) + out, err = decodeForkchoiceResponse(enc) + require.NoError(t, err) + require.Equal(t, engine_types.SyncingStatus, out.PayloadStatus.Status) + require.Nil(t, out.PayloadId) +} + +func TestSSZRESTBuiltPayloadRoundTrip(t *testing.T) { + blockValue := uint256.NewInt(112233).ToBig() + bundle := &engine_types.BlobsBundle{ + Commitments: []hexutil.Bytes{bytes.Repeat([]byte{0x01}, sszKZGBytes)}, + Proofs: []hexutil.Bytes{bytes.Repeat([]byte{0x02}, sszKZGBytes)}, + Blobs: []hexutil.Bytes{bytes.Repeat([]byte{0x03}, sszBlobBytes)}, + } + requests := []hexutil.Bytes{{0x00, 0xaa}, {0x01, 0xbb}} + for _, tc := range []struct { - name string - version clparams.StateVersion - encode func(clparams.StateVersion) ([]byte, error) - decode func([]byte, clparams.StateVersion) error + version clparams.StateVersion + wantBundle bool + wantRequests bool }{ - {"newPayloadV1", clparams.BellatrixVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV2", clparams.CapellaVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV3", clparams.DenebVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV5", clparams.GloasVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"forkchoiceV1", clparams.BellatrixVersion, encodeEmptyForkchoiceRequest, decodeEmptyForkchoiceRequest}, - {"forkchoiceV4", clparams.GloasVersion, encodeEmptyForkchoiceRequest, decodeEmptyForkchoiceRequest}, + {clparams.BellatrixVersion, false, false}, + {clparams.CapellaVersion, false, false}, + {clparams.DenebVersion, true, false}, + {clparams.ElectraVersion, true, true}, + {clparams.FuluVersion, true, true}, + {clparams.GloasVersion, true, true}, } { - t.Run(tc.name, func(t *testing.T) { - enc, err := tc.encode(tc.version) + t.Run(tc.version.String(), func(t *testing.T) { + payload := engine_types.NewExecutionPayloadSSZ(tc.version) + if tc.version >= clparams.GloasVersion { + slot := hexutil.Uint64(7) + payload.SlotNumber = &slot + payload.BlockAccessList = hexutil.Bytes{0x0a} + } + resp := &engine_types.GetPayloadResponse{ + ExecutionPayload: payload, + BlockValue: (*hexutil.Big)(blockValue), + ShouldOverrideBuilder: true, + } + if tc.wantBundle { + resp.BlobsBundle = bundle + } + if tc.wantRequests { + resp.ExecutionRequests = requests + } + enc, err := encodeBuiltPayload(resp, tc.version) + require.NoError(t, err) + out, err := decodeBuiltPayload(enc, tc.version) require.NoError(t, err) - require.NoError(t, tc.decode(enc, tc.version)) + require.Equal(t, blockValue, out.BlockValue.ToInt()) + require.Equal(t, payload.BlockHash, out.ExecutionPayload.BlockHash) + if tc.wantBundle { + require.NotNil(t, out.BlobsBundle) + require.Equal(t, bundle.Commitments, out.BlobsBundle.Commitments) + require.True(t, out.ShouldOverrideBuilder) + } + if tc.wantRequests { + require.Equal(t, requests, out.ExecutionRequests) + } + if tc.version >= clparams.GloasVersion { + require.Equal(t, hexutil.Bytes{0x0a}, out.ExecutionPayload.BlockAccessList) + } }) } } -func encodeEmptyNewPayloadRequest(version clparams.StateVersion) ([]byte, error) { - return encodeNewPayloadRequest(version, engine_types.NewExecutionPayloadSSZ(version), solid.NewHashList(sszMaxBlobHashes), common.Hash{}, &solid.TransactionsSSZ{}) -} +func TestSSZRESTBodiesResponseRoundTrip(t *testing.T) { + withdrawal := &types.Withdrawal{Index: 1, Validator: 2, Address: common.HexToAddress("0x03"), Amount: 4} + body := &engine_types.ExecutionPayloadBodyV2{ + Transactions: []hexutil.Bytes{{0x01, 0x02}}, + Withdrawals: []*types.Withdrawal{withdrawal}, + BlockAccessList: hexutil.Bytes{0xba, 0x11}, + } -func decodeEmptyNewPayloadRequest(buf []byte, version clparams.StateVersion) error { - _, _, _, _, err := decodeNewPayloadRequest(buf, version) - return err -} + for _, tc := range []struct { + version clparams.StateVersion + wantWithdrawals bool + wantBAL bool + }{ + {clparams.BellatrixVersion, false, false}, + {clparams.DenebVersion, true, false}, + {clparams.FuluVersion, true, false}, + {clparams.GloasVersion, true, true}, + } { + t.Run(tc.version.String(), func(t *testing.T) { + enc, err := encodeBodiesResponse([]*engine_types.ExecutionPayloadBodyV2{body, nil}, tc.version) + require.NoError(t, err) + entries, err := decodeBodiesResponse(enc, tc.version) + require.NoError(t, err) + require.Len(t, entries, 2) -func encodeEmptyForkchoiceRequest(version clparams.StateVersion) ([]byte, error) { - state := engine_types.ForkChoiceState{} - return encodeForkchoiceRequest(version, &state, nil) -} + require.True(t, entries[0].Available) + require.Equal(t, body.Transactions, entries[0].Transactions) + if tc.wantWithdrawals { + require.Len(t, entries[0].Withdrawals, 1) + require.Equal(t, *withdrawal, *entries[0].Withdrawals[0]) + } else { + require.Empty(t, entries[0].Withdrawals) + } + if tc.wantBAL { + require.Equal(t, body.BlockAccessList, entries[0].BlockAccessList) + } else { + require.Empty(t, entries[0].BlockAccessList) + } -func decodeEmptyForkchoiceRequest(buf []byte, version clparams.StateVersion) error { - _, _, err := decodeForkchoiceRequest(buf, version) - return err + require.False(t, entries[1].Available) + require.Empty(t, entries[1].Transactions) + }) + } } -func TestSSZRESTGetBlobsCodecsRoundTrip(t *testing.T) { +func TestSSZRESTBlobsResponseRoundTrip(t *testing.T) { blob := bytes.Repeat([]byte{0x11}, sszBlobBytes) proof := bytes.Repeat([]byte{0x22}, sszKZGBytes) proofs := make([]hexutil.Bytes, sszCellsPerExtBlob) @@ -100,186 +429,197 @@ func TestSSZRESTGetBlobsCodecsRoundTrip(t *testing.T) { proofs[i] = proof } - for _, tc := range []struct { - name string - enc func() ([]byte, error) - dec func([]byte) error - }{ - { - name: "v1", - enc: func() ([]byte, error) { - return encodeGetBlobsV1Response([]*engine_types.BlobAndProofV1{{Blob: blob, Proof: proof}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewStaticListSSZ[*engine_types.BlobAndProofV1](sszMaxGetBlobHashes, sszBlobBytes+sszKZGBytes)) - }, - }, - { - name: "v2", - enc: func() ([]byte, error) { - return encodeGetBlobsV2Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewDynamicListSSZ[*engine_types.BlobAndProofV2](sszMaxGetBlobHashes)) - }, - }, - { - name: "v3", - enc: func() ([]byte, error) { - return encodeGetBlobsV3Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewDynamicListSSZ[*engine_types.NullableBlobAndProofV2](sszMaxGetBlobHashes)) - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - enc, err := tc.enc() - require.NoError(t, err) - require.NoError(t, tc.dec(enc)) - }) - } + t.Run("v1", func(t *testing.T) { + enc, err := encodeBlobsV1Response([]*engine_types.BlobAndProofV1{{Blob: blob, Proof: proof}, nil}) + require.NoError(t, err) + out, err := decodeBlobsV1Response(enc) + require.NoError(t, err) + require.Len(t, out, 2) + require.NotNil(t, out[0]) + require.Equal(t, hexutil.Bytes(blob), out[0].Blob) + require.Equal(t, hexutil.Bytes(proof), out[0].Proof) + require.Nil(t, out[1]) + }) + + t.Run("v2", func(t *testing.T) { + enc, err := encodeBlobsV2Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) + require.NoError(t, err) + out, err := decodeBlobsV2Response(enc) + require.NoError(t, err) + require.Len(t, out, 2) + require.NotNil(t, out[0]) + require.Equal(t, hexutil.Bytes(blob), out[0].Blob) + require.Len(t, out[0].CellProofs, sszCellsPerExtBlob) + require.Nil(t, out[1]) + }) } func TestSSZRESTCapabilitiesRoute(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - body, err := encodeCapabilities([]string{"engine_newPayloadV1"}) - require.NoError(t, err) - - req := httptest.NewRequest(http.MethodPost, "/engine/v1/capabilities", bytes.NewReader(body)) + srv := newTestEngineServer(allForksConfig(), false) + req := httptest.NewRequest(http.MethodGet, "/engine/v2/capabilities", nil) rec := httptest.NewRecorder() srv.SSZRESTHandler().ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - require.Equal(t, sszRestContentType, rec.Header().Get("Content-Type")) - resp, err := decodeCapabilities(rec.Body.Bytes(), 0) - require.NoError(t, err) - require.Contains(t, resp, "engine_newPayloadV1") - require.Contains(t, resp, "POST /engine/v4/payloads") - require.NotContains(t, resp, "engine_exchangeCapabilities") + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var caps struct { + SupportedForks []string `json:"supported_forks"` + ForkScopedEndpoints []string `json:"fork_scoped_endpoints"` + IndependentlyVersioned map[string][]string `json:"independently_versioned"` + UnscopedEndpoints []string `json:"unscoped_endpoints"` + Limits map[string]uint64 `json:"limits"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &caps)) + require.Equal(t, []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"}, caps.SupportedForks) + require.Equal(t, []string{"payloads", "forkchoice", "bodies"}, caps.ForkScopedEndpoints) + require.Equal(t, []string{"v1", "v2", "v3"}, caps.IndependentlyVersioned["blobs"]) + require.Equal(t, []string{"capabilities", "identity"}, caps.UnscopedEndpoints) + require.Equal(t, uint64(sszMaxBodiesRequest), caps.Limits["bodies.max_count"]) + require.Equal(t, uint64(sszMaxGetBlobHashes), caps.Limits["blobs.max_versioned_hashes"]) + require.Equal(t, uint64(sszMaxRequestBody), caps.Limits["payload.max_bytes"]) } -func TestSSZRESTAdvertisedRoutes(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - for _, route := range []struct { - method string - path string - code int - }{ - {http.MethodPost, "/engine/v1/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v4/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v5/payloads", http.StatusBadRequest}, - {http.MethodGet, "/engine/v1/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v2/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v3/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v4/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v5/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v6/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v4/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/client/version", http.StatusBadRequest}, - } { - t.Run(route.method+" "+route.path, func(t *testing.T) { - req := httptest.NewRequest(route.method, route.path, strings.NewReader("bad-ssz")) - rec := httptest.NewRecorder() - srv.SSZRESTHandler().ServeHTTP(rec, req) - require.Equal(t, route.code, rec.Code) - }) - } +func TestSSZRESTIdentityRoute(t *testing.T) { + srv := newTestEngineServer(&chain.Config{}, false) + req := httptest.NewRequest(http.MethodGet, "/engine/v2/identity", nil) + req.Header.Set("X-Engine-Client-Version", "LH/v9.9.9") + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var versions []engine_types.ClientVersionV1 + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &versions)) + require.Len(t, versions, 1) + require.Equal(t, "EG", versions[0].Code) } -func TestSSZRESTEndpointVersionMapping(t *testing.T) { +func TestSSZRESTRoutingErrors(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), false) for _, tc := range []struct { - name string - fn func(int) (clparams.StateVersion, bool) - in int - want clparams.StateVersion + name string + method string + path string + contentType string + code int + problem string }{ - {"newPayloadV1", sszNewPayloadVersion, 1, clparams.BellatrixVersion}, - {"newPayloadV2", sszNewPayloadVersion, 2, clparams.CapellaVersion}, - {"newPayloadV3", sszNewPayloadVersion, 3, clparams.DenebVersion}, - {"newPayloadV4", sszNewPayloadVersion, 4, clparams.ElectraVersion}, - {"newPayloadV5", sszNewPayloadVersion, 5, clparams.GloasVersion}, - {"getPayloadV1", sszGetPayloadVersion, 1, clparams.BellatrixVersion}, - {"getPayloadV2", sszGetPayloadVersion, 2, clparams.CapellaVersion}, - {"getPayloadV3", sszGetPayloadVersion, 3, clparams.DenebVersion}, - {"getPayloadV4", sszGetPayloadVersion, 4, clparams.ElectraVersion}, - {"getPayloadV5", sszGetPayloadVersion, 5, clparams.FuluVersion}, - {"getPayloadV6", sszGetPayloadVersion, 6, clparams.GloasVersion}, - {"forkchoiceV1", sszForkchoiceVersion, 1, clparams.BellatrixVersion}, - {"forkchoiceV2", sszForkchoiceVersion, 2, clparams.CapellaVersion}, - {"forkchoiceV3", sszForkchoiceVersion, 3, clparams.DenebVersion}, - {"forkchoiceV4", sszForkchoiceVersion, 4, clparams.GloasVersion}, + {"legacy 764 payloads endpoint", http.MethodPost, "/engine/v1/payloads", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"legacy 764 forkchoice endpoint", http.MethodPost, "/engine/v1/forkchoice", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"legacy 764 capabilities endpoint", http.MethodPost, "/engine/v1/capabilities", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"unknown fork", http.MethodPost, "/engine/v2/foobar/payloads", sszRestContentType, http.StatusBadRequest, problemUnsupportedFork}, + {"trailing slash", http.MethodPost, "/engine/v2/cancun/payloads/", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"method mismatch on capabilities", http.MethodPost, "/engine/v2/capabilities", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"wrong content type", http.MethodPost, "/engine/v2/cancun/payloads", "application/json", http.StatusUnsupportedMediaType, problemUnsupportedMediaType}, + {"bad ssz body", http.MethodPost, "/engine/v2/cancun/payloads", sszRestContentType, http.StatusBadRequest, problemSSZDecodeError}, + {"bad payload id", http.MethodGet, "/engine/v2/cancun/payloads/not-an-id", "", http.StatusBadRequest, problemInvalidRequest}, + {"bodies range missing params", http.MethodGet, "/engine/v2/cancun/bodies", "", http.StatusBadRequest, problemInvalidRequest}, + {"bodies range zero count", http.MethodGet, "/engine/v2/cancun/bodies?from=1&count=0", "", http.StatusUnprocessableEntity, problemInvalidBody}, + {"bodies range too large", http.MethodGet, "/engine/v2/cancun/bodies?from=1&count=33", "", http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {"bodies hash too many", http.MethodPost, "/engine/v2/cancun/bodies/hash", sszRestContentType, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {"blobs unknown revision", http.MethodPost, "/engine/v2/blobs/v9", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"blobs too many hashes", http.MethodPost, "/engine/v2/blobs/v1", sszRestContentType, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, } { t.Run(tc.name, func(t *testing.T) { - got, ok := tc.fn(tc.in) - require.True(t, ok) - require.Equal(t, tc.want, got) + var body []byte + switch tc.name { + case "bad ssz body": + body = []byte("bad-ssz") + case "bodies hash too many": + body = bytes.Repeat([]byte{0x01}, (sszMaxBodiesRequest+1)*32) + case "blobs too many hashes": + body = bytes.Repeat([]byte{0x01}, (sszMaxGetBlobHashes+1)*32) + } + req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader(body)) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + require.Equal(t, tc.code, rec.Code) + require.Equal(t, tc.problem, problemType(t, rec)) }) } } -func TestSSZRESTNewPayloadV5UsesGloasPayloadSchema(t *testing.T) { - payload := engine_types.NewExecutionPayloadSSZ(clparams.GloasVersion) - slot := hexutil.Uint64(123) - payload.SlotNumber = &slot - payload.BlockAccessList = hexutil.Bytes{0x01, 0x02, 0x03} +// A fork that is valid but not scheduled on this chain must be rejected +// with 400 unsupported-fork. +func TestSSZRESTUnscheduledFork(t *testing.T) { + srv := newTestEngineServer(&chain.Config{ShanghaiTime: common.NewUint64(0), CancunTime: common.NewUint64(0)}, false) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/amsterdam/payloads", nil)) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemUnsupportedFork, problemType(t, rec)) +} - enc, err := encodeNewPayloadRequest(clparams.GloasVersion, payload, solid.NewHashList(sszMaxBlobHashes), common.Hash{}, &solid.TransactionsSSZ{}) +// An empty (but well-formed) Paris payload travels through decode and dispatch +// and comes back as an SSZ INVALID status ("invalid block hash") over HTTP 200. +func TestSSZRESTNewPayloadDispatch(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + payload := engine_types.NewExecutionPayloadSSZ(clparams.BellatrixVersion) + body, err := encodeNewPayloadEnvelope(clparams.BellatrixVersion, payload, common.Hash{}, nil) require.NoError(t, err) - wireVersion, ok := sszNewPayloadVersion(5) - require.True(t, ok) - out, _, _, _, err := decodeNewPayloadRequest(enc, wireVersion) - require.NoError(t, err) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/paris/payloads", body)) - require.NotNil(t, out.SlotNumber) - require.Equal(t, hexutil.Uint64(123), *out.SlotNumber) - require.Equal(t, hexutil.Bytes{0x01, 0x02, 0x03}, out.BlockAccessList) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, sszRestContentType, rec.Header().Get("Content-Type")) + var status engine_types.PayloadStatus + require.NoError(t, status.DecodeSSZ(rec.Body.Bytes(), 0)) + require.Equal(t, engine_types.InvalidStatus, status.Status) + require.Contains(t, status.ValidationError.Error().Error(), "invalid block hash") } -func TestSSZRESTForkchoiceV4UsesGloasPayloadAttributesSchema(t *testing.T) { - slotNumber := hexutil.Uint64(456) - targetGasLimit := hexutil.Uint64(36000000) +// A forkchoice with payload attributes whose timestamp belongs to a different +// fork than the URL must be rejected with 400 unsupported-fork. +func TestSSZRESTForkchoiceURLForkMustMatchAttributes(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + root := common.HexToHash("0x04") attrs := &engine_types.PayloadAttributes{ - Timestamp: 1, - SuggestedFeeRecipient: common.HexToAddress("0x1234"), - Withdrawals: nil, - SlotNumber: &slotNumber, - TargetGasLimit: &targetGasLimit, - SSZVersion: clparams.GloasVersion, + Timestamp: 150, // shanghai era + SuggestedFeeRecipient: common.HexToAddress("0x06"), + ParentBeaconBlockRoot: &root, } - state := engine_types.ForkChoiceState{} - enc, err := encodeForkchoiceRequest(clparams.GloasVersion, &state, attrs) + attrs.SSZVersion = clparams.DenebVersion + body, err := encodeForkchoiceUpdate(clparams.DenebVersion, &engine_types.ForkChoiceState{}, attrs, nil) require.NoError(t, err) - wireVersion, ok := sszForkchoiceVersion(4) - require.True(t, ok) - _, engineAttrs, err := decodeForkchoiceRequest(enc, wireVersion) - require.NoError(t, err) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/cancun/forkchoice", body)) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemUnsupportedFork, problemType(t, rec)) +} - require.NotNil(t, engineAttrs) - require.NotNil(t, engineAttrs.SlotNumber) - require.Equal(t, hexutil.Uint64(456), *engineAttrs.SlotNumber) - require.NotNil(t, engineAttrs.TargetGasLimit) - require.Equal(t, hexutil.Uint64(36000000), *engineAttrs.TargetGasLimit) +func TestSSZRESTBlobsPoolDisabled(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + hash := common.HexToHash("0x01") + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/blobs/v1", hash[:])) + require.Equal(t, http.StatusNoContent, rec.Code) + require.Empty(t, rec.Body.Bytes()) } -func TestExchangeCapabilitiesAdvertisesJSONRPCAndSSZREST(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - caps := srv.ExchangeCapabilities([]string{"engine_newPayloadV1"}) - require.Contains(t, caps, "engine_newPayloadV1") - require.Contains(t, caps, "engine_getPayloadV6") - require.Contains(t, caps, "POST /engine/v1/capabilities") - require.Contains(t, caps, "GET /engine/v6/payloads/{payload_id}") - require.NotContains(t, caps, "engine_exchangeCapabilities") +func TestSSZRESTErrorMapping(t *testing.T) { + for _, tc := range []struct { + err error + code int + problem string + }{ + {&engine_helpers.UnknownPayloadErr, http.StatusNotFound, problemUnknownPayload}, + {&engine_helpers.InvalidForkchoiceStateErr, http.StatusConflict, problemInvalidForkchoice}, + {&engine_helpers.InvalidPayloadAttributesErr, http.StatusUnprocessableEntity, problemInvalidAttributes}, + {&engine_helpers.TooLargeRequestErr, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {&engine_helpers.ReorgTooDeepErr, http.StatusConflict, problemReorgTooDeep}, + {&rpc.UnsupportedForkError{Message: "no"}, http.StatusBadRequest, problemUnsupportedFork}, + {&rpc.InvalidParamsError{Message: "no"}, http.StatusUnprocessableEntity, problemInvalidBody}, + {errors.New("boom"), http.StatusInternalServerError, problemInternal}, + } { + rec := httptest.NewRecorder() + writeEngineProblem(rec, tc.err) + require.Equal(t, tc.code, rec.Code) + require.Equal(t, tc.problem, problemType(t, rec)) + } } func TestSSZRESTGetPayloadIDPathParsing(t *testing.T) { @@ -291,19 +631,13 @@ func TestSSZRESTGetPayloadIDPathParsing(t *testing.T) { require.Error(t, err) } -func TestSSZRESTErrorMapping(t *testing.T) { - for _, tc := range []struct { - err error - code int - }{ - {&engine_helpers.UnknownPayloadErr, http.StatusNotFound}, - {&engine_helpers.InvalidForkchoiceStateErr, http.StatusConflict}, - {&engine_helpers.InvalidPayloadAttributesErr, http.StatusUnprocessableEntity}, - {&engine_helpers.TooLargeRequestErr, http.StatusRequestEntityTooLarge}, - {errors.New("boom"), http.StatusInternalServerError}, - } { - rec := httptest.NewRecorder() - writeEngineError(rec, tc.err) - require.Equal(t, tc.code, rec.Code) +func TestExchangeCapabilitiesDropsLegacySSZEndpoints(t *testing.T) { + srv := newTestEngineServer(&chain.Config{}, false) + caps := srv.ExchangeCapabilities([]string{"engine_newPayloadV1"}) + require.Contains(t, caps, "engine_newPayloadV1") + require.Contains(t, caps, "engine_getPayloadV6") + require.NotContains(t, caps, "engine_exchangeCapabilities") + for _, capability := range caps { + require.NotContains(t, capability, "/engine/v") } } diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index 0907a25e1f1..f8c2a0edd89 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -6,10 +6,12 @@ // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. +// SSZ wire codecs for the Engine API v2 REST transport +// (https://github.com/ethereum/execution-apis/pull/793). + package engineapi import ( - "encoding/binary" "fmt" "math/big" @@ -20,22 +22,92 @@ import ( "github.com/erigontech/erigon/cl/cltypes/solid" ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/clonable" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/execution/types" ) const ( - sszMaxBlobHashes = 4096 - sszMaxGetBlobHashes = 128 - sszMaxCapabilityNameBytes = 64 - sszMaxCapabilities = 64 - sszBlobBytes = 0x20000 - sszKZGBytes = 48 - sszCellsPerExtBlob = 128 + sszMaxGetBlobHashes = 128 + sszMaxBodiesRequest = 32 + sszMaxRequestBody = 128 << 20 + sszBlobBytes = 0x20000 + sszKZGBytes = 48 + sszCellsPerExtBlob = 128 + sszCustodyBitvectorBytes = sszCellsPerExtBlob / 8 + sszMaxBALBytes = 0x40000000 + sszMaxWithdrawals = 16 + sszWithdrawalBytes = 44 ) -var mainnetBeaconCfg = &clparams.MainnetBeaconConfig +var engineForkOrder = []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"} + +func engineForkVersion(name string) (clparams.StateVersion, bool) { + switch name { + case "paris": + return clparams.BellatrixVersion, true + case "shanghai": + return clparams.CapellaVersion, true + case "cancun": + return clparams.DenebVersion, true + case "prague": + return clparams.ElectraVersion, true + case "osaka": + return clparams.FuluVersion, true + case "amsterdam": + return clparams.GloasVersion, true + default: + return 0, false + } +} + +func forkScheduled(cfg *chain.Config, name string) bool { + switch name { + case "paris": + return true + case "shanghai": + return cfg.ShanghaiTime != nil + case "cancun": + return cfg.CancunTime != nil + case "prague": + return cfg.PragueTime != nil + case "osaka": + return cfg.OsakaTime != nil + case "amsterdam": + return cfg.AmsterdamTime != nil + default: + return false + } +} + +func supportedForkNames(cfg *chain.Config) []string { + out := make([]string, 0, len(engineForkOrder)) + for _, name := range engineForkOrder { + if forkScheduled(cfg, name) { + out = append(out, name) + } + } + return out +} + +func forkNameAtTime(cfg *chain.Config, time uint64) string { + switch { + case cfg.IsAmsterdam(time): + return "amsterdam" + case cfg.IsOsaka(time): + return "osaka" + case cfg.IsPrague(time): + return "prague" + case cfg.IsCancun(time): + return "cancun" + case cfg.IsShanghai(time): + return "shanghai" + default: + return "paris" + } +} func hashListValues(l solid.HashListSSZ) []common.Hash { if l == nil { @@ -61,164 +133,136 @@ func transactionsBytes(txs *solid.TransactionsSSZ) []hexutil.Bytes { return out } -func validatePayloadIDList(id *solid.ByteListSSZ) error { - if id == nil { - return nil - } - if l := id.Len(); l != 0 && l != 8 { - return fmt.Errorf("payload ID length %d, want 0 or 8", l) +func newTransactionsSSZ(txs []hexutil.Bytes) *solid.TransactionsSSZ { + binaryTxs := make([][]byte, len(txs)) + for i, tx := range txs { + binaryTxs[i] = tx } - return nil + return solid.NewTransactionsSSZFromTransactions(binaryTxs) } -func newPayloadRequestSchema(version clparams.StateVersion, payload *engine_types.ExecutionPayload, blobHashes solid.HashListSSZ, parentRoot *common.Hash, requests *solid.TransactionsSSZ) []any { - switch version { - case clparams.BellatrixVersion, clparams.CapellaVersion: +// ExecutionPayloadEnvelope is the request body of POST /{fork}/payloads. +// parent_beacon_block_root exists since Cancun, execution_requests since +// Prague; expected blob versioned hashes are recomputed from the transactions. +func newPayloadEnvelopeSchema(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot *common.Hash, requests *solid.TransactionsSSZ) []any { + switch { + case version < clparams.DenebVersion: return []any{payload} - case clparams.DenebVersion: - return []any{payload, blobHashes, parentRoot[:]} + case version < clparams.ElectraVersion: + return []any{payload, parentRoot[:]} default: - return []any{payload, blobHashes, parentRoot[:], requests} + return []any{payload, parentRoot[:], requests} } } -func decodeNewPayloadRequest(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, solid.HashListSSZ, common.Hash, *solid.TransactionsSSZ, error) { +func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, common.Hash, []hexutil.Bytes, error) { payload := engine_types.NewExecutionPayloadSSZ(version) - blobHashes := solid.NewHashList(sszMaxBlobHashes) parentRoot := common.Hash{} requests := &solid.TransactionsSSZ{} - err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadRequestSchema(version, payload, blobHashes, &parentRoot, requests)...) - return payload, blobHashes, parentRoot, requests, err + if err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadEnvelopeSchema(version, payload, &parentRoot, requests)...); err != nil { + return nil, common.Hash{}, nil, err + } + return payload, parentRoot, transactionsBytes(requests), nil } -func encodeNewPayloadRequest(version clparams.StateVersion, payload *engine_types.ExecutionPayload, blobHashes solid.HashListSSZ, parentRoot common.Hash, requests *solid.TransactionsSSZ) ([]byte, error) { - return ssz2.MarshalSSZ(nil, newPayloadRequestSchema(version, payload, blobHashes, &parentRoot, requests)...) +func encodeNewPayloadEnvelope(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot common.Hash, requests []hexutil.Bytes) ([]byte, error) { + return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newTransactionsSSZ(requests))...) } -func decodeForkchoiceRequest(buf []byte, version clparams.StateVersion) (engine_types.ForkChoiceState, *engine_types.PayloadAttributes, error) { +// blobVersionedHashesFromTxs recomputes the versioned hashes the legacy API +// received as expectedBlobVersionedHashes. Undecodable transactions yield an +// empty list; newPayload re-decodes them and reports INVALID itself. +func blobVersionedHashesFromTxs(txs []hexutil.Bytes) []common.Hash { + hashes := []common.Hash{} + binaryTxs := make([][]byte, len(txs)) + for i, tx := range txs { + binaryTxs[i] = tx + } + transactions, err := types.DecodeTransactions(binaryTxs) + if err != nil { + return hashes + } + for _, txn := range transactions { + if txn.Type() == types.BlobTxType { + hashes = append(hashes, txn.GetBlobHashes()...) + } + } + return hashes +} + +// ForkchoiceUpdate is the request body of POST /{fork}/forkchoice. +// custody_columns (Optional[Bitvector[CELLS_PER_EXT_BLOB]]) exists since Amsterdam. +func forkchoiceUpdateSchema(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *solid.ListSSZ[*engine_types.PayloadAttributes], custody *solid.ByteListSSZ) []any { + if version < clparams.GloasVersion { + return []any{state, attrs} + } + return []any{state, attrs, custody} +} + +func decodeForkchoiceUpdate(buf []byte, version clparams.StateVersion) (engine_types.ForkChoiceState, *engine_types.PayloadAttributes, []byte, error) { state := engine_types.ForkChoiceState{} attrsList := solid.NewDynamicListSSZ[*engine_types.PayloadAttributes](1) - if err := ssz2.UnmarshalSSZ(buf, int(version), &state, attrsList); err != nil { - return state, nil, err + custodyList := solid.NewByteListSSZ(sszCustodyBitvectorBytes) + if err := ssz2.UnmarshalSSZ(buf, int(version), forkchoiceUpdateSchema(version, &state, attrsList, custodyList)...); err != nil { + return state, nil, nil, err } - if attrsList.Len() == 0 { - return state, nil, nil + var custody []byte + if l := len(custodyList.Bytes()); l != 0 { + if l != sszCustodyBitvectorBytes { + return state, nil, nil, fmt.Errorf("custody columns bitvector length %d, want %d", l, sszCustodyBitvectorBytes) + } + custody = custodyList.Bytes() + } + var attrs *engine_types.PayloadAttributes + if attrsList.Len() != 0 { + attrs = attrsList.Get(0) + attrs.SSZVersion = version } - attrs := attrsList.Get(0) - attrs.SSZVersion = version - return state, attrs, nil + return state, attrs, custody, nil } -func encodeForkchoiceRequest(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *engine_types.PayloadAttributes) ([]byte, error) { +func encodeForkchoiceUpdate(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *engine_types.PayloadAttributes, custody []byte) ([]byte, error) { attrsList := solid.NewDynamicListSSZ[*engine_types.PayloadAttributes](1) if attrs != nil { attrs.SSZVersion = version attrsList.Append(attrs) } - return ssz2.MarshalSSZ(nil, state, attrsList) + custodyList := solid.NewByteListSSZ(sszCustodyBitvectorBytes) + if err := custodyList.SetBytes(custody); err != nil { + return nil, err + } + return ssz2.MarshalSSZ(nil, forkchoiceUpdateSchema(version, state, attrsList, custodyList)...) } +// ForkchoiceUpdateResponse is {payload_status: PayloadStatus, payload_id: Optional[Bytes8]}. func encodeForkchoiceResponse(resp *engine_types.ForkChoiceUpdatedResponse) ([]byte, error) { payloadID := solid.NewByteListSSZ(8) - if resp.PayloadId != nil && len(*resp.PayloadId) == 8 { + if resp.PayloadId != nil { + if len(*resp.PayloadId) != 8 { + return nil, fmt.Errorf("payload ID length %d, want 8", len(*resp.PayloadId)) + } if err := payloadID.SetBytes(*resp.PayloadId); err != nil { return nil, err } } - if err := validatePayloadIDList(payloadID); err != nil { - return nil, err - } return ssz2.MarshalSSZ(nil, resp.PayloadStatus, payloadID) } -func encodeCapabilities(names []string) ([]byte, error) { - capabilities := make([]*solid.ByteListSSZ, 0, len(names)) - for _, name := range names { - capabilities = append(capabilities, capabilityName(name)) - } - if len(capabilities) > sszMaxCapabilities { - return nil, fmt.Errorf("too many capabilities: %d", len(capabilities)) - } - out := make([]byte, 0) - offset := len(capabilities) * 4 - for _, capability := range capabilities { - if capability == nil { - capability = solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - } - out = append(out, 0, 0, 0, 0) - binary.LittleEndian.PutUint32(out[len(out)-4:], uint32(offset)) - offset += capability.EncodingSizeSSZ() - } - for _, capability := range capabilities { - if capability == nil { - capability = solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - } - var err error - out, err = capability.EncodeSSZ(out) - if err != nil { - return nil, err - } - } - return out, nil -} - -func decodeCapabilities(buf []byte, version int) ([]string, error) { - if len(buf) == 0 { - return nil, nil - } - if len(buf) < 4 { - return nil, fmt.Errorf("capabilities: short offset table") - } - firstOffset := binary.LittleEndian.Uint32(buf[:4]) - if firstOffset%4 != 0 || firstOffset > uint32(len(buf)) { - return nil, fmt.Errorf("capabilities: bad first offset %d", firstOffset) - } - count := int(firstOffset / 4) - if count > sszMaxCapabilities { - return nil, fmt.Errorf("too many capabilities: %d", count) - } - offsets := make([]uint32, count+1) - offsets[count] = uint32(len(buf)) - for i := 0; i < count; i++ { - offsets[i] = binary.LittleEndian.Uint32(buf[i*4:]) - if i > 0 && offsets[i] < offsets[i-1] { - return nil, fmt.Errorf("capabilities: non-monotonic offset %d", i) - } - if offsets[i] > uint32(len(buf)) { - return nil, fmt.Errorf("capabilities: offset %d out of bounds", i) - } +func decodeForkchoiceResponse(buf []byte) (*engine_types.ForkChoiceUpdatedResponse, error) { + status := &engine_types.PayloadStatus{} + payloadID := solid.NewByteListSSZ(8) + if err := ssz2.UnmarshalSSZ(buf, 0, status, payloadID); err != nil { + return nil, err } - out := make([]string, 0, count) - for i := 0; i < count; i++ { - capability := solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - if err := capability.DecodeSSZ(buf[offsets[i]:offsets[i+1]], version); err != nil { - return nil, err + resp := &engine_types.ForkChoiceUpdatedResponse{PayloadStatus: status} + if l := len(payloadID.Bytes()); l != 0 { + if l != 8 { + return nil, fmt.Errorf("payload ID length %d, want 0 or 8", l) } - out = append(out, string(capability.Bytes())) - } - return out, nil -} - -func capabilityName(s string) *solid.ByteListSSZ { - b := solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - _ = b.SetBytes([]byte(s)) - return b -} - -func encodeClientVersionResponse(versions []engine_types.ClientVersionV1) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.ClientVersionV1](4) - for i := range versions { - list.Append(&versions[i]) + id := hexutil.Bytes(payloadID.Bytes()) + resp.PayloadId = &id } - return ssz2.MarshalSSZ(nil, list) -} - -func decodeClientVersionRequest(buf []byte) (*engine_types.ClientVersionV1, error) { - version := &engine_types.ClientVersionV1{} - if err := ssz2.UnmarshalSSZ(buf, 0, version); err != nil { - return nil, err - } - return version, nil + return resp, nil } func blockValueHash(v *hexutil.Big) common.Hash { @@ -234,6 +278,15 @@ func blockValueHash(v *hexutil.Big) common.Hash { return out } +func blockValueFromHash(v common.Hash) *hexutil.Big { + var be [32]byte + for i := range v { + be[i] = v[31-i] + } + u := new(uint256.Int).SetBytes(be[:]) + return (*hexutil.Big)(u.ToBig()) +} + func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersion) *engine_types.BlobsBundle { if b == nil { return engine_types.NewBlobsBundleSSZ(version) @@ -242,76 +295,325 @@ func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersio return b } -func encodeGetPayloadResponse(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { +// BuiltPayload is the response of GET /{fork}/payloads/{payloadId}: +// {payload, block_value, blobs_bundle, execution_requests, should_override_builder}, +// with blobs_bundle existing since Cancun and execution_requests since Prague. +func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { payload := resp.ExecutionPayload payload.SSZVersion = version - executionRequests, err := executionRequestsFromList(resp.ExecutionRequests, version) - if err != nil { - return nil, err - } blockValue := blockValueHash(resp.BlockValue) blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) - switch version { - case clparams.CapellaVersion: + requests := newTransactionsSSZ(resp.ExecutionRequests) + switch { + case version < clparams.DenebVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:]) - case clparams.DenebVersion: + case version < clparams.ElectraVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder) default: - return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder, executionRequests) + return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, requests, resp.ShouldOverrideBuilder) } } -func executionRequestsFromList(requests []hexutil.Bytes, version clparams.StateVersion) (*cltypes.ExecutionRequests, error) { - out := cltypes.NewExecutionRequests(mainnetBeaconCfg) - for _, request := range requests { - if len(request) == 0 { - continue - } - data := request[1:] - switch request[0] { - case types.DepositRequestType: - if err := out.Deposits.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - case types.WithdrawalRequestType: - if err := out.Withdrawals.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - case types.ConsolidationRequestType: - if err := out.Consolidations.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unknown execution request type %d", request[0]) +func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_types.GetPayloadResponse, error) { + payload := engine_types.NewExecutionPayloadSSZ(version) + blockValue := common.Hash{} + blobsBundle := engine_types.NewBlobsBundleSSZ(version) + requests := &solid.TransactionsSSZ{} + shouldOverride := false + var err error + switch { + case version < clparams.DenebVersion: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:]) + case version < clparams.ElectraVersion: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, &shouldOverride) + default: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, requests, &shouldOverride) + } + if err != nil { + return nil, err + } + resp := &engine_types.GetPayloadResponse{ + ExecutionPayload: payload, + BlockValue: blockValueFromHash(blockValue), + ShouldOverrideBuilder: shouldOverride, + } + if version >= clparams.DenebVersion { + resp.BlobsBundle = blobsBundle + } + if version >= clparams.ElectraVersion { + resp.ExecutionRequests = transactionsBytes(requests) + } + return resp, nil +} + +func newWithdrawalListSSZ(withdrawals []*types.Withdrawal) *solid.ListSSZ[*cltypes.Withdrawal] { + l := solid.NewStaticListSSZ[*cltypes.Withdrawal](sszMaxWithdrawals, sszWithdrawalBytes) + for _, w := range withdrawals { + l.Append(&cltypes.Withdrawal{Index: w.Index, Validator: w.Validator, Address: w.Address, Amount: w.Amount}) + } + return l +} + +func withdrawalsFromSSZList(l *solid.ListSSZ[*cltypes.Withdrawal]) []*types.Withdrawal { + out := make([]*types.Withdrawal, 0, l.Len()) + l.Range(func(_ int, w *cltypes.Withdrawal, _ int) bool { + out = append(out, &types.Withdrawal{Index: w.Index, Validator: w.Validator, Address: w.Address, Amount: w.Amount}) + return true + }) + return out +} + +// sszPayloadBody is the fork-scoped ExecutionPayloadBody: withdrawals exist +// since Shanghai and block_access_list since Amsterdam. +type sszPayloadBody struct { + version clparams.StateVersion + transactions *solid.TransactionsSSZ + withdrawals *solid.ListSSZ[*cltypes.Withdrawal] + blockAccessList *solid.ByteListSSZ +} + +func newSSZPayloadBody(version clparams.StateVersion) *sszPayloadBody { + return &sszPayloadBody{ + version: version, + transactions: &solid.TransactionsSSZ{}, + withdrawals: solid.NewStaticListSSZ[*cltypes.Withdrawal](sszMaxWithdrawals, sszWithdrawalBytes), + blockAccessList: solid.NewByteListSSZ(sszMaxBALBytes), + } +} + +func (b *sszPayloadBody) schema() []any { + switch { + case b.version < clparams.CapellaVersion: + return []any{b.transactions} + case b.version < clparams.GloasVersion: + return []any{b.transactions, b.withdrawals} + default: + return []any{b.transactions, b.withdrawals, b.blockAccessList} + } +} + +func (b *sszPayloadBody) Static() bool { return false } + +func (b *sszPayloadBody) EncodeSSZ(dst []byte) ([]byte, error) { + return ssz2.MarshalSSZ(dst, b.schema()...) +} + +func (b *sszPayloadBody) DecodeSSZ(buf []byte, version int) error { + b.version = clparams.StateVersion(version) + return ssz2.UnmarshalSSZ(buf, version, b.schema()...) +} + +func (b *sszPayloadBody) EncodingSizeSSZ() int { + size := 4 + b.transactions.EncodingSizeSSZ() + if b.version >= clparams.CapellaVersion { + size += 4 + b.withdrawals.EncodingSizeSSZ() + } + if b.version >= clparams.GloasVersion { + size += 4 + b.blockAccessList.EncodingSizeSSZ() + } + return size +} + +func (b *sszPayloadBody) Clone() clonable.Clonable { return newSSZPayloadBody(b.version) } + +// sszBodyEntry is BodyEntry {available: boolean, body: ExecutionPayloadBody}. +// When Available is false the body is zero-valued. +type sszBodyEntry struct { + Available bool + Transactions []hexutil.Bytes + Withdrawals []*types.Withdrawal + BlockAccessList hexutil.Bytes + version clparams.StateVersion +} + +func (e *sszBodyEntry) Static() bool { return false } + +func (e *sszBodyEntry) body() (*sszPayloadBody, error) { + body := newSSZPayloadBody(e.version) + body.transactions = newTransactionsSSZ(e.Transactions) + body.withdrawals = newWithdrawalListSSZ(e.Withdrawals) + if err := body.blockAccessList.SetBytes(e.BlockAccessList); err != nil { + return nil, err + } + return body, nil +} + +func (e *sszBodyEntry) EncodeSSZ(dst []byte) ([]byte, error) { + body, err := e.body() + if err != nil { + return nil, err + } + return ssz2.MarshalSSZ(dst, e.Available, body) +} + +func (e *sszBodyEntry) DecodeSSZ(buf []byte, version int) error { + e.version = clparams.StateVersion(version) + body := newSSZPayloadBody(e.version) + if err := ssz2.UnmarshalSSZ(buf, version, &e.Available, body); err != nil { + return err + } + e.Transactions = transactionsBytes(body.transactions) + e.Withdrawals = withdrawalsFromSSZList(body.withdrawals) + e.BlockAccessList = body.blockAccessList.Bytes() + return nil +} + +func (e *sszBodyEntry) EncodingSizeSSZ() int { + body, err := e.body() + if err != nil { + return 0 + } + return 1 + 4 + body.EncodingSizeSSZ() +} + +func (e *sszBodyEntry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBodyEntry) Clone() clonable.Clonable { return &sszBodyEntry{} } + +// The response of POST /{fork}/bodies/hash and GET /{fork}/bodies is +// List[BodyEntry, MAX_BODIES_REQUEST]; nil bodies become available=false. +func encodeBodiesResponse(bodies []*engine_types.ExecutionPayloadBodyV2, version clparams.StateVersion) ([]byte, error) { + entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) + for _, body := range bodies { + entry := &sszBodyEntry{version: version} + if body != nil { + entry.Available = true + entry.Transactions = body.Transactions + entry.Withdrawals = body.Withdrawals + entry.BlockAccessList = body.BlockAccessList } + entries.Append(entry) } + return entries.EncodeSSZ(nil) +} + +func decodeBodiesResponse(buf []byte, version clparams.StateVersion) ([]*sszBodyEntry, error) { + entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) + if err := entries.DecodeSSZ(buf, int(version)); err != nil { + return nil, err + } + out := make([]*sszBodyEntry, 0, entries.Len()) + entries.Range(func(_ int, e *sszBodyEntry, _ int) bool { + out = append(out, e) + return true + }) return out, nil } -func encodeGetBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) { - list := solid.NewStaticListSSZ[*engine_types.BlobAndProofV1](sszMaxGetBlobHashes, sszBlobBytes+sszKZGBytes) - for _, blob := range blobs { - if blob != nil { - list.Append(blob) - } +// sszBlobV1Entry is BlobEntry {available: boolean, contents: BlobAndProofV1}. +type sszBlobV1Entry struct { + Available bool + Blob hexutil.Bytes + Proof hexutil.Bytes +} + +func (*sszBlobV1Entry) Static() bool { return true } + +func (*sszBlobV1Entry) EncodingSizeSSZ() int { return 1 + sszBlobBytes + sszKZGBytes } + +func (e *sszBlobV1Entry) EncodeSSZ(dst []byte) ([]byte, error) { + blob, proof := []byte(e.Blob), []byte(e.Proof) + if !e.Available { + blob, proof = make([]byte, sszBlobBytes), make([]byte, sszKZGBytes) + } + if len(blob) != sszBlobBytes || len(proof) != sszKZGBytes { + return nil, fmt.Errorf("bad blob/proof length %d/%d", len(blob), len(proof)) + } + return ssz2.MarshalSSZ(dst, e.Available, blob, proof) +} + +func (e *sszBlobV1Entry) DecodeSSZ(buf []byte, version int) error { + e.Blob = make(hexutil.Bytes, sszBlobBytes) + e.Proof = make(hexutil.Bytes, sszKZGBytes) + return ssz2.UnmarshalSSZ(buf, version, &e.Available, []byte(e.Blob), []byte(e.Proof)) +} + +func (e *sszBlobV1Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBlobV1Entry) Clone() clonable.Clonable { return &sszBlobV1Entry{} } + +// sszBlobV2Entry is BlobEntry {available: boolean, contents: BlobAndProofV2}, +// shared by /blobs/v2 and /blobs/v3. +type sszBlobV2Entry struct { + Available bool + Contents *engine_types.BlobAndProofV2 +} + +func (*sszBlobV2Entry) Static() bool { return false } + +func (e *sszBlobV2Entry) EncodeSSZ(dst []byte) ([]byte, error) { + contents := e.Contents + if contents == nil { + contents = &engine_types.BlobAndProofV2{} } - return ssz2.MarshalSSZ(nil, list) + return ssz2.MarshalSSZ(dst, e.Available, contents) } -func encodeGetBlobsV2Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.BlobAndProofV2](sszMaxGetBlobHashes) +func (e *sszBlobV2Entry) DecodeSSZ(buf []byte, version int) error { + e.Contents = &engine_types.BlobAndProofV2{} + if err := ssz2.UnmarshalSSZ(buf, version, &e.Available, e.Contents); err != nil { + return err + } + if !e.Available { + e.Contents = nil + } + return nil +} + +func (e *sszBlobV2Entry) EncodingSizeSSZ() int { out, _ := e.EncodeSSZ(nil); return len(out) } + +func (e *sszBlobV2Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBlobV2Entry) Clone() clonable.Clonable { return &sszBlobV2Entry{} } + +// The response of POST /blobs/vN is List[BlobEntry, MAX_BLOBS_REQUEST]; +// nil contents become available=false with zero-valued contents. +func encodeBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) { + entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) for _, blob := range blobs { + entry := &sszBlobV1Entry{} if blob != nil { - list.Append(blob) + entry.Available = true + entry.Blob = blob.Blob + entry.Proof = blob.Proof } + entries.Append(entry) } - return ssz2.MarshalSSZ(nil, list) + return entries.EncodeSSZ(nil) } -func encodeGetBlobsV3Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.NullableBlobAndProofV2](sszMaxGetBlobHashes) +func decodeBlobsV1Response(buf []byte) ([]*engine_types.BlobAndProofV1, error) { + entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) + if err := entries.DecodeSSZ(buf, 0); err != nil { + return nil, err + } + out := make([]*engine_types.BlobAndProofV1, entries.Len()) + entries.Range(func(i int, e *sszBlobV1Entry, _ int) bool { + if e.Available { + out[i] = &engine_types.BlobAndProofV1{Blob: e.Blob, Proof: e.Proof} + } + return true + }) + return out, nil +} + +func encodeBlobsV2Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { + entries := solid.NewDynamicListSSZ[*sszBlobV2Entry](sszMaxGetBlobHashes) for _, blob := range blobs { - list.Append(engine_types.NewNullableBlobAndProofV2(blob)) + entries.Append(&sszBlobV2Entry{Available: blob != nil, Contents: blob}) } - return ssz2.MarshalSSZ(nil, list) + return entries.EncodeSSZ(nil) +} + +func decodeBlobsV2Response(buf []byte) ([]*engine_types.BlobAndProofV2, error) { + entries := solid.NewDynamicListSSZ[*sszBlobV2Entry](sszMaxGetBlobHashes) + if err := entries.DecodeSSZ(buf, 0); err != nil { + return nil, err + } + out := make([]*engine_types.BlobAndProofV2, entries.Len()) + entries.Range(func(i int, e *sszBlobV2Entry, _ int) bool { + out[i] = e.Contents + return true + }) + return out, nil } From d4140501ea5bc6b79aa8be2432479a0338cec1db Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 15 Jun 2026 11:54:53 +0200 Subject: [PATCH 02/11] engineapi: align Engine API SSZ with execution-apis#793 update Track the June 14 "resolve feedback from implementers" update to execution-apis#793: - Wrap the top-level bodies and blobs request/response bodies in single-field SSZ containers (BodiesByHashRequest, BodiesResponse, BlobsV{1,2,3}Request/Response) instead of bare lists, matching the now-normative refactor.md. - Range bodies truncate at the latest known block (no trailing available=false padding); in-range out-of-era blocks stay available=false. - BuiltPayload for Shanghai now carries should_override_builder, per the spec's per-fork catalogue. - MAX_REQUEST_BODY_SIZE is 2**26 (64 MiB), advertised as limits.payload.max_bytes. - Tighten the BlobsBundleV2 proofs list bound to MAX_BLOBS_PER_PAYLOAD * CELLS_PER_EXT_BLOB. Wire format verified byte-compatible with go-ethereum#35171 for every container both implement. --- execution/engineapi/engine_types/ssz.go | 2 +- execution/engineapi/sszrest_handler.go | 31 +++++----- execution/engineapi/sszrest_test.go | 76 ++++++++++++++++++++----- execution/engineapi/sszrest_wire.go | 51 ++++++++++++----- 4 files changed, 116 insertions(+), 44 deletions(-) diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go index 5991945fc45..ca38455e886 100644 --- a/execution/engineapi/engine_types/ssz.go +++ b/execution/engineapi/engine_types/ssz.go @@ -27,7 +27,7 @@ const ( sszBlobBytes = 0x20000 sszKZGBytes = 48 sszCellsPerExtBlob = 128 - sszMaxCellProofs = 33554432 + sszMaxCellProofs = sszMaxBlobHashes * sszCellsPerExtBlob // MAX_BLOBS_PER_PAYLOAD * CELLS_PER_EXT_BLOB ) var mainnetBeaconCfg = &clparams.MainnetBeaconConfig diff --git a/execution/engineapi/sszrest_handler.go b/execution/engineapi/sszrest_handler.go index 8fe83d907c9..c90793ef149 100644 --- a/execution/engineapi/sszrest_handler.go +++ b/execution/engineapi/sszrest_handler.go @@ -18,7 +18,6 @@ import ( "strings" "github.com/erigontech/erigon/cl/clparams" - "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" @@ -56,7 +55,7 @@ func (e *EngineServer) SSZRESTHandler() http.Handler { func (e *EngineServer) handleSSZREST(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") - // trailing slashes are forbidden, so an empty last segment is method-not-found + // an empty last segment (e.g. a trailing slash) matches no route if len(parts) < 3 || parts[0] != "engine" || parts[1] != "v2" || parts[len(parts)-1] == "" { writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") return @@ -311,16 +310,15 @@ func (e *EngineServer) handleSSZBodiesByHash(w http.ResponseWriter, r *http.Requ if !ok { return } - if len(body) > sszMaxBodiesRequest*32 { + if len(body) > 4+sszMaxBodiesRequest*32 { writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many block hashes") return } - hashes := solid.NewHashList(sszMaxBodiesRequest) - if err := hashes.DecodeSSZ(body, 0); err != nil { + blockHashes, err := decodeHashListRequest(body, sszMaxBodiesRequest) + if err != nil { writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - blockHashes := hashListValues(hashes) bodies, err := e.chainRW.GetPayloadBodiesByHash(r.Context(), blockHashes) if err != nil { writeEngineProblem(w, err) @@ -370,14 +368,16 @@ func (e *EngineServer) handleSSZBodiesByRange(w http.ResponseWriter, r *http.Req writeEngineProblem(w, err) return } - // blocks outside the URL fork's time range or past the latest known block - // come back as available=false - entries := make([]*engine_types.ExecutionPayloadBodyV2, count) - for i := uint64(0); i < count; i++ { - if i >= uint64(len(bodies)) || bodies[i] == nil { + // The response is truncated at the latest known block: GetPayloadBodiesByRange + // stops at head and trims trailing nils, so past-head blocks are omitted rather + // than padded with available=false. In-range blocks outside the URL fork's time + // range still come back as available=false. + entries := make([]*engine_types.ExecutionPayloadBodyV2, len(bodies)) + for i := range bodies { + if bodies[i] == nil { continue } - header := e.chainRW.GetHeaderByNumber(r.Context(), from+i) + header := e.chainRW.GetHeaderByNumber(r.Context(), from+uint64(i)) if header == nil || forkNameAtTime(e.config, header.Time) != forkName { continue } @@ -404,16 +404,15 @@ func (e *EngineServer) handleSSZGetBlobs(w http.ResponseWriter, r *http.Request, if !ok { return } - if len(body) > sszMaxGetBlobHashes*32 { + if len(body) > 4+sszMaxGetBlobHashes*32 { writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many blob hashes") return } - hashes := solid.NewHashList(sszMaxGetBlobHashes) - if err := hashes.DecodeSSZ(body, 0); err != nil { + blobHashes, err := decodeHashListRequest(body, sszMaxGetBlobHashes) + if err != nil { writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - blobHashes := hashListValues(hashes) var out []byte switch revision { diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index bfb3175f4e5..23a45a405da 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -246,8 +246,8 @@ func TestSSZRESTForkchoiceUpdateRoundTrip(t *testing.T) { }) } -// Pin the exact wire example from execution-apis#793: a VALID PayloadStatus -// with latest_valid_hash present and no validation_error is 41 bytes. +// Pin the exact wire examples from execution-apis#793. Example A: a VALID +// PayloadStatus with latest_valid_hash present and no validation_error is 41 bytes. func TestSSZRESTPayloadStatusSpecExample(t *testing.T) { latest := common.HexToHash("0xcd") status := &engine_types.PayloadStatus{Status: engine_types.ValidStatus, LatestValidHash: &latest} @@ -258,6 +258,21 @@ func TestSSZRESTPayloadStatusSpecExample(t *testing.T) { require.Equal(t, uint32(9), binary.LittleEndian.Uint32(enc[1:5])) require.Equal(t, uint32(41), binary.LittleEndian.Uint32(enc[5:9])) require.Equal(t, latest[:], enc[9:41]) + + // Example B: INVALID with validation_error "bad state root" and no + // latest_valid_hash is 27 bytes; note the inner 4-byte offset before the text. + invalid := &engine_types.PayloadStatus{ + Status: engine_types.InvalidStatus, + ValidationError: engine_types.NewStringifiedErrorFromString("bad state root"), + } + encB, err := invalid.EncodeSSZ(nil) + require.NoError(t, err) + require.Len(t, encB, 27) + require.Equal(t, byte(1), encB[0]) + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(encB[1:5])) // offset[latest_valid_hash] + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(encB[5:9])) // offset[validation_error] + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(encB[9:13])) // inner String offset + require.Equal(t, []byte("bad state root"), encB[13:27]) } func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { @@ -327,15 +342,16 @@ func TestSSZRESTBuiltPayloadRoundTrip(t *testing.T) { for _, tc := range []struct { version clparams.StateVersion + wantSOB bool wantBundle bool wantRequests bool }{ - {clparams.BellatrixVersion, false, false}, - {clparams.CapellaVersion, false, false}, - {clparams.DenebVersion, true, false}, - {clparams.ElectraVersion, true, true}, - {clparams.FuluVersion, true, true}, - {clparams.GloasVersion, true, true}, + {clparams.BellatrixVersion, false, false, false}, + {clparams.CapellaVersion, true, false, false}, + {clparams.DenebVersion, true, true, false}, + {clparams.ElectraVersion, true, true, true}, + {clparams.FuluVersion, true, true, true}, + {clparams.GloasVersion, true, true, true}, } { t.Run(tc.version.String(), func(t *testing.T) { payload := engine_types.NewExecutionPayloadSSZ(tc.version) @@ -361,10 +377,10 @@ func TestSSZRESTBuiltPayloadRoundTrip(t *testing.T) { require.NoError(t, err) require.Equal(t, blockValue, out.BlockValue.ToInt()) require.Equal(t, payload.BlockHash, out.ExecutionPayload.BlockHash) + require.Equal(t, tc.wantSOB, out.ShouldOverrideBuilder) if tc.wantBundle { require.NotNil(t, out.BlobsBundle) require.Equal(t, bundle.Commitments, out.BlobsBundle.Commitments) - require.True(t, out.ShouldOverrideBuilder) } if tc.wantRequests { require.Equal(t, requests, out.ExecutionRequests) @@ -397,6 +413,9 @@ func TestSSZRESTBodiesResponseRoundTrip(t *testing.T) { t.Run(tc.version.String(), func(t *testing.T) { enc, err := encodeBodiesResponse([]*engine_types.ExecutionPayloadBodyV2{body, nil}, tc.version) require.NoError(t, err) + // BodiesResponse is a single-field container: the body opens with a + // 4-byte offset (=4) to the wrapped entries list. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) entries, err := decodeBodiesResponse(enc, tc.version) require.NoError(t, err) require.Len(t, entries, 2) @@ -432,6 +451,8 @@ func TestSSZRESTBlobsResponseRoundTrip(t *testing.T) { t.Run("v1", func(t *testing.T) { enc, err := encodeBlobsV1Response([]*engine_types.BlobAndProofV1{{Blob: blob, Proof: proof}, nil}) require.NoError(t, err) + // BlobsV1Response is a single-field container wrapping the entries list. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) out, err := decodeBlobsV1Response(enc) require.NoError(t, err) require.Len(t, out, 2) @@ -444,6 +465,8 @@ func TestSSZRESTBlobsResponseRoundTrip(t *testing.T) { t.Run("v2", func(t *testing.T) { enc, err := encodeBlobsV2Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) require.NoError(t, err) + // BlobsV2Response (shared by /v3) is a single-field container. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) out, err := decodeBlobsV2Response(enc) require.NoError(t, err) require.Len(t, out, 2) @@ -454,6 +477,28 @@ func TestSSZRESTBlobsResponseRoundTrip(t *testing.T) { }) } +func TestSSZRESTHashListRequestRoundTrip(t *testing.T) { + hashes := []common.Hash{common.HexToHash("0xaa"), common.HexToHash("0xbb")} + for _, limit := range []int{sszMaxBodiesRequest, sszMaxGetBlobHashes} { + enc, err := encodeHashListRequest(hashes, limit) + require.NoError(t, err) + // single-field container: a 4-byte offset (=4) precedes the hash list body + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) + require.Len(t, enc, 4+len(hashes)*32) + out, err := decodeHashListRequest(enc, limit) + require.NoError(t, err) + require.Equal(t, hashes, out) + } + + // an empty request is just the 4-byte offset, no hashes + enc, err := encodeHashListRequest(nil, sszMaxBodiesRequest) + require.NoError(t, err) + require.Len(t, enc, 4) + out, err := decodeHashListRequest(enc, sszMaxBodiesRequest) + require.NoError(t, err) + require.Empty(t, out) +} + func TestSSZRESTCapabilitiesRoute(t *testing.T) { srv := newTestEngineServer(allForksConfig(), false) req := httptest.NewRequest(http.MethodGet, "/engine/v2/capabilities", nil) @@ -474,9 +519,11 @@ func TestSSZRESTCapabilitiesRoute(t *testing.T) { require.Equal(t, []string{"payloads", "forkchoice", "bodies"}, caps.ForkScopedEndpoints) require.Equal(t, []string{"v1", "v2", "v3"}, caps.IndependentlyVersioned["blobs"]) require.Equal(t, []string{"capabilities", "identity"}, caps.UnscopedEndpoints) - require.Equal(t, uint64(sszMaxBodiesRequest), caps.Limits["bodies.max_count"]) - require.Equal(t, uint64(sszMaxGetBlobHashes), caps.Limits["blobs.max_versioned_hashes"]) - require.Equal(t, uint64(sszMaxRequestBody), caps.Limits["payload.max_bytes"]) + // pin the spec's MAX_* values: MAX_BODIES_REQUEST, MAX_VERSIONED_HASHES_PER_REQUEST, + // MAX_REQUEST_BODY_SIZE (2**26 = 64 MiB). + require.Equal(t, uint64(32), caps.Limits["bodies.max_count"]) + require.Equal(t, uint64(128), caps.Limits["blobs.max_versioned_hashes"]) + require.Equal(t, uint64(67108864), caps.Limits["payload.max_bytes"]) } func TestSSZRESTIdentityRoute(t *testing.T) { @@ -593,9 +640,10 @@ func TestSSZRESTForkchoiceURLForkMustMatchAttributes(t *testing.T) { func TestSSZRESTBlobsPoolDisabled(t *testing.T) { srv := newTestEngineServer(allForksConfig(), true) - hash := common.HexToHash("0x01") + body, err := encodeHashListRequest([]common.Hash{common.HexToHash("0x01")}, sszMaxGetBlobHashes) + require.NoError(t, err) rec := httptest.NewRecorder() - srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/blobs/v1", hash[:])) + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/blobs/v1", body)) require.Equal(t, http.StatusNoContent, rec.Code) require.Empty(t, rec.Body.Bytes()) } diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index f8c2a0edd89..f304e10ab25 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -32,7 +32,7 @@ import ( const ( sszMaxGetBlobHashes = 128 sszMaxBodiesRequest = 32 - sszMaxRequestBody = 128 << 20 + sszMaxRequestBody = 64 << 20 // MAX_REQUEST_BODY_SIZE = 2**26 sszBlobBytes = 0x20000 sszKZGBytes = 48 sszCellsPerExtBlob = 128 @@ -121,6 +121,24 @@ func hashListValues(l solid.HashListSSZ) []common.Hash { return out } +// BodiesByHashRequest {block_hashes} and BlobsVNRequest {versioned_hashes} are +// single-field containers wrapping a List[Hash32, limit]; they share this codec. +func encodeHashListRequest(hashes []common.Hash, limit int) ([]byte, error) { + list := solid.NewHashList(limit) + for _, hash := range hashes { + list.Append(hash) + } + return ssz2.MarshalSSZ(nil, list) +} + +func decodeHashListRequest(buf []byte, limit int) ([]common.Hash, error) { + list := solid.NewHashList(limit) + if err := ssz2.UnmarshalSSZ(buf, 0, list); err != nil { + return nil, err + } + return hashListValues(list), nil +} + func transactionsBytes(txs *solid.TransactionsSSZ) []hexutil.Bytes { if txs == nil { return nil @@ -297,7 +315,8 @@ func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersio // BuiltPayload is the response of GET /{fork}/payloads/{payloadId}: // {payload, block_value, blobs_bundle, execution_requests, should_override_builder}, -// with blobs_bundle existing since Cancun and execution_requests since Prague. +// with should_override_builder existing since Shanghai, blobs_bundle since Cancun +// and execution_requests since Prague. func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { payload := resp.ExecutionPayload payload.SSZVersion = version @@ -305,8 +324,10 @@ func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams. blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) requests := newTransactionsSSZ(resp.ExecutionRequests) switch { - case version < clparams.DenebVersion: + case version < clparams.CapellaVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:]) + case version < clparams.DenebVersion: + return ssz2.MarshalSSZ(nil, payload, blockValue[:], resp.ShouldOverrideBuilder) case version < clparams.ElectraVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder) default: @@ -322,8 +343,10 @@ func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_type shouldOverride := false var err error switch { - case version < clparams.DenebVersion: + case version < clparams.CapellaVersion: err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:]) + case version < clparams.DenebVersion: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], &shouldOverride) case version < clparams.ElectraVersion: err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, &shouldOverride) default: @@ -471,7 +494,8 @@ func (e *sszBodyEntry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } func (*sszBodyEntry) Clone() clonable.Clonable { return &sszBodyEntry{} } // The response of POST /{fork}/bodies/hash and GET /{fork}/bodies is -// List[BodyEntry, MAX_BODIES_REQUEST]; nil bodies become available=false. +// BodiesResponse {entries: List[BodyEntry, MAX_BODIES_REQUEST]}; nil bodies +// become available=false. func encodeBodiesResponse(bodies []*engine_types.ExecutionPayloadBodyV2, version clparams.StateVersion) ([]byte, error) { entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) for _, body := range bodies { @@ -484,12 +508,12 @@ func encodeBodiesResponse(bodies []*engine_types.ExecutionPayloadBodyV2, version } entries.Append(entry) } - return entries.EncodeSSZ(nil) + return ssz2.MarshalSSZ(nil, entries) } func decodeBodiesResponse(buf []byte, version clparams.StateVersion) ([]*sszBodyEntry, error) { entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) - if err := entries.DecodeSSZ(buf, int(version)); err != nil { + if err := ssz2.UnmarshalSSZ(buf, int(version), entries); err != nil { return nil, err } out := make([]*sszBodyEntry, 0, entries.Len()) @@ -566,8 +590,9 @@ func (e *sszBlobV2Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } func (*sszBlobV2Entry) Clone() clonable.Clonable { return &sszBlobV2Entry{} } -// The response of POST /blobs/vN is List[BlobEntry, MAX_BLOBS_REQUEST]; -// nil contents become available=false with zero-valued contents. +// The response of POST /blobs/vN is BlobsVNResponse {entries: +// List[BlobVNEntry, MAX_BLOBS_REQUEST]}; nil contents become available=false +// with zero-valued contents. func encodeBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) { entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) for _, blob := range blobs { @@ -579,12 +604,12 @@ func encodeBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) } entries.Append(entry) } - return entries.EncodeSSZ(nil) + return ssz2.MarshalSSZ(nil, entries) } func decodeBlobsV1Response(buf []byte) ([]*engine_types.BlobAndProofV1, error) { entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) - if err := entries.DecodeSSZ(buf, 0); err != nil { + if err := ssz2.UnmarshalSSZ(buf, 0, entries); err != nil { return nil, err } out := make([]*engine_types.BlobAndProofV1, entries.Len()) @@ -602,12 +627,12 @@ func encodeBlobsV2Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) for _, blob := range blobs { entries.Append(&sszBlobV2Entry{Available: blob != nil, Contents: blob}) } - return entries.EncodeSSZ(nil) + return ssz2.MarshalSSZ(nil, entries) } func decodeBlobsV2Response(buf []byte) ([]*engine_types.BlobAndProofV2, error) { entries := solid.NewDynamicListSSZ[*sszBlobV2Entry](sszMaxGetBlobHashes) - if err := entries.DecodeSSZ(buf, 0); err != nil { + if err := ssz2.UnmarshalSSZ(buf, 0, entries); err != nil { return nil, err } out := make([]*engine_types.BlobAndProofV2, entries.Len()) From 82f088d65595e2860c8a0d03d9f240c325d554e4 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 15 Jun 2026 12:33:01 +0200 Subject: [PATCH 03/11] engineapi: enforce hash-list limit on decode, reset PayloadStatus optionals Address review feedback on the SSZ-REST transport: - decodeHashListRequest now rejects lists longer than its limit instead of relying on a caller-side size guard (hashList.DecodeSSZ accepts any multiple-of-32 length without capping). - PayloadStatus.DecodeSSZ clears latest_valid_hash and validation_error before decoding so a reused instance can't leak stale optionals. --- execution/engineapi/engine_types/ssz.go | 2 ++ execution/engineapi/sszrest_test.go | 31 +++++++++++++++++++++++++ execution/engineapi/sszrest_wire.go | 3 +++ 3 files changed, 36 insertions(+) diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go index ca38455e886..f9190a082d9 100644 --- a/execution/engineapi/engine_types/ssz.go +++ b/execution/engineapi/engine_types/ssz.go @@ -269,10 +269,12 @@ func (s *PayloadStatus) DecodeSSZ(buf []byte, version int) error { return err } s.Status = engineStatus + s.LatestValidHash = nil if latest.Length() > 0 { hash := latest.Get(0) s.LatestValidHash = &hash } + s.ValidationError = nil if validationErr.value != nil { s.ValidationError = NewStringifiedErrorFromString(*validationErr.value) } diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index 23a45a405da..42211a41e6c 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -497,6 +497,37 @@ func TestSSZRESTHashListRequestRoundTrip(t *testing.T) { out, err := decodeHashListRequest(enc, sszMaxBodiesRequest) require.NoError(t, err) require.Empty(t, out) + + // decode enforces the limit itself, independent of any caller-side size guard + oversized, err := encodeHashListRequest(make([]common.Hash, sszMaxBodiesRequest+1), sszMaxBodiesRequest+1) + require.NoError(t, err) + _, err = decodeHashListRequest(oversized, sszMaxBodiesRequest) + require.Error(t, err) +} + +// A reused PayloadStatus must not leak optional fields from a previous decode. +func TestSSZRESTPayloadStatusDecodeResetsOptionals(t *testing.T) { + latest := common.HexToHash("0xab") + full := &engine_types.PayloadStatus{ + Status: engine_types.InvalidStatus, + LatestValidHash: &latest, + ValidationError: engine_types.NewStringifiedErrorFromString("bad"), + } + encFull, err := full.EncodeSSZ(nil) + require.NoError(t, err) + bare := &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + encBare, err := bare.EncodeSSZ(nil) + require.NoError(t, err) + + var reused engine_types.PayloadStatus + require.NoError(t, reused.DecodeSSZ(encFull, 0)) + require.NotNil(t, reused.LatestValidHash) + require.NotNil(t, reused.ValidationError) + + require.NoError(t, reused.DecodeSSZ(encBare, 0)) + require.Equal(t, engine_types.SyncingStatus, reused.Status) + require.Nil(t, reused.LatestValidHash) + require.Nil(t, reused.ValidationError) } func TestSSZRESTCapabilitiesRoute(t *testing.T) { diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index f304e10ab25..96a1fc33655 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -136,6 +136,9 @@ func decodeHashListRequest(buf []byte, limit int) ([]common.Hash, error) { if err := ssz2.UnmarshalSSZ(buf, 0, list); err != nil { return nil, err } + if list.Length() > limit { + return nil, fmt.Errorf("hash list length %d exceeds limit %d", list.Length(), limit) + } return hashListValues(list), nil } From d255e089ede715b177e36f63e1e01d0ac111433a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 12:25:36 +0200 Subject: [PATCH 04/11] engineapi: move should_override_builder to Cancun in BuiltPayload SSZ Track execution-apis#793 commit e509f20f9e ("advertise all forks"), which corrected the per-fork BuiltPayload catalogue: should_override_builder is introduced at Cancun (getPayloadV3), alongside blobs_bundle, not at Shanghai. BuiltPayloadShanghai is now the Paris shape {payload, block_value}; should_override_builder first appears from Cancun on. --- execution/engineapi/sszrest_test.go | 2 +- execution/engineapi/sszrest_wire.go | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index 42211a41e6c..7bae0c1d9d7 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -347,7 +347,7 @@ func TestSSZRESTBuiltPayloadRoundTrip(t *testing.T) { wantRequests bool }{ {clparams.BellatrixVersion, false, false, false}, - {clparams.CapellaVersion, true, false, false}, + {clparams.CapellaVersion, false, false, false}, {clparams.DenebVersion, true, true, false}, {clparams.ElectraVersion, true, true, true}, {clparams.FuluVersion, true, true, true}, diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index 96a1fc33655..ebbe6a58fca 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -318,8 +318,8 @@ func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersio // BuiltPayload is the response of GET /{fork}/payloads/{payloadId}: // {payload, block_value, blobs_bundle, execution_requests, should_override_builder}, -// with should_override_builder existing since Shanghai, blobs_bundle since Cancun -// and execution_requests since Prague. +// with blobs_bundle and should_override_builder since Cancun and +// execution_requests since Prague. func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { payload := resp.ExecutionPayload payload.SSZVersion = version @@ -327,10 +327,8 @@ func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams. blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) requests := newTransactionsSSZ(resp.ExecutionRequests) switch { - case version < clparams.CapellaVersion: - return ssz2.MarshalSSZ(nil, payload, blockValue[:]) case version < clparams.DenebVersion: - return ssz2.MarshalSSZ(nil, payload, blockValue[:], resp.ShouldOverrideBuilder) + return ssz2.MarshalSSZ(nil, payload, blockValue[:]) case version < clparams.ElectraVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder) default: @@ -346,10 +344,8 @@ func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_type shouldOverride := false var err error switch { - case version < clparams.CapellaVersion: - err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:]) case version < clparams.DenebVersion: - err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], &shouldOverride) + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:]) case version < clparams.ElectraVersion: err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, &shouldOverride) default: From 7f5d0083c296cd24079e3c92f3b111a4e8224a26 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 15:44:12 +0200 Subject: [PATCH 05/11] engineapi: add geth byte-compatibility golden vectors for REST-SSZ Borrow the payload/attributes/body golden vectors from go-ethereum's REST-SSZ engine API (ethereum/go-ethereum#35171, beacon/engine/ssz/bytecompat_test.go @ fcefb32655) and assert Erigon reproduces the exact bytes on each fork (paris/shanghai/cancun/amsterdam). Locks cross-client wire parity into CI: base_fee LE, withdrawals, blob-gas, and the Amsterdam block_access_list/slot_number/target_gas_limit fields. --- .../engineapi/sszrest_bytecompat_test.go | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 execution/engineapi/sszrest_bytecompat_test.go diff --git a/execution/engineapi/sszrest_bytecompat_test.go b/execution/engineapi/sszrest_bytecompat_test.go new file mode 100644 index 00000000000..964fe231e77 --- /dev/null +++ b/execution/engineapi/sszrest_bytecompat_test.go @@ -0,0 +1,163 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. + +package engineapi + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/types" +) + +// Golden vectors shared with go-ethereum's REST-SSZ engine API: encoding the same +// sample data on the same fork must reproduce these exact bytes (cross-client wire parity). +const ( + gethPayloadParis = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f1536500000000fc01000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000ff010000657874080000000a000000020304" + gethPayloadShanghai = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000000002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000030200000e020000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + gethPayloadCancun = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000130200001e02000000000200000000000000000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + gethPayloadAmsterdam = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001c02000000863ba10100000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000001f0200002a0200000000020000000000000000000000000056020000c800000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" + + gethAttrsParis = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000" + gethAttrsShanghai = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000400000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + gethAttrsCancun = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000006000000077000000000000000000000000000000000000000000000000000000000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + gethAttrsAmsterdam = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000007000000077000000000000000000000000000000000000000000000000000000000000002a0000000000000080c3c901000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + + gethBodyParis = "04000000080000000a000000020304" + gethBodyCancun = "0800000013000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + gethBodyAmsterdam = "0c0000001700000043000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" +) + +func gethSampleWithdrawals() []*types.Withdrawal { + return []*types.Withdrawal{{Index: 1, Validator: 2, Address: common.Address{0x09}, Amount: 3}} +} + +func gethSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPayload { + p := engine_types.NewExecutionPayloadSSZ(version) + p.ParentHash = common.Hash{0x11} + p.FeeRecipient = common.Address{0x22} + p.StateRoot = common.Hash{0x33} + p.ReceiptsRoot = common.Hash{0x44} + p.LogsBloom = make(hexutil.Bytes, 256) + p.PrevRandao = common.Hash{0x55} + p.BlockNumber = 100 + p.GasLimit = 30000000 + p.GasUsed = 21000 + p.Timestamp = 1700000000 + p.ExtraData = hexutil.Bytes("ext") + p.BaseFeePerGas = (*hexutil.Big)(big.NewInt(7_000_000_000)) + p.BlockHash = common.Hash{0x66} + p.Transactions = []hexutil.Bytes{{0x02, 0x03}, {0x04}} + if version >= clparams.CapellaVersion { + p.Withdrawals = gethSampleWithdrawals() + } + if version >= clparams.DenebVersion { + blobGasUsed, excessBlobGas := hexutil.Uint64(131072), hexutil.Uint64(0) + p.BlobGasUsed, p.ExcessBlobGas = &blobGasUsed, &excessBlobGas + } + if version >= clparams.GloasVersion { + slot := hexutil.Uint64(200) + bal := hexutil.Bytes{0xde, 0xad, 0xbe, 0xef} + p.SlotNumber, p.BlockAccessList = &slot, &bal + } + return p +} + +func gethSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttributes { + a := &engine_types.PayloadAttributes{ + Timestamp: 1700000000, + PrevRandao: common.Hash{0x55}, + SuggestedFeeRecipient: common.Address{0x22}, + SSZVersion: version, + } + if version >= clparams.CapellaVersion { + a.Withdrawals = gethSampleWithdrawals() + } + if version >= clparams.DenebVersion { + root := common.Hash{0x77} + a.ParentBeaconBlockRoot = &root + } + if version >= clparams.GloasVersion { + slot, targetGasLimit := hexutil.Uint64(42), hexutil.Uint64(30000000) + a.SlotNumber, a.TargetGasLimit = &slot, &targetGasLimit + } + return a +} + +func gethSampleBody(t *testing.T, version clparams.StateVersion) *sszPayloadBody { + t.Helper() + b := newSSZPayloadBody(version) + b.transactions = newTransactionsSSZ([]hexutil.Bytes{{0x02, 0x03}, {0x04}}) + if version >= clparams.CapellaVersion { + b.withdrawals = newWithdrawalListSSZ(gethSampleWithdrawals()) + } + if version >= clparams.GloasVersion { + require.NoError(t, b.blockAccessList.SetBytes([]byte{0xde, 0xad, 0xbe, 0xef})) + } + return b +} + +func TestSSZRESTByteCompatWithGeth(t *testing.T) { + t.Run("payload", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, gethPayloadParis}, + {"shanghai", clparams.CapellaVersion, gethPayloadShanghai}, + {"cancun", clparams.DenebVersion, gethPayloadCancun}, + {"amsterdam", clparams.GloasVersion, gethPayloadAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := gethSamplePayload(tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) + + t.Run("attributes", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, gethAttrsParis}, + {"shanghai", clparams.CapellaVersion, gethAttrsShanghai}, + {"cancun", clparams.DenebVersion, gethAttrsCancun}, + {"amsterdam", clparams.GloasVersion, gethAttrsAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := gethSampleAttrs(tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) + + t.Run("body", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, gethBodyParis}, + {"cancun", clparams.DenebVersion, gethBodyCancun}, + {"amsterdam", clparams.GloasVersion, gethBodyAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := gethSampleBody(t, tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) +} From 945e5f9dee12c26e2c0c65c9871480d4056fa697 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 15:55:47 +0200 Subject: [PATCH 06/11] engineapi, cl/cltypes/solid: bound execution_requests at MAX_EXECUTION_REQUESTS_PER_PAYLOAD execution_requests was built via the shared TransactionsSSZ helper with unset limits, so decode fell back to the transaction default (2**20) rather than the spec bound MAX_EXECUTION_REQUESTS_PER_PAYLOAD (2**8 = 256). Add a dedicated 256 / 2**30-bounded list for the requests list in newPayload and getPayload, matching execution-apis#793 and go-ethereum; payload/body transactions keep the transaction-list bound. --- cl/cltypes/solid/transactions.go | 8 ++++++++ execution/engineapi/sszrest_test.go | 24 ++++++++++++++++++++++++ execution/engineapi/sszrest_wire.go | 23 +++++++++++++++++++---- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/cl/cltypes/solid/transactions.go b/cl/cltypes/solid/transactions.go index 3a6bc297a1c..f2f43b7d02d 100644 --- a/cl/cltypes/solid/transactions.go +++ b/cl/cltypes/solid/transactions.go @@ -170,6 +170,14 @@ func NewTransactionsSSZWithLimits(maxTransactionsPerPayload, maxBytesPerTransact } } +func NewTransactionsSSZFromTransactionsWithLimits(txs [][]byte, maxTransactionsPerPayload, maxBytesPerTransaction uint64) *TransactionsSSZ { + return &TransactionsSSZ{ + underlying: txs, + maxTransactionsPerPayload: maxTransactionsPerPayload, + maxBytesPerTransaction: maxBytesPerTransaction, + } +} + func (t *TransactionsSSZ) maxTransactions() uint64 { if t.maxTransactionsPerPayload != 0 { return t.maxTransactionsPerPayload diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index a3588f773bb..dbd7530044e 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -724,3 +724,27 @@ func TestExchangeCapabilitiesDropsLegacySSZEndpoints(t *testing.T) { require.NotContains(t, capability, "/engine/v") } } + +func TestSSZRESTExecutionRequestsBound(t *testing.T) { + // execution_requests is bounded at MAX_EXECUTION_REQUESTS_PER_PAYLOAD (256); + // a longer list must be rejected on decode. + mkRequests := func(n int) []hexutil.Bytes { + reqs := make([]hexutil.Bytes, n) + for i := range reqs { + reqs[i] = hexutil.Bytes{byte(i)} + } + return reqs + } + payload := engine_types.NewExecutionPayloadSSZ(clparams.ElectraVersion) + + enc, err := encodeNewPayloadEnvelope(clparams.ElectraVersion, payload, common.Hash{}, mkRequests(256)) + require.NoError(t, err) + _, _, out, err := decodeNewPayloadEnvelope(enc, clparams.ElectraVersion) + require.NoError(t, err) + require.Len(t, out, 256) + + enc, err = encodeNewPayloadEnvelope(clparams.ElectraVersion, payload, common.Hash{}, mkRequests(257)) + require.NoError(t, err) + _, _, _, err = decodeNewPayloadEnvelope(enc, clparams.ElectraVersion) + require.Error(t, err) +} diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index ebbe6a58fca..e014905ff0e 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -162,6 +162,21 @@ func newTransactionsSSZ(txs []hexutil.Bytes) *solid.TransactionsSSZ { return solid.NewTransactionsSSZFromTransactions(binaryTxs) } +const ( + sszMaxExecutionRequests = 1 << 8 // MAX_EXECUTION_REQUESTS_PER_PAYLOAD + sszMaxBytesPerExecutionRequest = 1 << 30 // MAX_BYTES_PER_EXECUTION_REQUEST +) + +// newExecutionRequestsSSZ bounds the list at MAX_EXECUTION_REQUESTS_PER_PAYLOAD, +// unlike newTransactionsSSZ which uses the larger transaction-list bound. +func newExecutionRequestsSSZ(requests []hexutil.Bytes) *solid.TransactionsSSZ { + binary := make([][]byte, len(requests)) + for i, r := range requests { + binary[i] = r + } + return solid.NewTransactionsSSZFromTransactionsWithLimits(binary, sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) +} + // ExecutionPayloadEnvelope is the request body of POST /{fork}/payloads. // parent_beacon_block_root exists since Cancun, execution_requests since // Prague; expected blob versioned hashes are recomputed from the transactions. @@ -179,7 +194,7 @@ func newPayloadEnvelopeSchema(version clparams.StateVersion, payload *engine_typ func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, common.Hash, []hexutil.Bytes, error) { payload := engine_types.NewExecutionPayloadSSZ(version) parentRoot := common.Hash{} - requests := &solid.TransactionsSSZ{} + requests := newExecutionRequestsSSZ(nil) if err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadEnvelopeSchema(version, payload, &parentRoot, requests)...); err != nil { return nil, common.Hash{}, nil, err } @@ -187,7 +202,7 @@ func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engin } func encodeNewPayloadEnvelope(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot common.Hash, requests []hexutil.Bytes) ([]byte, error) { - return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newTransactionsSSZ(requests))...) + return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newExecutionRequestsSSZ(requests))...) } // blobVersionedHashesFromTxs recomputes the versioned hashes the legacy API @@ -325,7 +340,7 @@ func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams. payload.SSZVersion = version blockValue := blockValueHash(resp.BlockValue) blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) - requests := newTransactionsSSZ(resp.ExecutionRequests) + requests := newExecutionRequestsSSZ(resp.ExecutionRequests) switch { case version < clparams.DenebVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:]) @@ -340,7 +355,7 @@ func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_type payload := engine_types.NewExecutionPayloadSSZ(version) blockValue := common.Hash{} blobsBundle := engine_types.NewBlobsBundleSSZ(version) - requests := &solid.TransactionsSSZ{} + requests := newExecutionRequestsSSZ(nil) shouldOverride := false var err error switch { From d19d2af7823ec9c47632b574e103e2d6972b9eab Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 16:18:43 +0200 Subject: [PATCH 07/11] engineapi: rename byte-compat vectors to golden, credit go-ethereum in header Rename the geth* identifiers in sszrest_bytecompat_test.go to golden*, and move the go-ethereum attribution for the borrowed vectors into a dual-copyright file header (go-ethereum Authors for the golden vectors, Erigon Authors for the test harness), matching execution/abi/abi.go's style. --- .../engineapi/sszrest_bytecompat_test.go | 87 +++++++++++-------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/execution/engineapi/sszrest_bytecompat_test.go b/execution/engineapi/sszrest_bytecompat_test.go index 964fe231e77..221217dab0a 100644 --- a/execution/engineapi/sszrest_bytecompat_test.go +++ b/execution/engineapi/sszrest_bytecompat_test.go @@ -1,5 +1,21 @@ +// Copyright 2026 The go-ethereum Authors +// (golden test vectors) // Copyright 2026 The Erigon Authors +// (test harness) // This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . package engineapi @@ -17,29 +33,30 @@ import ( "github.com/erigontech/erigon/execution/types" ) -// Golden vectors shared with go-ethereum's REST-SSZ engine API: encoding the same -// sample data on the same fork must reproduce these exact bytes (cross-client wire parity). +// Golden SSZ vectors lifted from go-ethereum's REST-SSZ engine API +// (beacon/engine/ssz/bytecompat_test.go): encoding the same sample data on the +// same fork must reproduce these bytes — cross-client wire parity. const ( - gethPayloadParis = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f1536500000000fc01000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000ff010000657874080000000a000000020304" - gethPayloadShanghai = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000000002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000030200000e020000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethPayloadCancun = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000130200001e02000000000200000000000000000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethPayloadAmsterdam = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001c02000000863ba10100000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000001f0200002a0200000000020000000000000000000000000056020000c800000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" + goldenPayloadParis = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f1536500000000fc01000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000ff010000657874080000000a000000020304" + goldenPayloadShanghai = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000000002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000030200000e020000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenPayloadCancun = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000130200001e02000000000200000000000000000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenPayloadAmsterdam = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001c02000000863ba10100000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000001f0200002a0200000000020000000000000000000000000056020000c800000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" - gethAttrsParis = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000" - gethAttrsShanghai = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000400000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethAttrsCancun = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000006000000077000000000000000000000000000000000000000000000000000000000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethAttrsAmsterdam = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000007000000077000000000000000000000000000000000000000000000000000000000000002a0000000000000080c3c901000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenAttrsParis = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000" + goldenAttrsShanghai = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000400000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenAttrsCancun = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000006000000077000000000000000000000000000000000000000000000000000000000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenAttrsAmsterdam = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000007000000077000000000000000000000000000000000000000000000000000000000000002a0000000000000080c3c901000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethBodyParis = "04000000080000000a000000020304" - gethBodyCancun = "0800000013000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" - gethBodyAmsterdam = "0c0000001700000043000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" + goldenBodyParis = "04000000080000000a000000020304" + goldenBodyCancun = "0800000013000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenBodyAmsterdam = "0c0000001700000043000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" ) -func gethSampleWithdrawals() []*types.Withdrawal { +func goldenSampleWithdrawals() []*types.Withdrawal { return []*types.Withdrawal{{Index: 1, Validator: 2, Address: common.Address{0x09}, Amount: 3}} } -func gethSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPayload { +func goldenSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPayload { p := engine_types.NewExecutionPayloadSSZ(version) p.ParentHash = common.Hash{0x11} p.FeeRecipient = common.Address{0x22} @@ -56,7 +73,7 @@ func gethSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPay p.BlockHash = common.Hash{0x66} p.Transactions = []hexutil.Bytes{{0x02, 0x03}, {0x04}} if version >= clparams.CapellaVersion { - p.Withdrawals = gethSampleWithdrawals() + p.Withdrawals = goldenSampleWithdrawals() } if version >= clparams.DenebVersion { blobGasUsed, excessBlobGas := hexutil.Uint64(131072), hexutil.Uint64(0) @@ -70,7 +87,7 @@ func gethSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPay return p } -func gethSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttributes { +func goldenSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttributes { a := &engine_types.PayloadAttributes{ Timestamp: 1700000000, PrevRandao: common.Hash{0x55}, @@ -78,7 +95,7 @@ func gethSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttribu SSZVersion: version, } if version >= clparams.CapellaVersion { - a.Withdrawals = gethSampleWithdrawals() + a.Withdrawals = goldenSampleWithdrawals() } if version >= clparams.DenebVersion { root := common.Hash{0x77} @@ -91,12 +108,12 @@ func gethSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttribu return a } -func gethSampleBody(t *testing.T, version clparams.StateVersion) *sszPayloadBody { +func goldenSampleBody(t *testing.T, version clparams.StateVersion) *sszPayloadBody { t.Helper() b := newSSZPayloadBody(version) b.transactions = newTransactionsSSZ([]hexutil.Bytes{{0x02, 0x03}, {0x04}}) if version >= clparams.CapellaVersion { - b.withdrawals = newWithdrawalListSSZ(gethSampleWithdrawals()) + b.withdrawals = newWithdrawalListSSZ(goldenSampleWithdrawals()) } if version >= clparams.GloasVersion { require.NoError(t, b.blockAccessList.SetBytes([]byte{0xde, 0xad, 0xbe, 0xef})) @@ -104,20 +121,20 @@ func gethSampleBody(t *testing.T, version clparams.StateVersion) *sszPayloadBody return b } -func TestSSZRESTByteCompatWithGeth(t *testing.T) { +func TestSSZRESTByteCompatWithGolden(t *testing.T) { t.Run("payload", func(t *testing.T) { for _, tc := range []struct { name string version clparams.StateVersion want string }{ - {"paris", clparams.BellatrixVersion, gethPayloadParis}, - {"shanghai", clparams.CapellaVersion, gethPayloadShanghai}, - {"cancun", clparams.DenebVersion, gethPayloadCancun}, - {"amsterdam", clparams.GloasVersion, gethPayloadAmsterdam}, + {"paris", clparams.BellatrixVersion, goldenPayloadParis}, + {"shanghai", clparams.CapellaVersion, goldenPayloadShanghai}, + {"cancun", clparams.DenebVersion, goldenPayloadCancun}, + {"amsterdam", clparams.GloasVersion, goldenPayloadAmsterdam}, } { t.Run(tc.name, func(t *testing.T) { - enc, err := gethSamplePayload(tc.version).EncodeSSZ(nil) + enc, err := goldenSamplePayload(tc.version).EncodeSSZ(nil) require.NoError(t, err) require.Equal(t, tc.want, hex.EncodeToString(enc)) }) @@ -130,13 +147,13 @@ func TestSSZRESTByteCompatWithGeth(t *testing.T) { version clparams.StateVersion want string }{ - {"paris", clparams.BellatrixVersion, gethAttrsParis}, - {"shanghai", clparams.CapellaVersion, gethAttrsShanghai}, - {"cancun", clparams.DenebVersion, gethAttrsCancun}, - {"amsterdam", clparams.GloasVersion, gethAttrsAmsterdam}, + {"paris", clparams.BellatrixVersion, goldenAttrsParis}, + {"shanghai", clparams.CapellaVersion, goldenAttrsShanghai}, + {"cancun", clparams.DenebVersion, goldenAttrsCancun}, + {"amsterdam", clparams.GloasVersion, goldenAttrsAmsterdam}, } { t.Run(tc.name, func(t *testing.T) { - enc, err := gethSampleAttrs(tc.version).EncodeSSZ(nil) + enc, err := goldenSampleAttrs(tc.version).EncodeSSZ(nil) require.NoError(t, err) require.Equal(t, tc.want, hex.EncodeToString(enc)) }) @@ -149,12 +166,12 @@ func TestSSZRESTByteCompatWithGeth(t *testing.T) { version clparams.StateVersion want string }{ - {"paris", clparams.BellatrixVersion, gethBodyParis}, - {"cancun", clparams.DenebVersion, gethBodyCancun}, - {"amsterdam", clparams.GloasVersion, gethBodyAmsterdam}, + {"paris", clparams.BellatrixVersion, goldenBodyParis}, + {"cancun", clparams.DenebVersion, goldenBodyCancun}, + {"amsterdam", clparams.GloasVersion, goldenBodyAmsterdam}, } { t.Run(tc.name, func(t *testing.T) { - enc, err := gethSampleBody(t, tc.version).EncodeSSZ(nil) + enc, err := goldenSampleBody(t, tc.version).EncodeSSZ(nil) require.NoError(t, err) require.Equal(t, tc.want, hex.EncodeToString(enc)) }) From bbd83d8ca1f8b3f25b297632bb30dfe8c9e4bf19 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 16:30:40 +0200 Subject: [PATCH 08/11] engineapi, cl/cltypes/solid: simplify execution_requests bound to decode-only TransactionsSSZ.EncodeSSZ ignores the list limit, so bounding only the decode path suffices: the two decode sites now use the existing NewTransactionsSSZWithLimits, and the dedicated newExecutionRequestsSSZ helper plus the unused NewTransactionsSSZFromTransactionsWithLimits constructor are removed. Behavior unchanged (over-256 requests rejected on decode); reverts the no-op encode-site changes and drops the cl/cltypes/solid addition. --- cl/cltypes/solid/transactions.go | 8 -------- execution/engineapi/sszrest_wire.go | 19 +++++-------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/cl/cltypes/solid/transactions.go b/cl/cltypes/solid/transactions.go index f2f43b7d02d..3a6bc297a1c 100644 --- a/cl/cltypes/solid/transactions.go +++ b/cl/cltypes/solid/transactions.go @@ -170,14 +170,6 @@ func NewTransactionsSSZWithLimits(maxTransactionsPerPayload, maxBytesPerTransact } } -func NewTransactionsSSZFromTransactionsWithLimits(txs [][]byte, maxTransactionsPerPayload, maxBytesPerTransaction uint64) *TransactionsSSZ { - return &TransactionsSSZ{ - underlying: txs, - maxTransactionsPerPayload: maxTransactionsPerPayload, - maxBytesPerTransaction: maxBytesPerTransaction, - } -} - func (t *TransactionsSSZ) maxTransactions() uint64 { if t.maxTransactionsPerPayload != 0 { return t.maxTransactionsPerPayload diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index e014905ff0e..4e782fdacce 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -162,21 +162,12 @@ func newTransactionsSSZ(txs []hexutil.Bytes) *solid.TransactionsSSZ { return solid.NewTransactionsSSZFromTransactions(binaryTxs) } +// execution_requests list bounds; enforced on decode (TransactionsSSZ.EncodeSSZ ignores limits). const ( sszMaxExecutionRequests = 1 << 8 // MAX_EXECUTION_REQUESTS_PER_PAYLOAD sszMaxBytesPerExecutionRequest = 1 << 30 // MAX_BYTES_PER_EXECUTION_REQUEST ) -// newExecutionRequestsSSZ bounds the list at MAX_EXECUTION_REQUESTS_PER_PAYLOAD, -// unlike newTransactionsSSZ which uses the larger transaction-list bound. -func newExecutionRequestsSSZ(requests []hexutil.Bytes) *solid.TransactionsSSZ { - binary := make([][]byte, len(requests)) - for i, r := range requests { - binary[i] = r - } - return solid.NewTransactionsSSZFromTransactionsWithLimits(binary, sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) -} - // ExecutionPayloadEnvelope is the request body of POST /{fork}/payloads. // parent_beacon_block_root exists since Cancun, execution_requests since // Prague; expected blob versioned hashes are recomputed from the transactions. @@ -194,7 +185,7 @@ func newPayloadEnvelopeSchema(version clparams.StateVersion, payload *engine_typ func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, common.Hash, []hexutil.Bytes, error) { payload := engine_types.NewExecutionPayloadSSZ(version) parentRoot := common.Hash{} - requests := newExecutionRequestsSSZ(nil) + requests := solid.NewTransactionsSSZWithLimits(sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) if err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadEnvelopeSchema(version, payload, &parentRoot, requests)...); err != nil { return nil, common.Hash{}, nil, err } @@ -202,7 +193,7 @@ func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engin } func encodeNewPayloadEnvelope(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot common.Hash, requests []hexutil.Bytes) ([]byte, error) { - return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newExecutionRequestsSSZ(requests))...) + return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newTransactionsSSZ(requests))...) } // blobVersionedHashesFromTxs recomputes the versioned hashes the legacy API @@ -340,7 +331,7 @@ func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams. payload.SSZVersion = version blockValue := blockValueHash(resp.BlockValue) blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) - requests := newExecutionRequestsSSZ(resp.ExecutionRequests) + requests := newTransactionsSSZ(resp.ExecutionRequests) switch { case version < clparams.DenebVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:]) @@ -355,7 +346,7 @@ func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_type payload := engine_types.NewExecutionPayloadSSZ(version) blockValue := common.Hash{} blobsBundle := engine_types.NewBlobsBundleSSZ(version) - requests := newExecutionRequestsSSZ(nil) + requests := solid.NewTransactionsSSZWithLimits(sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) shouldOverride := false var err error switch { From f13fff67d3c5f5d56a0abf562d713450c93530f1 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 18 Jun 2026 16:36:48 +0200 Subject: [PATCH 09/11] engineapi: distinguish oversize from other read errors in readSSZBody Address PR review feedback: readSSZBody mapped every io.ReadAll error to 413 request-too-large, mislabeling non-size read errors (truncated body, client disconnect). Now only *http.MaxBytesError yields 413; other read errors yield 400 invalid-request. Also pass the ResponseWriter to http.MaxBytesReader so the server can signal Connection: close on overflow. --- execution/engineapi/sszrest_handler.go | 9 +++++++-- execution/engineapi/sszrest_test.go | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/execution/engineapi/sszrest_handler.go b/execution/engineapi/sszrest_handler.go index c90793ef149..7e9001ee3fa 100644 --- a/execution/engineapi/sszrest_handler.go +++ b/execution/engineapi/sszrest_handler.go @@ -162,9 +162,14 @@ func checkSSZContentType(w http.ResponseWriter, r *http.Request) bool { func readSSZBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { defer r.Body.Close() - body, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, sszMaxRequestBody)) + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, sszMaxRequestBody)) if err != nil { - writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + } else { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) + } return nil, false } return body, true diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index dbd7530044e..c999b912448 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "testing" + "testing/iotest" "github.com/holiman/uint256" "github.com/stretchr/testify/require" @@ -748,3 +749,14 @@ func TestSSZRESTExecutionRequestsBound(t *testing.T) { _, _, _, err = decodeNewPayloadEnvelope(enc, clparams.ElectraVersion) require.Error(t, err) } + +func TestSSZRESTReadBodyNonSizeError(t *testing.T) { + // a non-size read error (truncated body / client disconnect) is 400, not 413 + srv := newTestEngineServer(allForksConfig(), false) + req := httptest.NewRequest(http.MethodPost, "/engine/v2/cancun/forkchoice", iotest.ErrReader(errors.New("boom"))) + req.Header.Set("Content-Type", sszRestContentType) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemInvalidRequest, problemType(t, rec)) +} From 8bedc58d1bddb3bbd4fb4b3d0d6855c921dab12c Mon Sep 17 00:00:00 2001 From: noop Date: Thu, 18 Jun 2026 17:08:14 +0200 Subject: [PATCH 10/11] engineapi: name the blob-bundle bound MAX_BLOB_COMMITMENTS_PER_BLOCK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BlobsBundle list bound (4096) is MAX_BLOB_COMMITMENTS_PER_BLOCK per execution-apis#793, not MAX_BLOBS_PER_PAYLOAD (the dropped comment) and not a blob-hash count — sszMaxGetBlobHashes (128 = MAX_BLOBS_REQUEST) is the request bound. Rename sszMaxBlobHashes accordingly and drop the now-redundant comment; value and wire format unchanged. --- execution/engineapi/engine_types/ssz.go | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go index e428c397682..1d694eda522 100644 --- a/execution/engineapi/engine_types/ssz.go +++ b/execution/engineapi/engine_types/ssz.go @@ -21,13 +21,13 @@ import ( ) const ( - sszMaxBlobHashes = 4096 - sszMaxBytesPerTransaction = 0x40000000 - sszMaxValidationError = 1024 - sszBlobBytes = 0x20000 - sszKZGBytes = 48 - sszCellsPerExtBlob = 128 - sszMaxCellProofs = sszMaxBlobHashes * sszCellsPerExtBlob // MAX_BLOBS_PER_PAYLOAD * CELLS_PER_EXT_BLOB + sszMaxBlobCommitmentsPerBlock = 4096 + sszMaxBytesPerTransaction = 0x40000000 + sszMaxValidationError = 1024 + sszBlobBytes = 0x20000 + sszKZGBytes = 48 + sszCellsPerExtBlob = 128 + sszMaxCellProofs = sszMaxBlobCommitmentsPerBlock * sszCellsPerExtBlob ) var mainnetBeaconCfg = &clparams.MainnetBeaconConfig @@ -439,13 +439,13 @@ func NewBlobsBundleSSZ(version clparams.StateVersion) *BlobsBundle { func (b *BlobsBundle) Static() bool { return false } func (b *BlobsBundle) EncodeSSZ(dst []byte) ([]byte, error) { - proofsLimit := sszMaxBlobHashes + proofsLimit := sszMaxBlobCommitmentsPerBlock if b.blobsBundleSSZVersion() >= clparams.FuluVersion { proofsLimit = sszMaxCellProofs } - commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobHashes, sszKZGBytes) + commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobCommitmentsPerBlock, sszKZGBytes) proofs := solid.NewStaticListSSZ[*cltypes.KZGProof](proofsLimit, sszKZGBytes) - blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobHashes, sszBlobBytes) + blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobCommitmentsPerBlock, sszBlobBytes) for _, commitment := range b.Commitments { commitments.Append(newKZGCommitment(commitment)) } @@ -460,13 +460,13 @@ func (b *BlobsBundle) EncodeSSZ(dst []byte) ([]byte, error) { func (b *BlobsBundle) DecodeSSZ(buf []byte, version int) error { b.SSZVersion = clparams.StateVersion(version) - proofsLimit := sszMaxBlobHashes + proofsLimit := sszMaxBlobCommitmentsPerBlock if b.SSZVersion >= clparams.FuluVersion { proofsLimit = sszMaxCellProofs } - commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobHashes, sszKZGBytes) + commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobCommitmentsPerBlock, sszKZGBytes) proofs := solid.NewStaticListSSZ[*cltypes.KZGProof](proofsLimit, sszKZGBytes) - blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobHashes, sszBlobBytes) + blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobCommitmentsPerBlock, sszBlobBytes) if err := ssz2.UnmarshalSSZ(buf, version, commitments, proofs, blobs); err != nil { return err } From 85c721d91906203ccfd3f73355d5d61f56d766ba Mon Sep 17 00:00:00 2001 From: noop Date: Thu, 18 Jun 2026 17:19:24 +0200 Subject: [PATCH 11/11] engineapi: compute SSZ entry sizes directly and reuse zero blob buffers EncodeDynamicList builds its per-element offset table from EncodingSizeSSZ, so each entry's reported size must match EncodeSSZ exactly (the multi-entry round-trip tests verify this): - sszBodyEntry.EncodingSizeSSZ built a body that can error and returned a bogus 0 on failure; derive the size from the entry fields instead. - sszBlobV2Entry.EncodingSizeSSZ re-encoded the whole entry (a 128 KiB blob) just to measure its length; compute it directly. - sszBlobV1Entry.EncodeSSZ allocated a fresh 128 KiB+48 B zero blob/proof for every unavailable entry; reuse shared read-only zero buffers. Addresses Copilot review feedback; no wire-format change. --- execution/engineapi/sszrest_wire.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index 4e782fdacce..572323b07c4 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -487,11 +487,14 @@ func (e *sszBodyEntry) DecodeSSZ(buf []byte, version int) error { } func (e *sszBodyEntry) EncodingSizeSSZ() int { - body, err := e.body() - if err != nil { - return 0 + size := 1 + 4 + 4 + newTransactionsSSZ(e.Transactions).EncodingSizeSSZ() + if e.version >= clparams.CapellaVersion { + size += 4 + len(e.Withdrawals)*sszWithdrawalBytes } - return 1 + 4 + body.EncodingSizeSSZ() + if e.version >= clparams.GloasVersion { + size += 4 + len(e.BlockAccessList) + } + return size } func (e *sszBodyEntry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } @@ -529,6 +532,12 @@ func decodeBodiesResponse(buf []byte, version clparams.StateVersion) ([]*sszBody return out, nil } +// Shared read-only zero buffers for unavailable blob entries; never mutated. +var ( + zeroSSZBlob = make([]byte, sszBlobBytes) + zeroSSZProof = make([]byte, sszKZGBytes) +) + // sszBlobV1Entry is BlobEntry {available: boolean, contents: BlobAndProofV1}. type sszBlobV1Entry struct { Available bool @@ -543,7 +552,7 @@ func (*sszBlobV1Entry) EncodingSizeSSZ() int { return 1 + sszBlobBytes + sszKZGB func (e *sszBlobV1Entry) EncodeSSZ(dst []byte) ([]byte, error) { blob, proof := []byte(e.Blob), []byte(e.Proof) if !e.Available { - blob, proof = make([]byte, sszBlobBytes), make([]byte, sszKZGBytes) + blob, proof = zeroSSZBlob, zeroSSZProof } if len(blob) != sszBlobBytes || len(proof) != sszKZGBytes { return nil, fmt.Errorf("bad blob/proof length %d/%d", len(blob), len(proof)) @@ -589,7 +598,13 @@ func (e *sszBlobV2Entry) DecodeSSZ(buf []byte, version int) error { return nil } -func (e *sszBlobV2Entry) EncodingSizeSSZ() int { out, _ := e.EncodeSSZ(nil); return len(out) } +func (e *sszBlobV2Entry) EncodingSizeSSZ() int { + proofs := 0 + if e.Contents != nil { + proofs = len(e.Contents.CellProofs) + } + return 1 + 4 + sszBlobBytes + 4 + proofs*sszKZGBytes +} func (e *sszBlobV2Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil }