diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go
index 1c8071fe3b3..65e29355bb3 100644
--- a/cmd/rpcdaemon/cli/config.go
+++ b/cmd/rpcdaemon/cli/config.go
@@ -960,6 +960,13 @@ func createHandler(cfg *httpcfg.HttpCfg, apiList []rpc.API, httpHandler http.Han
return
}
+ // EIP-8161: Route /engine/* paths to SSZ-REST handler,
+ // everything else (/) to JSON-RPC handler.
+ if cfg.SszRestHandler != nil && strings.HasPrefix(r.URL.Path, "/engine/") {
+ cfg.SszRestHandler.ServeHTTP(w, r)
+ return
+ }
+
httpHandler.ServeHTTP(w, r)
})
diff --git a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
index 89eb8784f6a..e7808f0d5ff 100644
--- a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
+++ b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
@@ -17,6 +17,7 @@
package httpcfg
import (
+ "net/http"
"time"
"github.com/erigontech/erigon/db/datadir"
@@ -114,4 +115,8 @@ type HttpCfg struct {
RpcTxSyncDefaultTimeout time.Duration // Default timeout for eth_sendRawTransactionSync
RpcTxSyncMaxTimeout time.Duration // Maximum timeout for eth_sendRawTransactionSync
+
+ // EIP-8161: SSZ-REST Engine API Transport — handler injected by EngineServer,
+ // served on the same port as JSON-RPC (path-based routing).
+ SszRestHandler http.Handler
}
diff --git a/execution/engineapi/engine_server.go b/execution/engineapi/engine_server.go
index 4349d8ad197..0d4400a3bbc 100644
--- a/execution/engineapi/engine_server.go
+++ b/execution/engineapi/engine_server.go
@@ -92,6 +92,7 @@ type EngineServer struct {
// TODO Remove this on next release
printPectraBanner bool
maxReorgDepth uint64
+ httpConfig *httpcfg.HttpCfg
}
func NewEngineServer(
@@ -151,6 +152,7 @@ func (e *EngineServer) Start(
return nil
})
}
+ e.httpConfig = httpConfig
base := jsonrpc.NewBaseApi(filters, stateCache, blockReader, httpConfig.WithDatadir, httpConfig.EvmCallTimeout, engineReader, httpConfig.Dirs, nil, httpConfig.BlockRangeLimit, httpConfig.GetLogsMaxResults)
ethImpl := jsonrpc.NewEthAPI(base, db, eth, e.txpool, mining, jsonrpc.NewEthApiConfig(httpConfig), e.logger)
@@ -167,6 +169,11 @@ func (e *EngineServer) Start(
Version: "1.0",
}}
+ // EIP-8161: Register SSZ-REST handler on the same port as JSON-RPC.
+ // Path-based routing: /engine/* → SSZ-REST, / → JSON-RPC
+ httpConfig.SszRestHandler = NewSszRestHandler(e, e.logger)
+ e.logger.Info("[EngineServer] SSZ-REST routes registered on Engine API port")
+
eg.Go(func() error {
defer e.logger.Debug("[EngineServer] engine rpc server goroutine terminated")
err := cli.StartRpcServerWithJwtAuthentication(ctx, httpConfig, apiList, e.logger)
@@ -175,6 +182,7 @@ func (e *EngineServer) Start(
}
return err
})
+
return eg.Wait()
}
diff --git a/execution/engineapi/engine_ssz_rest_server.go b/execution/engineapi/engine_ssz_rest_server.go
new file mode 100644
index 00000000000..69d2ca7c2f7
--- /dev/null
+++ b/execution/engineapi/engine_ssz_rest_server.go
@@ -0,0 +1,525 @@
+// Copyright 2025 The Erigon Authors
+// 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
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "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/hexutil"
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/execution/engineapi/engine_types"
+ "github.com/erigontech/erigon/rpc"
+)
+
+// SszRestServer implements the EIP-8161 SSZ-REST Engine API transport.
+// Routes are registered on the same HTTP server as the JSON-RPC Engine API
+// (path-based routing: /engine/* → SSZ-REST, / → JSON-RPC).
+type SszRestServer struct {
+ engine *EngineServer
+ logger log.Logger
+}
+
+// NewSszRestHandler creates an http.Handler for SSZ-REST routes.
+// JWT authentication is handled by the caller (the main engine API handler).
+func NewSszRestHandler(engine *EngineServer, logger log.Logger) http.Handler {
+ s := &SszRestServer{
+ engine: engine,
+ logger: logger,
+ }
+ mux := http.NewServeMux()
+ s.registerRoutes(mux)
+ return mux
+}
+
+// sszErrorResponse writes a JSON error response per EIP-8161 spec.
+func sszErrorResponse(w http.ResponseWriter, code int, jsonCode int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ body, _ := json.Marshal(struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }{Code: jsonCode, Message: message})
+ w.Write(body) //nolint:errcheck
+}
+
+// sszResponse writes a successful SSZ-encoded response.
+func sszResponse(w http.ResponseWriter, data []byte) {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.WriteHeader(http.StatusOK)
+ w.Write(data) //nolint:errcheck
+}
+
+// registerRoutes registers all SSZ-REST endpoint routes per execution-apis SSZ spec.
+// Uses RESTful resource-oriented paths (POST /engine/v{N}/payloads for newPayload,
+// GET /engine/v{N}/payloads/{id} for getPayload, etc.)
+func (s *SszRestServer) registerRoutes(mux *http.ServeMux) {
+ // newPayload: POST /engine/v{N}/payloads
+ mux.HandleFunc("POST /engine/v1/payloads", s.handleNewPayloadV1)
+ mux.HandleFunc("POST /engine/v2/payloads", s.handleNewPayloadV2)
+ mux.HandleFunc("POST /engine/v3/payloads", s.handleNewPayloadV3)
+ mux.HandleFunc("POST /engine/v4/payloads", s.handleNewPayloadV4)
+ mux.HandleFunc("POST /engine/v5/payloads", s.handleNewPayloadV5)
+
+ // getPayload: GET /engine/v{N}/payloads/{payload_id}
+ mux.HandleFunc("GET /engine/v1/payloads/", s.handleGetPayloadV1)
+ mux.HandleFunc("GET /engine/v2/payloads/", s.handleGetPayloadV2)
+ mux.HandleFunc("GET /engine/v3/payloads/", s.handleGetPayloadV3)
+ mux.HandleFunc("GET /engine/v4/payloads/", s.handleGetPayloadV4)
+ mux.HandleFunc("GET /engine/v5/payloads/", s.handleGetPayloadV5)
+ mux.HandleFunc("GET /engine/v6/payloads/", s.handleGetPayloadV6)
+
+ // forkchoiceUpdated: POST /engine/v{N}/forkchoice
+ mux.HandleFunc("POST /engine/v1/forkchoice", s.handleForkchoiceUpdatedV1)
+ mux.HandleFunc("POST /engine/v2/forkchoice", s.handleForkchoiceUpdatedV2)
+ mux.HandleFunc("POST /engine/v3/forkchoice", s.handleForkchoiceUpdatedV3)
+
+ // getBlobs: POST /engine/v{N}/blobs
+ mux.HandleFunc("POST /engine/v1/blobs", s.handleGetBlobsV1)
+
+ // capabilities: POST /engine/v1/capabilities
+ mux.HandleFunc("POST /engine/v1/capabilities", s.handleExchangeCapabilities)
+
+ // client version: POST /engine/v1/client/version
+ mux.HandleFunc("POST /engine/v1/client/version", s.handleGetClientVersion)
+
+ // Legacy paths (backwards compatibility with existing CL clients)
+ mux.HandleFunc("POST /engine/v1/new_payload", s.handleNewPayloadV1)
+ mux.HandleFunc("POST /engine/v2/new_payload", s.handleNewPayloadV2)
+ mux.HandleFunc("POST /engine/v3/new_payload", s.handleNewPayloadV3)
+ mux.HandleFunc("POST /engine/v4/new_payload", s.handleNewPayloadV4)
+ mux.HandleFunc("POST /engine/v5/new_payload", s.handleNewPayloadV5)
+ mux.HandleFunc("POST /engine/v1/forkchoice_updated", s.handleForkchoiceUpdatedV1)
+ mux.HandleFunc("POST /engine/v2/forkchoice_updated", s.handleForkchoiceUpdatedV2)
+ mux.HandleFunc("POST /engine/v3/forkchoice_updated", s.handleForkchoiceUpdatedV3)
+ mux.HandleFunc("POST /engine/v1/get_payload", s.handleGetPayloadV1Legacy)
+ mux.HandleFunc("POST /engine/v2/get_payload", s.handleGetPayloadV2Legacy)
+ mux.HandleFunc("POST /engine/v3/get_payload", s.handleGetPayloadV3Legacy)
+ mux.HandleFunc("POST /engine/v4/get_payload", s.handleGetPayloadV4Legacy)
+ mux.HandleFunc("POST /engine/v5/get_payload", s.handleGetPayloadV5Legacy)
+ mux.HandleFunc("POST /engine/v1/get_blobs", s.handleGetBlobsV1)
+ mux.HandleFunc("POST /engine/v1/exchange_capabilities", s.handleExchangeCapabilities)
+ mux.HandleFunc("POST /engine/v1/get_client_version", s.handleGetClientVersion)
+}
+
+// readBody reads the request body with a size limit.
+func readBody(r *http.Request, maxSize int64) ([]byte, error) {
+ return io.ReadAll(io.LimitReader(r.Body, maxSize))
+}
+
+// --- newPayload handlers ---
+
+func (s *SszRestServer) handleNewPayloadV1(w http.ResponseWriter, r *http.Request) {
+ s.handleNewPayload(w, r, 1)
+}
+
+func (s *SszRestServer) handleNewPayloadV2(w http.ResponseWriter, r *http.Request) {
+ s.handleNewPayload(w, r, 2)
+}
+
+func (s *SszRestServer) handleNewPayloadV3(w http.ResponseWriter, r *http.Request) {
+ s.handleNewPayload(w, r, 3)
+}
+
+func (s *SszRestServer) handleNewPayloadV4(w http.ResponseWriter, r *http.Request) {
+ s.handleNewPayload(w, r, 4)
+}
+
+func (s *SszRestServer) handleNewPayloadV5(w http.ResponseWriter, r *http.Request) {
+ s.handleNewPayload(w, r, 5)
+}
+
+func (s *SszRestServer) handleNewPayload(w http.ResponseWriter, r *http.Request, version int) {
+ s.logger.Info("[SSZ-REST] Received NewPayload", "version", version)
+
+ body, err := readBody(r, 16*1024*1024) // 16 MB max
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ if len(body) == 0 {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "empty request body")
+ return
+ }
+
+ // Decode the SSZ request: V1/V2 is just ExecutionPayload, V3/V4 is a wrapper container
+ ep, blobHashes, parentBeaconBlockRoot, executionRequests, err := engine_types.DecodeNewPayloadRequest(body, version)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("SSZ decode error: %v", err))
+ return
+ }
+
+ ctx := r.Context()
+ var result *engine_types.PayloadStatus
+
+ switch version {
+ case 1:
+ result, err = s.engine.NewPayloadV1(ctx, ep)
+ case 2:
+ result, err = s.engine.NewPayloadV2(ctx, ep)
+ case 3:
+ result, err = s.engine.NewPayloadV3(ctx, ep, blobHashes, parentBeaconBlockRoot)
+ case 4, 5:
+ // Determine the correct fork version from the payload timestamp.
+ // The SSZ payload format is the same (Deneb) for V4/V5, but the engine
+ // does a fork-version check internally.
+ ts := uint64(ep.Timestamp)
+ forkVersion := clparams.ElectraVersion
+ if s.engine.config.IsAmsterdam(ts) {
+ forkVersion = clparams.GloasVersion
+ } else if s.engine.config.IsOsaka(ts) {
+ forkVersion = clparams.FuluVersion
+ } else if s.engine.config.IsPrague(ts) {
+ forkVersion = clparams.ElectraVersion
+ }
+ s.logger.Info("[SSZ-REST] NewPayload fork check", "timestamp", ts, "forkVersion", forkVersion, "urlVersion", version)
+ result, err = s.engine.newPayload(ctx, ep, blobHashes, parentBeaconBlockRoot, executionRequests, forkVersion)
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported newPayload version: %d", version))
+ return
+ }
+
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ psBytes, _ := result.EncodeSSZ(nil)
+ sszResponse(w, psBytes)
+}
+
+// --- forkchoiceUpdated handlers ---
+
+func (s *SszRestServer) handleForkchoiceUpdatedV1(w http.ResponseWriter, r *http.Request) {
+ s.handleForkchoiceUpdated(w, r, 1)
+}
+
+func (s *SszRestServer) handleForkchoiceUpdatedV2(w http.ResponseWriter, r *http.Request) {
+ s.handleForkchoiceUpdated(w, r, 2)
+}
+
+func (s *SszRestServer) handleForkchoiceUpdatedV3(w http.ResponseWriter, r *http.Request) {
+ s.handleForkchoiceUpdated(w, r, 3)
+}
+
+func (s *SszRestServer) handleForkchoiceUpdated(w http.ResponseWriter, r *http.Request, version int) {
+ s.logger.Info("[SSZ-REST] Received ForkchoiceUpdated", "version", version)
+
+ body, err := readBody(r, 1024*1024) // 1 MB max
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ const fixedSize = 100 // forkchoice_state(96) + payload_attributes_offset(4)
+ if len(body) < fixedSize {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "request body too short for ForkchoiceUpdatedRequest")
+ return
+ }
+ attrOffset := int(binary.LittleEndian.Uint32(body[96:]))
+ if attrOffset < fixedSize || attrOffset > len(body) || (attrOffset < len(body) && len(body)-attrOffset < 4) {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "invalid payload attributes list offset")
+ return
+ }
+
+ fcs := &engine_types.ForkChoiceState{}
+ payloadAttributesList := solid.NewDynamicListSSZ[*engine_types.PayloadAttributes](1)
+ if err := ssz2.UnmarshalSSZ(body, version, fcs, payloadAttributesList); err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("SSZ decode error: %v", err))
+ return
+ }
+ var payloadAttributes *engine_types.PayloadAttributes
+ if payloadAttributesList.Len() > 0 {
+ payloadAttributes = payloadAttributesList.Get(0)
+ }
+
+ ctx := r.Context()
+ var resp *engine_types.ForkChoiceUpdatedResponse
+
+ switch version {
+ case 1:
+ resp, err = s.engine.ForkchoiceUpdatedV1(ctx, fcs, payloadAttributes)
+ case 2:
+ resp, err = s.engine.ForkchoiceUpdatedV2(ctx, fcs, payloadAttributes)
+ case 3:
+ resp, err = s.engine.ForkchoiceUpdatedV3(ctx, fcs, payloadAttributes)
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported forkchoiceUpdated version: %d", version))
+ return
+ }
+
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ // Encode response
+ if resp.PayloadId != nil {
+ s.logger.Info("[SSZ-REST] ForkchoiceUpdated response", "payloadId", fmt.Sprintf("%x", []byte(*resp.PayloadId)), "status", resp.PayloadStatus.Status)
+ } else {
+ s.logger.Info("[SSZ-REST] ForkchoiceUpdated response", "payloadId", "nil", "status", resp.PayloadStatus.Status)
+ }
+ respBytes := engine_types.EncodeForkchoiceUpdatedResponse(resp)
+ s.logger.Info("[SSZ-REST] ForkchoiceUpdated encoded", "len", len(respBytes), "first20", fmt.Sprintf("%x", respBytes[:min(20, len(respBytes))]))
+ sszResponse(w, respBytes)
+}
+
+// --- getPayload handlers (GET with payload_id in URL path) ---
+
+func (s *SszRestServer) handleGetPayloadV1(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 1)
+}
+
+func (s *SszRestServer) handleGetPayloadV2(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 2)
+}
+
+func (s *SszRestServer) handleGetPayloadV3(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 3)
+}
+
+func (s *SszRestServer) handleGetPayloadV4(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 4)
+}
+
+func (s *SszRestServer) handleGetPayloadV5(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 5)
+}
+
+func (s *SszRestServer) handleGetPayloadV6(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromPath(w, r, 6)
+}
+
+// handleGetPayloadFromPath handles GET /engine/v{N}/payloads/{payload_id}
+// where payload_id is hex-encoded Bytes8 (e.g., 0x0000000000000001).
+func (s *SszRestServer) handleGetPayloadFromPath(w http.ResponseWriter, r *http.Request, version int) {
+ // Extract payload_id from URL path: /engine/v{N}/payloads/{payload_id}
+ path := r.URL.Path
+ // Find the last path segment
+ lastSlash := len(path) - 1
+ for lastSlash > 0 && path[lastSlash] != '/' {
+ lastSlash--
+ }
+ payloadIdHex := path[lastSlash+1:]
+ if payloadIdHex == "" {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "missing payload_id in URL path")
+ return
+ }
+
+ payloadIdBytes, err := hexutil.Decode(payloadIdHex)
+ if err != nil || len(payloadIdBytes) != 8 {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("invalid payload_id: %s", payloadIdHex))
+ return
+ }
+
+ s.logger.Info("[SSZ-REST] Received GetPayload", "version", version, "payloadId", payloadIdHex)
+ s.doGetPayload(w, r, version, hexutil.Bytes(payloadIdBytes))
+}
+
+// --- getPayload legacy handlers (POST with payload_id in body) ---
+
+func (s *SszRestServer) handleGetPayloadV1Legacy(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromBody(w, r, 1)
+}
+func (s *SszRestServer) handleGetPayloadV2Legacy(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromBody(w, r, 2)
+}
+func (s *SszRestServer) handleGetPayloadV3Legacy(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromBody(w, r, 3)
+}
+func (s *SszRestServer) handleGetPayloadV4Legacy(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromBody(w, r, 4)
+}
+func (s *SszRestServer) handleGetPayloadV5Legacy(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayloadFromBody(w, r, 5)
+}
+
+func (s *SszRestServer) handleGetPayloadFromBody(w http.ResponseWriter, r *http.Request, version int) {
+ s.logger.Info("[SSZ-REST] Received GetPayload (legacy POST)", "version", version)
+
+ body, err := readBody(r, 64)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+ if len(body) != 8 {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("expected 8 bytes for payload ID, got %d", len(body)))
+ return
+ }
+ payloadIdBytes := make(hexutil.Bytes, 8)
+ copy(payloadIdBytes, body)
+ s.doGetPayload(w, r, version, payloadIdBytes)
+}
+
+func (s *SszRestServer) doGetPayload(w http.ResponseWriter, r *http.Request, version int, payloadIdBytes hexutil.Bytes) {
+ ctx := r.Context()
+ var err error
+
+ switch version {
+ case 1:
+ result, err := s.engine.GetPayloadV1(ctx, payloadIdBytes)
+ if err != nil {
+ s.handleGetPayloadError(w, err)
+ return
+ }
+ resp := &engine_types.GetPayloadResponse{ExecutionPayload: result}
+ sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(resp, 1))
+ case 2, 3, 4, 5, 6:
+ var result *engine_types.GetPayloadResponse
+ encodeVersion := version
+ switch version {
+ case 2:
+ result, err = s.engine.GetPayloadV2(ctx, payloadIdBytes)
+ case 3:
+ result, err = s.engine.GetPayloadV3(ctx, payloadIdBytes)
+ case 4:
+ result, err = s.engine.GetPayloadV4(ctx, payloadIdBytes)
+ case 5:
+ result, err = s.engine.GetPayloadV5(ctx, payloadIdBytes)
+ encodeVersion = 4
+ case 6:
+ result, err = s.engine.GetPayloadV5(ctx, payloadIdBytes) // TODO: GetPayloadV6 when available
+ encodeVersion = 4
+ }
+ if err != nil {
+ s.handleGetPayloadError(w, err)
+ return
+ }
+ sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(result, encodeVersion))
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported getPayload version: %d", version))
+ }
+}
+
+// handleGetPayloadError returns 404 for unknown payload ID, otherwise delegates.
+func (s *SszRestServer) handleGetPayloadError(w http.ResponseWriter, err error) {
+ if err != nil && err.Error() == "unknown payload" {
+ sszErrorResponse(w, http.StatusNotFound, -32001, "Unknown payload ID")
+ return
+ }
+ s.handleEngineError(w, err)
+}
+
+// --- getBlobs handler ---
+
+func (s *SszRestServer) handleGetBlobsV1(w http.ResponseWriter, r *http.Request) {
+ s.logger.Info("[SSZ-REST] Received GetBlobsV1")
+
+ body, err := readBody(r, 1024*1024) // 1 MB max
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ hashes, err := engine_types.DecodeGetBlobsRequest(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ ctx := r.Context()
+ if s.engine.txpool == nil {
+ sszErrorResponse(w, http.StatusInternalServerError, -32603, "txpool unavailable")
+ return
+ }
+ result, err := s.engine.GetBlobsV1(ctx, hashes)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ sszResponse(w, engine_types.EncodeGetBlobsV1Response(result))
+}
+
+// --- exchangeCapabilities handler ---
+
+func (s *SszRestServer) handleExchangeCapabilities(w http.ResponseWriter, r *http.Request) {
+ s.logger.Info("[SSZ-REST] Received ExchangeCapabilities")
+
+ body, err := readBody(r, 1024*1024)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ capabilities, err := engine_types.DecodeCapabilities(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ result := s.engine.ExchangeCapabilities(capabilities)
+ sszResponse(w, engine_types.EncodeCapabilities(result))
+}
+
+// --- getClientVersion handler ---
+
+func (s *SszRestServer) handleGetClientVersion(w http.ResponseWriter, r *http.Request) {
+ s.logger.Info("[SSZ-REST] Received GetClientVersion")
+
+ body, err := readBody(r, 1024*1024)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ var callerVersion *engine_types.ClientVersionV1
+ if len(body) > 0 {
+ cv, err := engine_types.DecodeClientVersion(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+ callerVersion = cv
+ }
+
+ ctx := r.Context()
+ result, err := s.engine.GetClientVersionV1(ctx, callerVersion)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ sszResponse(w, engine_types.EncodeClientVersions(result))
+}
+
+// handleEngineError converts engine errors to appropriate HTTP error responses.
+func (s *SszRestServer) handleEngineError(w http.ResponseWriter, err error) {
+ s.logger.Warn("[SSZ-REST] Engine error", "err", err)
+ switch e := err.(type) {
+ case *rpc.InvalidParamsError:
+ sszErrorResponse(w, http.StatusBadRequest, -32602, e.Message)
+ case *rpc.UnsupportedForkError:
+ sszErrorResponse(w, http.StatusBadRequest, -32000, e.Message)
+ default:
+ errMsg := err.Error()
+ if errMsg == "invalid forkchoice state" {
+ sszErrorResponse(w, http.StatusConflict, -32000, errMsg)
+ } else if errMsg == "invalid payload attributes" {
+ sszErrorResponse(w, http.StatusUnprocessableEntity, -32000, errMsg)
+ } else {
+ sszErrorResponse(w, http.StatusInternalServerError, -32603, errMsg)
+ }
+ }
+}
diff --git a/execution/engineapi/engine_ssz_rest_server_test.go b/execution/engineapi/engine_ssz_rest_server_test.go
new file mode 100644
index 00000000000..a26f62a82fd
--- /dev/null
+++ b/execution/engineapi/engine_ssz_rest_server_test.go
@@ -0,0 +1,523 @@
+// Copyright 2025 The Erigon Authors
+// 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
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cmd/rpcdaemon/cli/httpcfg"
+ "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_types"
+ "github.com/erigontech/erigon/execution/execmodule/execmoduletester"
+ "github.com/erigontech/erigon/node/ethconfig"
+ "github.com/erigontech/erigon/rpc"
+)
+
+// getFreePort returns a free TCP port for testing.
+func getFreePort(t *testing.T) int {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ port := l.Addr().(*net.TCPAddr).Port
+ l.Close()
+ return port
+}
+
+// makeJWTToken creates a valid JWT token for testing.
+func makeJWTToken(secret []byte) string {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+ "iat": time.Now().Unix(),
+ })
+ tokenString, _ := token.SignedString(secret)
+ return tokenString
+}
+
+// sszRestTestSetup creates an EngineServer and a test HTTP server with
+// SSZ-REST routes + JWT middleware for testing.
+type sszRestTestSetup struct {
+ engineServer *EngineServer
+ jwtSecret []byte
+ baseURL string
+ cancel context.CancelFunc
+}
+
+func newSszRestTestSetup(t *testing.T) *sszRestTestSetup {
+ t.Helper()
+
+ mockSentry := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges))
+
+ maxReorgDepth := ethconfig.Defaults.MaxReorgDepth
+ engineServer := NewEngineServer(mockSentry.Log, mockSentry.ChainConfig, mockSentry.ExecModule, nil, false, false, false, true, nil, ethconfig.Defaults.FcuTimeout, maxReorgDepth)
+
+ port := getFreePort(t)
+ engineServer.httpConfig = &httpcfg.HttpCfg{
+ AuthRpcHTTPListenAddress: "127.0.0.1",
+ AuthRpcPort: 8551,
+ }
+
+ jwtSecret := make([]byte, 32)
+ rand.Read(jwtSecret)
+
+ // Create the SSZ-REST handler (same as production code)
+ sszHandler := NewSszRestHandler(engineServer, log.New())
+
+ // Wrap with JWT middleware for testing (in production this is done by createHandler)
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !rpc.CheckJwtSecret(w, r, jwtSecret) {
+ return
+ }
+ sszHandler.ServeHTTP(w, r)
+ })
+
+ listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ require.NoError(t, err)
+
+ server := &http.Server{Handler: handler}
+ go server.Serve(listener) //nolint:errcheck
+
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ <-ctx.Done()
+ server.Close()
+ }()
+
+ baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
+ waitForServer(t, baseURL, jwtSecret)
+
+ return &sszRestTestSetup{
+ engineServer: engineServer,
+ jwtSecret: jwtSecret,
+ baseURL: baseURL,
+ cancel: cancel,
+ }
+}
+
+func waitForServer(t *testing.T, baseURL string, jwtSecret []byte) {
+ t.Helper()
+ client := &http.Client{Timeout: time.Second}
+ for i := 0; i < 50; i++ {
+ req, _ := http.NewRequest("POST", baseURL+"/engine/v1/exchange_capabilities", nil)
+ req.Header.Set("Authorization", "Bearer "+makeJWTToken(jwtSecret))
+ resp, err := client.Do(req)
+ if err == nil {
+ resp.Body.Close()
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("SSZ-REST server did not start in time")
+}
+
+func (s *sszRestTestSetup) doRequest(t *testing.T, path string, body []byte) (*http.Response, []byte) {
+ t.Helper()
+ return s.doRequestWithToken(t, path, body, makeJWTToken(s.jwtSecret))
+}
+
+func (s *sszRestTestSetup) doRequestWithToken(t *testing.T, path string, body []byte, token string) (*http.Response, []byte) {
+ t.Helper()
+ var bodyReader io.Reader
+ if body != nil {
+ bodyReader = bytes.NewReader(body)
+ }
+
+ req, err := http.NewRequest("POST", s.baseURL+path, bodyReader)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/octet-stream")
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+
+ respBody, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ return resp, respBody
+}
+
+func TestSszRestJWTAuth(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Request without token should fail
+ httpReq, err := http.NewRequest("POST", setup.baseURL+"/engine/v1/exchange_capabilities", nil)
+ req.NoError(err)
+ httpReq.Header.Set("Content-Type", "application/octet-stream")
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Do(httpReq)
+ req.NoError(err)
+ resp.Body.Close()
+ req.Equal(http.StatusForbidden, resp.StatusCode)
+
+ // Request with invalid token should fail
+ httpReq2, err := http.NewRequest("POST", setup.baseURL+"/engine/v1/exchange_capabilities", nil)
+ req.NoError(err)
+ httpReq2.Header.Set("Content-Type", "application/octet-stream")
+ httpReq2.Header.Set("Authorization", "Bearer invalidtoken")
+
+ resp2, err := client.Do(httpReq2)
+ req.NoError(err)
+ resp2.Body.Close()
+ req.Equal(http.StatusForbidden, resp2.StatusCode)
+
+ // Request with valid token should succeed
+ body := engine_types.EncodeCapabilities([]string{"engine_newPayloadV4"})
+ resp3, _ := setup.doRequest(t, "/engine/v1/exchange_capabilities", body)
+ defer resp3.Body.Close()
+ req.Equal(http.StatusOK, resp3.StatusCode)
+}
+
+func TestSszRestExchangeCapabilities(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ clCapabilities := []string{
+ "engine_newPayloadV4",
+ "engine_forkchoiceUpdatedV3",
+ "engine_getPayloadV4",
+ }
+
+ body := engine_types.EncodeCapabilities(clCapabilities)
+ resp, respBody := setup.doRequest(t, "/engine/v1/exchange_capabilities", body)
+ defer resp.Body.Close()
+ req.Equal(http.StatusOK, resp.StatusCode)
+ req.Equal("application/octet-stream", resp.Header.Get("Content-Type"))
+
+ decoded, err := engine_types.DecodeCapabilities(respBody)
+ req.NoError(err)
+ req.NotEmpty(decoded)
+ // Should contain at least the capabilities we sent (EL returns its own list)
+ req.Contains(decoded, "engine_newPayloadV4")
+ req.Contains(decoded, "engine_forkchoiceUpdatedV3")
+}
+
+func TestSszRestGetClientVersion(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ callerVersion := &engine_types.ClientVersionV1{
+ Code: "CL",
+ Name: "TestClient",
+ Version: "1.0.0",
+ Commit: "0x12345678",
+ }
+
+ body := engine_types.EncodeClientVersion(callerVersion)
+ resp, respBody := setup.doRequest(t, "/engine/v1/get_client_version", body)
+ defer resp.Body.Close()
+ req.Equal(http.StatusOK, resp.StatusCode)
+
+ versions, err := engine_types.DecodeClientVersions(respBody)
+ req.NoError(err)
+ req.Len(versions, 1)
+ req.Equal("EG", versions[0].Code) // Erigon's client code
+}
+
+func TestSszRestGetBlobsV1(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Request with empty hashes — may return 200 or 500 depending on txpool availability
+ hashes := []common.Hash{}
+ body := engine_types.EncodeGetBlobsRequest(hashes)
+ resp, _ := setup.doRequest(t, "/engine/v1/get_blobs", body)
+ defer resp.Body.Close()
+ // The test setup doesn't have a fully initialized txpool/blockDownloader,
+ // so the handler may panic (recovered) or return an engine error.
+ // We verify the SSZ-REST transport layer handled it gracefully.
+ req.True(resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusInternalServerError)
+}
+
+func TestSszRestNotFoundEndpoint(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ resp, _ := setup.doRequest(t, "/engine/v99/nonexistent_method", nil)
+ defer resp.Body.Close()
+ // Go 1.22+ mux returns 404 for unmatched routes, or 405 for wrong methods
+ req.True(resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed)
+}
+
+func TestSszRestErrorResponseFormat(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Send malformed body to get_blobs
+ resp, respBody := setup.doRequest(t, "/engine/v1/get_blobs", []byte{0x01})
+ defer resp.Body.Close()
+ req.Equal(http.StatusBadRequest, resp.StatusCode)
+ req.Equal("application/json", resp.Header.Get("Content-Type"))
+
+ // Parse the JSON error response
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(respBody, &errResp)
+ req.NoError(err)
+ req.Equal(-32602, errResp.Code)
+ req.NotEmpty(errResp.Message)
+}
+
+func TestSszRestForkchoiceUpdatedV3(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Build a ForkchoiceState SSZ Container:
+ // forkchoice_state(96) + attributes_offset(4) + Union[None](1) = 101 bytes
+ fcs := &engine_types.ForkChoiceState{
+ HeadHash: common.Hash{},
+ SafeBlockHash: common.Hash{},
+ FinalizedBlockHash: common.Hash{},
+ }
+ fcsBytes := engine_types.EncodeForkchoiceState(fcs)
+ req.Len(fcsBytes, 96)
+
+ // Build the full container: fcs(96) + attr_offset(4) + union_selector(1)
+ body := make([]byte, 101)
+ copy(body[0:96], fcsBytes)
+ // attributes_offset = 100 (points to byte 100, the union selector)
+ body[96] = 100
+ body[97] = 0
+ body[98] = 0
+ body[99] = 0
+ body[100] = 0 // Union selector = 0 (None)
+
+ // ForkchoiceUpdatedV3 with no payload attributes
+ resp, respBody := setup.doRequest(t, "/engine/v3/forkchoice_updated", body)
+ defer resp.Body.Close()
+ // The test setup doesn't have a fully initialized blockDownloader,
+ // so the engine may panic (recovered by SSZ-REST middleware) or return an error.
+ // We verify the SSZ-REST transport layer handled it gracefully without crashing.
+ if resp.StatusCode == http.StatusOK {
+ req.Equal("application/octet-stream", resp.Header.Get("Content-Type"))
+ req.NotEmpty(respBody)
+ } else {
+ // Engine errors or recovered panics are returned as JSON
+ req.Equal("application/json", resp.Header.Get("Content-Type"))
+ req.True(resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusInternalServerError)
+ }
+}
+
+func TestSszRestForkchoiceUpdatedShortBody(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Send a body that's too short for ForkchoiceState
+ resp, respBody := setup.doRequest(t, "/engine/v3/forkchoice_updated", make([]byte, 50))
+ defer resp.Body.Close()
+ req.Equal(http.StatusBadRequest, resp.StatusCode)
+
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(respBody, &errResp)
+ req.NoError(err)
+ req.Contains(errResp.Message, "too short")
+}
+
+func TestSszRestGetPayloadWrongBodySize(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Send wrong-sized body (not 8 bytes)
+ resp, respBody := setup.doRequest(t, "/engine/v4/get_payload", make([]byte, 10))
+ defer resp.Body.Close()
+ req.Equal(http.StatusBadRequest, resp.StatusCode)
+
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(respBody, &errResp)
+ req.NoError(err)
+ req.Contains(errResp.Message, "expected 8 bytes")
+}
+
+func TestSszRestNewPayloadV1EmptyBody(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Empty body should return 400
+ resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", nil)
+ defer resp.Body.Close()
+ req.Equal(http.StatusBadRequest, resp.StatusCode)
+
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(respBody, &errResp)
+ req.NoError(err)
+ req.Equal(-32602, errResp.Code)
+}
+
+func TestSszRestNewPayloadV1MalformedBody(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Body too short to be a valid ExecutionPayload SSZ
+ resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", make([]byte, 100))
+ defer resp.Body.Close()
+ req.Equal(http.StatusBadRequest, resp.StatusCode)
+
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ err := json.Unmarshal(respBody, &errResp)
+ req.NoError(err)
+ req.Contains(errResp.Message, "SSZ decode error")
+}
+
+func TestSszRestNewPayloadV1ValidSSZ(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Build a minimal ExecutionPayload and encode it to SSZ
+ ep := &engine_types.ExecutionPayload{
+ ParentHash: common.Hash{},
+ FeeRecipient: common.Address{},
+ StateRoot: common.Hash{},
+ ReceiptsRoot: common.Hash{},
+ LogsBloom: make([]byte, 256),
+ PrevRandao: common.Hash{},
+ BlockNumber: 0,
+ GasLimit: 30000000,
+ GasUsed: 0,
+ Timestamp: 1700000000,
+ ExtraData: []byte{},
+ BaseFeePerGas: (*hexutil.Big)(common.Big0),
+ BlockHash: common.Hash{},
+ Transactions: []hexutil.Bytes{},
+ }
+
+ body := engine_types.EncodeExecutionPayloadSSZ(ep, 1)
+ resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", body)
+ defer resp.Body.Close()
+
+ // The engine may return a real PayloadStatus or an error.
+ // With the mock setup, it might fail because engine consumption is not enabled.
+ // We verify the SSZ-REST transport layer correctly decoded and dispatched the request.
+ if resp.StatusCode == http.StatusOK {
+ req.Equal("application/octet-stream", resp.Header.Get("Content-Type"))
+ // Should be an SSZ PayloadStatus response (minimum 9 bytes fixed + 1 byte union selector)
+ req.GreaterOrEqual(len(respBody), 10)
+ // Decode the response to verify it's valid SSZ
+ ps, err := engine_types.DecodePayloadStatus(respBody)
+ req.NoError(err)
+ req.NotEmpty(ps.Status)
+ } else {
+ // Engine errors come back as JSON
+ req.Equal("application/json", resp.Header.Get("Content-Type"))
+ }
+}
+
+func TestSszRestGetPayloadV1ValidRequest(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ // Send a valid 8-byte payload ID
+ payloadId := make([]byte, 8)
+ payloadId[7] = 0x01 // payload ID = 1
+
+ resp, respBody := setup.doRequest(t, "/engine/v1/get_payload", payloadId)
+ defer resp.Body.Close()
+
+ // The engine will likely return an error (unknown payload ID) or internal error
+ // because we haven't built a payload. The important thing is the handler doesn't
+ // return a "not yet supported" stub error.
+ if resp.StatusCode == http.StatusOK {
+ // Should be SSZ-encoded ExecutionPayload
+ req.Equal("application/octet-stream", resp.Header.Get("Content-Type"))
+ } else {
+ // Check that it's NOT the old stub error message
+ var errResp struct {
+ Message string `json:"message"`
+ }
+ json.Unmarshal(respBody, &errResp) //nolint:errcheck
+ req.NotContains(errResp.Message, "not yet supported")
+ req.NotContains(errResp.Message, "SSZ ExecutionPayload encoding")
+ }
+}
+
+func TestSszRestGetPayloadV4ValidRequest(t *testing.T) {
+ setup := newSszRestTestSetup(t)
+ defer setup.cancel()
+
+ req := require.New(t)
+
+ payloadId := make([]byte, 8)
+ payloadId[7] = 0x01
+
+ resp, respBody := setup.doRequest(t, "/engine/v4/get_payload", payloadId)
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusOK {
+ req.Equal("application/octet-stream", resp.Header.Get("Content-Type"))
+ } else {
+ var errResp struct {
+ Message string `json:"message"`
+ }
+ json.Unmarshal(respBody, &errResp) //nolint:errcheck
+ req.NotContains(errResp.Message, "not yet supported")
+ req.NotContains(errResp.Message, "SSZ ExecutionPayload encoding")
+ }
+}
diff --git a/execution/engineapi/engine_types/jsonrpc.go b/execution/engineapi/engine_types/jsonrpc.go
index 041403746c5..d8730395aa7 100644
--- a/execution/engineapi/engine_types/jsonrpc.go
+++ b/execution/engineapi/engine_types/jsonrpc.go
@@ -51,6 +51,7 @@ type ExecutionPayload struct {
ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas"`
SlotNumber *hexutil.Uint64 `json:"slotNumber,omitempty"`
BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"`
+ sszVersion int
}
// PayloadAttributes represent the attributes required to start assembling a payload
@@ -162,6 +163,15 @@ type GetPayloadResponse struct {
BlobsBundle *BlobsBundle `json:"blobsBundle"`
ExecutionRequests []hexutil.Bytes `json:"executionRequests"`
ShouldOverrideBuilder bool `json:"shouldOverrideBuilder"`
+ sszVersion int
+}
+
+type NewPayloadRequest struct {
+ Payload *ExecutionPayload
+ BlobVersionedHashes []common.Hash
+ ParentBeaconBlockRoot common.Hash
+ ExecutionRequests []hexutil.Bytes
+ sszVersion int
}
type ClientVersionV1 struct {
diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go
new file mode 100644
index 00000000000..7de87a0c718
--- /dev/null
+++ b/execution/engineapi/engine_types/ssz.go
@@ -0,0 +1,1147 @@
+// Copyright 2025 The Erigon Authors
+// 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 engine_types
+
+import (
+ "fmt"
+ "math/big"
+
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/merkle_tree"
+ 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/types"
+)
+
+// SSZ status codes for PayloadStatus (EIP-8161)
+const (
+ SSZStatusValid uint8 = 0
+ SSZStatusInvalid uint8 = 1
+ SSZStatusSyncing uint8 = 2
+ SSZStatusAccepted uint8 = 3
+ SSZStatusInvalidBlockHash uint8 = 4
+)
+
+// EngineStatusToSSZ converts a string EngineStatus to the SSZ uint8 representation.
+func EngineStatusToSSZ(status EngineStatus) uint8 {
+ switch status {
+ case ValidStatus:
+ return SSZStatusValid
+ case InvalidStatus:
+ return SSZStatusInvalid
+ case SyncingStatus:
+ return SSZStatusSyncing
+ case AcceptedStatus:
+ return SSZStatusAccepted
+ case InvalidBlockHashStatus:
+ return SSZStatusInvalidBlockHash
+ default:
+ return SSZStatusInvalid
+ }
+}
+
+// SSZToEngineStatus converts an SSZ uint8 status to the string EngineStatus.
+func SSZToEngineStatus(status uint8) EngineStatus {
+ switch status {
+ case SSZStatusValid:
+ return ValidStatus
+ case SSZStatusInvalid:
+ return InvalidStatus
+ case SSZStatusSyncing:
+ return SyncingStatus
+ case SSZStatusAccepted:
+ return AcceptedStatus
+ case SSZStatusInvalidBlockHash:
+ return InvalidBlockHashStatus
+ default:
+ return InvalidStatus
+ }
+}
+
+const payloadStatusFixedSize = 9 // status(1) + hash_offset(4) + err_offset(4)
+
+func (p *PayloadStatus) EncodeSSZ(buf []byte) (dst []byte, err error) {
+ status := []byte{EngineStatusToSSZ(p.Status)}
+ hashes := solid.NewHashList(1)
+ if p.LatestValidHash != nil {
+ hashes.Append(*p.LatestValidHash)
+ }
+ var errBytes []byte
+ if p.ValidationError != nil && p.ValidationError.Error() != nil {
+ errBytes = []byte(p.ValidationError.Error().Error())
+ }
+ return ssz2.MarshalSSZ(buf, status, hashes, &ByteListSSZ{data: errBytes})
+}
+
+func (p *PayloadStatus) DecodeSSZ(buf []byte, _ int) error {
+ status := []byte{0}
+ hashes := solid.NewHashList(1)
+ validationError := &ByteListSSZ{}
+ if err := ssz2.UnmarshalSSZ(buf, 0, status, hashes, validationError); err != nil {
+ return fmt.Errorf("PayloadStatus: %w", err)
+ }
+ p.Status = SSZToEngineStatus(status[0])
+ switch hashes.Length() {
+ case 0:
+ p.LatestValidHash = nil
+ case 1:
+ hash := hashes.Get(0)
+ p.LatestValidHash = &hash
+ default:
+ return fmt.Errorf("PayloadStatus: invalid latest valid hash count %d", hashes.Length())
+ }
+ if len(validationError.data) > 1024 {
+ return fmt.Errorf("PayloadStatus: validation error too long (%d > 1024)", len(validationError.data))
+ }
+ if len(validationError.data) > 0 {
+ p.ValidationError = NewStringifiedErrorFromString(string(validationError.data))
+ } else {
+ p.ValidationError = nil
+ }
+ return nil
+}
+
+func (p *PayloadStatus) EncodingSizeSSZ() int {
+ size := payloadStatusFixedSize
+ if p.LatestValidHash != nil {
+ size += 32
+ }
+ if p.ValidationError != nil && p.ValidationError.Error() != nil {
+ size += len(p.ValidationError.Error().Error())
+ }
+ return size
+}
+
+func (p *PayloadStatus) Static() bool { return false }
+func (p *PayloadStatus) Clone() clonable.Clonable { return &PayloadStatus{} }
+
+func DecodePayloadStatus(buf []byte) (*PayloadStatus, error) {
+ p := &PayloadStatus{}
+ if err := p.DecodeSSZ(buf, 0); err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+func (f *ForkChoiceState) EncodeSSZ(buf []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(buf, f.HeadHash[:], f.SafeBlockHash[:], f.FinalizedBlockHash[:])
+}
+
+func (f *ForkChoiceState) DecodeSSZ(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZ(buf, version, f.HeadHash[:], f.SafeBlockHash[:], f.FinalizedBlockHash[:])
+}
+
+func (f *ForkChoiceState) EncodingSizeSSZ() int { return 96 }
+func (f *ForkChoiceState) Static() bool { return true }
+func (f *ForkChoiceState) Clone() clonable.Clonable { return &ForkChoiceState{} }
+
+func EncodeForkchoiceState(fcs *ForkChoiceState) []byte {
+ buf, _ := fcs.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeForkchoiceState(buf []byte) (*ForkChoiceState, error) {
+ s := &ForkChoiceState{}
+ if err := s.DecodeSSZ(buf, 0); err != nil {
+ return nil, fmt.Errorf("ForkchoiceState: %w", err)
+ }
+ return s, nil
+}
+
+func (p *PayloadAttributes) EncodeSSZ(buf []byte) ([]byte, error) {
+ version := payloadAttributesVersionFromFields(p)
+ timestamp := uint64(p.Timestamp)
+ schema := []any{×tamp, p.PrevRandao[:], p.SuggestedFeeRecipient[:]}
+ if version >= 2 {
+ schema = append(schema, executionWithdrawalsToSolid(p.Withdrawals))
+ }
+ if version >= 3 {
+ root := common.Hash{}
+ if p.ParentBeaconBlockRoot != nil {
+ root = *p.ParentBeaconBlockRoot
+ }
+ schema = append(schema, root[:])
+ }
+ return ssz2.MarshalSSZ(buf, schema...)
+}
+
+func (p *PayloadAttributes) DecodeSSZ(buf []byte, version int) error {
+ var timestamp uint64
+ schema := []any{×tamp, p.PrevRandao[:], p.SuggestedFeeRecipient[:]}
+ withdrawals := solid.NewStaticListSSZ[*cltypes.Withdrawal](1_048_576, (&cltypes.Withdrawal{}).EncodingSizeSSZ())
+ if version >= 2 {
+ schema = append(schema, withdrawals)
+ }
+ var parentBeaconBlockRoot common.Hash
+ if version >= 3 {
+ schema = append(schema, parentBeaconBlockRoot[:])
+ }
+ if err := ssz2.UnmarshalSSZ(buf, version, schema...); err != nil {
+ return fmt.Errorf("PayloadAttributes: %w", err)
+ }
+ p.Timestamp = hexutil.Uint64(timestamp)
+ if version >= 2 {
+ p.Withdrawals = solidWithdrawalsToExecution(withdrawals)
+ }
+ if version >= 3 {
+ p.ParentBeaconBlockRoot = &parentBeaconBlockRoot
+ }
+ return nil
+}
+
+func (p *PayloadAttributes) EncodingSizeSSZ() int {
+ version := payloadAttributesVersionFromFields(p)
+ size := 60
+ if version >= 2 {
+ size += 4 + len(p.Withdrawals)*(&cltypes.Withdrawal{}).EncodingSizeSSZ()
+ }
+ if version >= 3 {
+ size += 32
+ }
+ return size
+}
+
+func (p *PayloadAttributes) Static() bool { return false }
+func (p *PayloadAttributes) Clone() clonable.Clonable { return &PayloadAttributes{} }
+func (p *PayloadAttributes) HashSSZ() ([32]byte, error) {
+ version := payloadAttributesVersionFromFields(p)
+ timestamp := uint64(p.Timestamp)
+ schema := []any{×tamp, p.PrevRandao[:], p.SuggestedFeeRecipient[:]}
+ if version >= 2 {
+ schema = append(schema, executionWithdrawalsToSolid(p.Withdrawals))
+ }
+ if version >= 3 {
+ root := common.Hash{}
+ if p.ParentBeaconBlockRoot != nil {
+ root = *p.ParentBeaconBlockRoot
+ }
+ schema = append(schema, root[:])
+ }
+ return merkle_tree.HashTreeRoot(schema...)
+}
+
+func payloadAttributesVersionFromFields(p *PayloadAttributes) int {
+ if p.ParentBeaconBlockRoot != nil {
+ return 3
+ }
+ if p.Withdrawals != nil {
+ return 2
+ }
+ return 1
+}
+
+const forkchoiceUpdatedResponseFixedSize = 8
+
+func (r *ForkChoiceUpdatedResponse) EncodeSSZ(buf []byte) (dst []byte, err error) {
+ var payloadID []byte
+ if r.PayloadId != nil {
+ payloadID = []byte(*r.PayloadId)
+ }
+ return ssz2.MarshalSSZ(buf, r.PayloadStatus, &ByteListSSZ{data: payloadID})
+}
+
+func (r *ForkChoiceUpdatedResponse) DecodeSSZ(buf []byte, _ int) error {
+ r.PayloadStatus = &PayloadStatus{}
+ payloadIDBytes := &ByteListSSZ{}
+ if err := ssz2.UnmarshalSSZ(buf, 0, r.PayloadStatus, payloadIDBytes); err != nil {
+ return fmt.Errorf("ForkChoiceUpdatedResponse: %w", err)
+ }
+
+ if len(payloadIDBytes.data) == 8 {
+ payloadID := make(hexutil.Bytes, 8)
+ copy(payloadID, payloadIDBytes.data)
+ r.PayloadId = &payloadID
+ } else if len(payloadIDBytes.data) == 0 {
+ r.PayloadId = nil
+ } else {
+ return fmt.Errorf("ForkChoiceUpdatedResponse: invalid payload ID length %d", len(payloadIDBytes.data))
+ }
+ return nil
+}
+
+func (r *ForkChoiceUpdatedResponse) EncodingSizeSSZ() int {
+ size := forkchoiceUpdatedResponseFixedSize
+ if r.PayloadStatus != nil {
+ size += r.PayloadStatus.EncodingSizeSSZ()
+ }
+ if r.PayloadId != nil {
+ size += len(*r.PayloadId)
+ }
+ return size
+}
+
+func (r *ForkChoiceUpdatedResponse) Static() bool { return false }
+func (r *ForkChoiceUpdatedResponse) Clone() clonable.Clonable { return &ForkChoiceUpdatedResponse{} }
+
+func EncodeForkchoiceUpdatedResponse(resp *ForkChoiceUpdatedResponse) []byte {
+ buf, _ := resp.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeForkchoiceUpdatedResponse(buf []byte) (*ForkChoiceUpdatedResponse, error) {
+ r := &ForkChoiceUpdatedResponse{}
+ if err := r.DecodeSSZ(buf, 0); err != nil {
+ return nil, err
+ }
+ return r, nil
+}
+
+// ---------------------------------------------------------------
+// SSZ Helper Types (SizedObjectSSZ implementations)
+// ---------------------------------------------------------------
+
+// ByteListSSZ wraps a byte slice for use in SSZ schemas as a variable-length field.
+type ByteListSSZ struct{ data []byte }
+
+func (b *ByteListSSZ) EncodeSSZ(buf []byte) ([]byte, error) { return append(buf, b.data...), nil }
+func (b *ByteListSSZ) DecodeSSZ(buf []byte, _ int) error {
+ b.data = append([]byte(nil), buf...)
+ return nil
+}
+func (b *ByteListSSZ) EncodingSizeSSZ() int { return len(b.data) }
+func (b *ByteListSSZ) Static() bool { return false }
+func (b *ByteListSSZ) Clone() clonable.Clonable { return &ByteListSSZ{} }
+
+// ConcatBytesListSSZ wraps a list of fixed-size byte slices (commitments, proofs, blobs).
+type ConcatBytesListSSZ struct {
+ items [][]byte
+ itemSize int
+}
+
+func (c *ConcatBytesListSSZ) EncodeSSZ(buf []byte) ([]byte, error) {
+ for _, item := range c.items {
+ buf = append(buf, item...)
+ }
+ return buf, nil
+}
+
+func (c *ConcatBytesListSSZ) DecodeSSZ(buf []byte, _ int) error {
+ if len(buf) == 0 {
+ c.items = nil
+ return nil
+ }
+ if c.itemSize > 0 && len(buf)%c.itemSize != 0 {
+ return fmt.Errorf("ConcatBytesListSSZ: length %d not aligned to %d", len(buf), c.itemSize)
+ }
+ if c.itemSize == 0 {
+ c.items = [][]byte{append([]byte(nil), buf...)}
+ return nil
+ }
+ count := len(buf) / c.itemSize
+ c.items = make([][]byte, count)
+ for i := range count {
+ c.items[i] = append([]byte(nil), buf[i*c.itemSize:(i+1)*c.itemSize]...)
+ }
+ return nil
+}
+
+func (c *ConcatBytesListSSZ) EncodingSizeSSZ() int {
+ size := 0
+ for _, item := range c.items {
+ size += len(item)
+ }
+ return size
+}
+
+func (c *ConcatBytesListSSZ) Static() bool { return false }
+func (c *ConcatBytesListSSZ) Clone() clonable.Clonable {
+ return &ConcatBytesListSSZ{itemSize: c.itemSize}
+}
+
+// ---------------------------------------------------------------
+// ExchangeCapabilities SSZ
+// ---------------------------------------------------------------
+
+type stringSSZ struct {
+ data string
+}
+
+func (s *stringSSZ) EncodeSSZ(buf []byte) ([]byte, error) {
+ return append(buf, s.data...), nil
+}
+
+func (s *stringSSZ) DecodeSSZ(buf []byte, _ int) error {
+ s.data = string(buf)
+ return nil
+}
+
+func (s *stringSSZ) EncodingSizeSSZ() int {
+ return len(s.data)
+}
+
+func (s *stringSSZ) Static() bool { return false }
+func (s *stringSSZ) Clone() clonable.Clonable { return &stringSSZ{} }
+func (s *stringSSZ) HashSSZ() ([32]byte, error) {
+ return merkle_tree.HashTreeRoot([]byte(s.data))
+}
+
+// Convenience wrappers (backward-compatible API).
+func EncodeCapabilities(capabilities []string) []byte {
+ caps := make([]*stringSSZ, len(capabilities))
+ for i, cap := range capabilities {
+ caps[i] = &stringSSZ{data: cap}
+ }
+ list := solid.NewDynamicListSSZFromList[*stringSSZ](caps, 128)
+ buf, _ := ssz2.MarshalSSZ(nil, list)
+ return buf
+}
+
+func DecodeCapabilities(buf []byte) ([]string, error) {
+ list := solid.NewDynamicListSSZ[*stringSSZ](128)
+ if err := ssz2.UnmarshalSSZ(buf, 0, list); err != nil {
+ return nil, err
+ }
+ capabilities := make([]string, 0, list.Len())
+ list.Range(func(_ int, value *stringSSZ, _ int) bool {
+ capabilities = append(capabilities, value.data)
+ return true
+ })
+ return capabilities, nil
+}
+
+func (cv *ClientVersionV1) EncodeSSZ(buf []byte) (dst []byte, err error) {
+ commit := clientVersionCommitBytes(cv.Commit)
+ return ssz2.MarshalSSZ(buf,
+ &ByteListSSZ{data: []byte(cv.Code)},
+ &ByteListSSZ{data: []byte(cv.Name)},
+ &ByteListSSZ{data: []byte(cv.Version)},
+ commit[:],
+ )
+}
+
+func (cv *ClientVersionV1) DecodeSSZ(buf []byte, _ int) error {
+ code := &ByteListSSZ{}
+ name := &ByteListSSZ{}
+ version := &ByteListSSZ{}
+ var commit [4]byte
+ if err := ssz2.UnmarshalSSZ(buf, 0, code, name, version, commit[:]); err != nil {
+ return fmt.Errorf("ClientVersion: %w", err)
+ }
+ cv.Code = string(code.data)
+ cv.Name = string(name.data)
+ cv.Version = string(version.data)
+ cv.Commit = hexutil.Encode(commit[:])
+ return nil
+}
+
+func (cv *ClientVersionV1) EncodingSizeSSZ() int {
+ return 16 + len(cv.Code) + len(cv.Name) + len(cv.Version)
+}
+func (cv *ClientVersionV1) Static() bool { return false }
+func (cv *ClientVersionV1) Clone() clonable.Clonable { return &ClientVersionV1{} }
+func (cv *ClientVersionV1) HashSSZ() ([32]byte, error) {
+ commit := clientVersionCommitBytes(cv.Commit)
+ return merkle_tree.HashTreeRoot([]byte(cv.Code), []byte(cv.Name), []byte(cv.Version), commit[:])
+}
+
+func clientVersionCommitBytes(commit string) [4]byte {
+ var out [4]byte
+ if commitRaw, err := hexutil.Decode(commit); err == nil {
+ copy(out[:], commitRaw)
+ }
+ return out
+}
+
+func EncodeClientVersion(cv *ClientVersionV1) []byte {
+ buf, _ := cv.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeClientVersion(buf []byte) (*ClientVersionV1, error) {
+ cv := &ClientVersionV1{}
+ if err := cv.DecodeSSZ(buf, 0); err != nil {
+ return nil, err
+ }
+ return cv, nil
+}
+
+func EncodeClientVersions(versions []ClientVersionV1) []byte {
+ items := make([]*ClientVersionV1, len(versions))
+ for i := range versions {
+ items[i] = &versions[i]
+ }
+ l := solid.NewDynamicListSSZFromList[*ClientVersionV1](items, 16)
+ buf, _ := ssz2.MarshalSSZ(nil, l)
+ return buf
+}
+
+func DecodeClientVersions(buf []byte) ([]ClientVersionV1, error) {
+ l := solid.NewDynamicListSSZ[*ClientVersionV1](16)
+ if err := ssz2.UnmarshalSSZ(buf, 0, l); err != nil {
+ return nil, err
+ }
+ result := make([]ClientVersionV1, 0, l.Len())
+ l.Range(func(_ int, value *ClientVersionV1, _ int) bool {
+ result = append(result, *value)
+ return true
+ })
+ return result, nil
+}
+
+func EncodeGetBlobsRequest(hashes []common.Hash) []byte {
+ versionedHashes := solid.NewHashList(max(len(hashes), 4096))
+ for _, hash := range hashes {
+ versionedHashes.Append(hash)
+ }
+ buf, _ := ssz2.MarshalSSZ(nil, versionedHashes)
+ return buf
+}
+
+func DecodeGetBlobsRequest(buf []byte) ([]common.Hash, error) {
+ versionedHashes := solid.NewHashList(4096)
+ if err := ssz2.UnmarshalSSZ(buf, 0, versionedHashes); err != nil {
+ return nil, err
+ }
+ return hashListToSlice(versionedHashes), nil
+}
+
+const (
+ blobAndProofV1BlobSize = 131072
+ blobAndProofV1ProofSize = 48
+ blobAndProofV1Size = blobAndProofV1BlobSize + blobAndProofV1ProofSize
+)
+
+func (b *BlobAndProofV1) EncodeSSZ(buf []byte) ([]byte, error) {
+ start := len(buf)
+ buf = append(buf, make([]byte, blobAndProofV1Size)...)
+ if b == nil {
+ return buf, nil
+ }
+ copy(buf[start:start+blobAndProofV1BlobSize], b.Blob)
+ copy(buf[start+blobAndProofV1BlobSize:start+blobAndProofV1Size], b.Proof)
+ return buf, nil
+}
+
+func (b *BlobAndProofV1) DecodeSSZ(buf []byte, version int) error {
+ blob := make([]byte, blobAndProofV1BlobSize)
+ proof := make([]byte, blobAndProofV1ProofSize)
+ if err := ssz2.UnmarshalSSZ(buf, version, blob, proof); err != nil {
+ return fmt.Errorf("BlobAndProofV1: %w", err)
+ }
+ b.Blob = blob
+ b.Proof = proof
+ return nil
+}
+
+func (b *BlobAndProofV1) EncodingSizeSSZ() int { return blobAndProofV1Size }
+func (b *BlobAndProofV1) Static() bool { return true }
+func (b *BlobAndProofV1) Clone() clonable.Clonable { return &BlobAndProofV1{} }
+func (b *BlobAndProofV1) HashSSZ() ([32]byte, error) {
+ if b == nil {
+ return merkle_tree.HashTreeRoot(make([]byte, blobAndProofV1BlobSize), make([]byte, blobAndProofV1ProofSize))
+ }
+ return merkle_tree.HashTreeRoot(fixedBytes(b.Blob, blobAndProofV1BlobSize), fixedBytes(b.Proof, blobAndProofV1ProofSize))
+}
+
+func EncodeGetBlobsV1Response(blobs []*BlobAndProofV1) []byte {
+ items := make([]*BlobAndProofV1, 0, len(blobs))
+ for _, blob := range blobs {
+ if blob != nil {
+ items = append(items, blob)
+ }
+ }
+ list := solid.NewStaticListSSZFromList[*BlobAndProofV1](items, 4096, blobAndProofV1Size)
+ buf, _ := ssz2.MarshalSSZ(nil, list)
+ return buf
+}
+
+func fixedBytes(src []byte, size int) []byte {
+ dst := make([]byte, size)
+ copy(dst, src)
+ return dst
+}
+
+func hashListFromSlice(hashes []common.Hash) solid.HashListSSZ {
+ versionedHashes := solid.NewHashList(max(len(hashes), 4096))
+ for _, hash := range hashes {
+ versionedHashes.Append(hash)
+ }
+ return versionedHashes
+}
+
+func hashListToSlice(versionedHashes solid.HashListSSZ) []common.Hash {
+ hashes := make([]common.Hash, 0, versionedHashes.Length())
+ versionedHashes.Range(func(_ int, hash common.Hash, _ int) bool {
+ hashes = append(hashes, hash)
+ return true
+ })
+ return hashes
+}
+
+// engineVersionToPayloadVersion maps Engine API versions to ExecutionPayload SSZ versions.
+func engineVersionToPayloadVersion(engineVersion int) int {
+ if engineVersion == 4 {
+ return 3
+ }
+ if engineVersion >= 5 {
+ return 4
+ }
+ return engineVersion
+}
+
+func (e *ExecutionPayload) EncodeSSZ(buf []byte) ([]byte, error) {
+ version := e.sszVersion
+ if version == 0 {
+ version = executionPayloadVersionFromFields(e)
+ }
+ logsBloom := executionPayloadLogsBloom(e)
+ extraData := solid.NewExtraData()
+ extraData.SetBytes(e.ExtraData)
+ baseFee := [32]byte{}
+ if e.BaseFeePerGas != nil {
+ baseFee = uint256ToSSZBytes(e.BaseFeePerGas.ToInt())
+ }
+ txs := make([][]byte, len(e.Transactions))
+ for i, tx := range e.Transactions {
+ txs[i] = []byte(tx)
+ }
+ blockNumber := uint64(e.BlockNumber)
+ gasLimit := uint64(e.GasLimit)
+ gasUsed := uint64(e.GasUsed)
+ timestamp := uint64(e.Timestamp)
+ schema := []any{
+ e.ParentHash[:], e.FeeRecipient[:], e.StateRoot[:], e.ReceiptsRoot[:],
+ logsBloom[:], e.PrevRandao[:],
+ &blockNumber, &gasLimit, &gasUsed, ×tamp,
+ extraData, baseFee[:], e.BlockHash[:], solid.NewTransactionsSSZFromTransactions(txs),
+ }
+ if version >= 2 {
+ schema = append(schema, executionWithdrawalsToSolid(e.Withdrawals))
+ }
+ if version >= 3 {
+ blobGasUsed, excessBlobGas := uint64(0), uint64(0)
+ if e.BlobGasUsed != nil {
+ blobGasUsed = uint64(*e.BlobGasUsed)
+ }
+ if e.ExcessBlobGas != nil {
+ excessBlobGas = uint64(*e.ExcessBlobGas)
+ }
+ schema = append(schema, &blobGasUsed, &excessBlobGas)
+ }
+ if version >= 4 {
+ slotNumber := uint64(0)
+ if e.SlotNumber != nil {
+ slotNumber = uint64(*e.SlotNumber)
+ }
+ schema = append(schema, &slotNumber, &ByteListSSZ{data: []byte(e.BlockAccessList)})
+ }
+ return ssz2.MarshalSSZ(buf, schema...)
+}
+
+func (e *ExecutionPayload) DecodeSSZ(buf []byte, version int) error {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ var (
+ logsBloom [256]byte
+ blockNumber uint64
+ gasLimit uint64
+ gasUsed uint64
+ timestamp uint64
+ baseFee [32]byte
+ extraData = solid.NewExtraData()
+ transactions = &solid.TransactionsSSZ{}
+ withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](1_048_576, (&cltypes.Withdrawal{}).EncodingSizeSSZ())
+ blobGasUsed uint64
+ excessBlobGas uint64
+ slotNumber uint64
+ blockAccess = &ByteListSSZ{}
+ )
+ schema := []any{
+ e.ParentHash[:], e.FeeRecipient[:], e.StateRoot[:], e.ReceiptsRoot[:],
+ logsBloom[:], e.PrevRandao[:],
+ &blockNumber, &gasLimit, &gasUsed, ×tamp,
+ extraData, baseFee[:], e.BlockHash[:], transactions,
+ }
+ if payloadVersion >= 2 {
+ schema = append(schema, withdrawals)
+ }
+ if payloadVersion >= 3 {
+ schema = append(schema, &blobGasUsed, &excessBlobGas)
+ }
+ if payloadVersion >= 4 {
+ schema = append(schema, &slotNumber, blockAccess)
+ }
+ if err := ssz2.UnmarshalSSZ(buf, payloadVersion, schema...); err != nil {
+ return err
+ }
+ e.sszVersion = payloadVersion
+ e.LogsBloom = make(hexutil.Bytes, 256)
+ copy(e.LogsBloom, logsBloom[:])
+ e.BlockNumber = hexutil.Uint64(blockNumber)
+ e.GasLimit = hexutil.Uint64(gasLimit)
+ e.GasUsed = hexutil.Uint64(gasUsed)
+ e.Timestamp = hexutil.Uint64(timestamp)
+ e.ExtraData = extraData.Bytes()
+ baseFeeInt := sszBytesToUint256(baseFee[:])
+ e.BaseFeePerGas = (*hexutil.Big)(baseFeeInt)
+ e.Transactions = transactionsToHex(transactions)
+ if payloadVersion >= 2 {
+ e.Withdrawals = solidWithdrawalsToExecution(withdrawals)
+ }
+ if payloadVersion >= 3 {
+ bgu := hexutil.Uint64(blobGasUsed)
+ e.BlobGasUsed = &bgu
+ ebg := hexutil.Uint64(excessBlobGas)
+ e.ExcessBlobGas = &ebg
+ }
+ if payloadVersion >= 4 {
+ sn := hexutil.Uint64(slotNumber)
+ e.SlotNumber = &sn
+ e.BlockAccessList = make(hexutil.Bytes, len(blockAccess.data))
+ copy(e.BlockAccessList, blockAccess.data)
+ }
+ return nil
+}
+
+func (e *ExecutionPayload) EncodingSizeSSZ() int {
+ version := e.sszVersion
+ if version == 0 {
+ version = executionPayloadVersionFromFields(e)
+ }
+ size := 508 // fixed part for v1 (includes ExtraData and Transactions offset slots)
+ size += len(e.ExtraData)
+ for _, tx := range e.Transactions {
+ size += len(tx) + 4
+ }
+ if version >= 2 {
+ size += 4 + len(e.Withdrawals)*(&cltypes.Withdrawal{}).EncodingSizeSSZ()
+ }
+ if version >= 3 {
+ size += 16
+ }
+ if version >= 4 {
+ size += 12 + len(e.BlockAccessList)
+ }
+ return size
+}
+
+func (e *ExecutionPayload) Static() bool { return false }
+func (e *ExecutionPayload) Clone() clonable.Clonable { return &ExecutionPayload{} }
+
+func executionPayloadVersionFromFields(ep *ExecutionPayload) int {
+ if ep.SlotNumber != nil || ep.BlockAccessList != nil {
+ return 4
+ }
+ if ep.BlobGasUsed != nil || ep.ExcessBlobGas != nil {
+ return 3
+ }
+ if ep.Withdrawals != nil {
+ return 2
+ }
+ return 1
+}
+
+// uint256ToSSZBytes converts a big.Int to 32-byte little-endian SSZ representation.
+func uint256ToSSZBytes(val *big.Int) [32]byte {
+ var buf [32]byte
+ if val == nil {
+ return buf
+ }
+ b := val.Bytes()
+ for i, v := range b {
+ buf[len(b)-1-i] = v
+ }
+ return buf
+}
+
+// sszBytesToUint256 converts 32-byte little-endian SSZ bytes to a big.Int.
+func sszBytesToUint256(buf []byte) *big.Int {
+ be := make([]byte, 32)
+ for i := 0; i < 32; i++ {
+ be[31-i] = buf[i]
+ }
+ return new(big.Int).SetBytes(be)
+}
+
+func executionPayloadLogsBloom(ep *ExecutionPayload) [256]byte {
+ var logsBloom [256]byte
+ if len(ep.LogsBloom) >= 256 {
+ copy(logsBloom[:], ep.LogsBloom[:256])
+ }
+ return logsBloom
+}
+
+func executionWithdrawalsToSolid(withdrawals []*types.Withdrawal) *solid.ListSSZ[*cltypes.Withdrawal] {
+ wds := make([]*cltypes.Withdrawal, len(withdrawals))
+ for i, w := range withdrawals {
+ wds[i] = &cltypes.Withdrawal{Index: w.Index, Validator: w.Validator, Address: w.Address, Amount: w.Amount}
+ }
+ return solid.NewStaticListSSZFromList[*cltypes.Withdrawal](wds, 1_048_576, (&cltypes.Withdrawal{}).EncodingSizeSSZ())
+}
+
+func solidWithdrawalsToExecution(withdrawals *solid.ListSSZ[*cltypes.Withdrawal]) []*types.Withdrawal {
+ out := make([]*types.Withdrawal, 0, withdrawals.Len())
+ withdrawals.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
+}
+
+func transactionsToHex(transactions *solid.TransactionsSSZ) []hexutil.Bytes {
+ txs := transactions.UnderlyngReference()
+ if len(txs) == 0 {
+ return []hexutil.Bytes{}
+ }
+ out := make([]hexutil.Bytes, len(txs))
+ for i, tx := range txs {
+ out[i] = make(hexutil.Bytes, len(tx))
+ copy(out[i], tx)
+ }
+ return out
+}
+
+// Convenience wrappers (backward-compatible API).
+func EncodeExecutionPayloadSSZ(ep *ExecutionPayload, version int) []byte {
+ ep.sszVersion = version
+ buf, _ := ep.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeExecutionPayloadSSZ(buf []byte, version int) (*ExecutionPayload, error) {
+ ep := &ExecutionPayload{sszVersion: version}
+ if err := ep.DecodeSSZ(buf, version); err != nil {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: %w", err)
+ }
+ return ep, nil
+}
+
+// ---------------------------------------------------------------
+// StructuredExecutionRequests SSZ
+// ---------------------------------------------------------------
+
+// StructuredRequestsSSZ is the SSZ container for execution requests
+// (deposits, withdrawals, consolidations) as 3 dynamic byte fields.
+type StructuredRequestsSSZ struct {
+ Deposits *ByteListSSZ
+ Withdrawals *ByteListSSZ
+ Consolidations *ByteListSSZ
+}
+
+func (r *StructuredRequestsSSZ) EncodeSSZ(buf []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(buf, r.Deposits, r.Withdrawals, r.Consolidations)
+}
+
+func (r *StructuredRequestsSSZ) DecodeSSZ(buf []byte, version int) error {
+ if r.Deposits == nil {
+ r.Deposits = &ByteListSSZ{}
+ }
+ if r.Withdrawals == nil {
+ r.Withdrawals = &ByteListSSZ{}
+ }
+ if r.Consolidations == nil {
+ r.Consolidations = &ByteListSSZ{}
+ }
+ return ssz2.UnmarshalSSZ(buf, version, r.Deposits, r.Withdrawals, r.Consolidations)
+}
+
+func (r *StructuredRequestsSSZ) EncodingSizeSSZ() int {
+ return 12 + r.Deposits.EncodingSizeSSZ() + r.Withdrawals.EncodingSizeSSZ() + r.Consolidations.EncodingSizeSSZ()
+}
+
+func (r *StructuredRequestsSSZ) Static() bool { return false }
+func (r *StructuredRequestsSSZ) Clone() clonable.Clonable { return &StructuredRequestsSSZ{} }
+
+func structuredRequestsFromSlice(reqs []hexutil.Bytes) *StructuredRequestsSSZ {
+ s := &StructuredRequestsSSZ{
+ Deposits: &ByteListSSZ{}, Withdrawals: &ByteListSSZ{}, Consolidations: &ByteListSSZ{},
+ }
+ for _, r := range reqs {
+ if len(r) < 1 {
+ continue
+ }
+ switch r[0] {
+ case 0x00:
+ s.Deposits.data = append(s.Deposits.data, r[1:]...)
+ case 0x01:
+ s.Withdrawals.data = append(s.Withdrawals.data, r[1:]...)
+ case 0x02:
+ s.Consolidations.data = append(s.Consolidations.data, r[1:]...)
+ }
+ }
+ return s
+}
+
+func (r *StructuredRequestsSSZ) toSlice() []hexutil.Bytes {
+ reqs := make([]hexutil.Bytes, 0, 3)
+ if len(r.Deposits.data) > 0 {
+ req := make(hexutil.Bytes, 1+len(r.Deposits.data))
+ req[0] = 0x00
+ copy(req[1:], r.Deposits.data)
+ reqs = append(reqs, req)
+ }
+ if len(r.Withdrawals.data) > 0 {
+ req := make(hexutil.Bytes, 1+len(r.Withdrawals.data))
+ req[0] = 0x01
+ copy(req[1:], r.Withdrawals.data)
+ reqs = append(reqs, req)
+ }
+ if len(r.Consolidations.data) > 0 {
+ req := make(hexutil.Bytes, 1+len(r.Consolidations.data))
+ req[0] = 0x02
+ copy(req[1:], r.Consolidations.data)
+ reqs = append(reqs, req)
+ }
+ return reqs
+}
+
+func (n *NewPayloadRequest) getSchema() []any {
+ hashes := hashListFromSlice(n.BlobVersionedHashes)
+ if n.sszVersion == 3 {
+ return []any{n.Payload, hashes, n.ParentBeaconBlockRoot[:]}
+ }
+ // V4+
+ return []any{n.Payload, hashes, n.ParentBeaconBlockRoot[:], structuredRequestsFromSlice(n.ExecutionRequests)}
+}
+
+func (n *NewPayloadRequest) EncodeSSZ(buf []byte) ([]byte, error) {
+ if n.sszVersion <= 2 {
+ return n.Payload.EncodeSSZ(buf)
+ }
+ return ssz2.MarshalSSZ(buf, n.getSchema()...)
+}
+
+func (n *NewPayloadRequest) DecodeSSZ(buf []byte, version int) error {
+ n.sszVersion = version
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if n.Payload == nil {
+ n.Payload = &ExecutionPayload{sszVersion: payloadVersion}
+ }
+ n.Payload.sszVersion = payloadVersion
+ if version <= 2 {
+ return n.Payload.DecodeSSZ(buf, payloadVersion)
+ }
+ hashes := solid.NewHashList(4096)
+ executionRequests := &StructuredRequestsSSZ{
+ Deposits: &ByteListSSZ{}, Withdrawals: &ByteListSSZ{}, Consolidations: &ByteListSSZ{},
+ }
+ schema := []any{n.Payload, hashes, n.ParentBeaconBlockRoot[:]}
+ if version >= 4 {
+ schema = append(schema, executionRequests)
+ }
+ if err := ssz2.UnmarshalSSZ(buf, version, schema...); err != nil {
+ return err
+ }
+ n.BlobVersionedHashes = hashListToSlice(hashes)
+ if version >= 4 {
+ n.ExecutionRequests = executionRequests.toSlice()
+ }
+ return nil
+}
+
+func (n *NewPayloadRequest) EncodingSizeSSZ() int {
+ if n.sszVersion <= 2 {
+ return n.Payload.EncodingSizeSSZ()
+ }
+ size := 4 + 4 + 32 // payload offset + hashes offset + parent root
+ size += n.Payload.EncodingSizeSSZ()
+ size += len(n.BlobVersionedHashes) * 32
+ if n.sszVersion >= 4 {
+ size += 4 // requests offset
+ size += structuredRequestsFromSlice(n.ExecutionRequests).EncodingSizeSSZ()
+ }
+ return size
+}
+
+func (n *NewPayloadRequest) Static() bool { return false }
+func (n *NewPayloadRequest) Clone() clonable.Clonable { return &NewPayloadRequest{} }
+
+// Convenience wrappers (backward-compatible API).
+func EncodeNewPayloadRequest(
+ ep *ExecutionPayload,
+ blobHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests []hexutil.Bytes,
+ version int,
+) []byte {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ n := &NewPayloadRequest{
+ Payload: ep,
+ BlobVersionedHashes: blobHashes,
+ ExecutionRequests: executionRequests,
+ ParentBeaconBlockRoot: common.Hash{},
+ sszVersion: version,
+ }
+ n.Payload.sszVersion = payloadVersion
+ if parentBeaconBlockRoot != nil {
+ n.ParentBeaconBlockRoot = *parentBeaconBlockRoot
+ }
+ buf, _ := n.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeNewPayloadRequest(buf []byte, version int) (
+ ep *ExecutionPayload,
+ blobHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests []hexutil.Bytes,
+ err error,
+) {
+ n := &NewPayloadRequest{sszVersion: version}
+ if err = n.DecodeSSZ(buf, version); err != nil {
+ return
+ }
+ ep = n.Payload
+ if version >= 3 {
+ blobHashes = n.BlobVersionedHashes
+ root := n.ParentBeaconBlockRoot
+ parentBeaconBlockRoot = &root
+ }
+ if version >= 4 {
+ executionRequests = n.ExecutionRequests
+ }
+ return
+}
+
+func (b *BlobsBundle) EncodeSSZ(buf []byte) ([]byte, error) {
+ commitments, proofs, blobs := b.sszLists()
+ return ssz2.MarshalSSZ(buf, commitments, proofs, blobs)
+}
+
+func (b *BlobsBundle) DecodeSSZ(buf []byte, version int) error {
+ commitments := &ConcatBytesListSSZ{itemSize: 48}
+ proofs := &ConcatBytesListSSZ{itemSize: 48}
+ blobs := &ConcatBytesListSSZ{itemSize: 131072}
+ if err := ssz2.UnmarshalSSZ(buf, version, commitments, proofs, blobs); err != nil {
+ return err
+ }
+ b.Commitments = bytesToHex(commitments.items)
+ b.Proofs = bytesToHex(proofs.items)
+ b.Blobs = bytesToHex(blobs.items)
+ return nil
+}
+
+func (b *BlobsBundle) EncodingSizeSSZ() int {
+ commitments, proofs, blobs := b.sszLists()
+ return 12 + commitments.EncodingSizeSSZ() + proofs.EncodingSizeSSZ() + blobs.EncodingSizeSSZ()
+}
+
+func (b *BlobsBundle) Static() bool { return false }
+func (b *BlobsBundle) Clone() clonable.Clonable { return &BlobsBundle{} }
+
+func (b *BlobsBundle) sszLists() (*ConcatBytesListSSZ, *ConcatBytesListSSZ, *ConcatBytesListSSZ) {
+ if b == nil {
+ return &ConcatBytesListSSZ{itemSize: 48}, &ConcatBytesListSSZ{itemSize: 48}, &ConcatBytesListSSZ{itemSize: 131072}
+ }
+ toBytes := func(items []hexutil.Bytes) [][]byte {
+ result := make([][]byte, len(items))
+ for i, item := range items {
+ result[i] = []byte(item)
+ }
+ return result
+ }
+ return &ConcatBytesListSSZ{items: toBytes(b.Commitments), itemSize: 48},
+ &ConcatBytesListSSZ{items: toBytes(b.Proofs), itemSize: 48},
+ &ConcatBytesListSSZ{items: toBytes(b.Blobs), itemSize: 131072}
+}
+
+func bytesToHex(items [][]byte) []hexutil.Bytes {
+ result := make([]hexutil.Bytes, len(items))
+ for i, item := range items {
+ result[i] = make(hexutil.Bytes, len(item))
+ copy(result[i], item)
+ }
+ return result
+}
+
+// ---------------------------------------------------------------
+// GetPayload response SSZ
+// ---------------------------------------------------------------
+
+// GetPayloadResponse uses the JSON-RPC response type as the SSZ container.
+func (g *GetPayloadResponse) EncodeSSZ(buf []byte) ([]byte, error) {
+ version := g.sszVersion
+ if version == 0 {
+ version = 1
+ }
+ payloadVersion := engineVersionToPayloadVersion(version)
+ g.ExecutionPayload.sszVersion = payloadVersion
+ if version == 1 {
+ return g.ExecutionPayload.EncodeSSZ(buf)
+ }
+ var overrideByte byte
+ if g.ShouldOverrideBuilder {
+ overrideByte = 1
+ }
+ var blockValue [32]byte
+ if g.BlockValue != nil {
+ blockValue = uint256ToSSZBytes(g.BlockValue.ToInt())
+ }
+ return ssz2.MarshalSSZ(buf,
+ g.ExecutionPayload, blockValue[:], g.BlobsBundle, []byte{overrideByte}, structuredRequestsFromSlice(g.ExecutionRequests),
+ )
+}
+
+func (g *GetPayloadResponse) DecodeSSZ(buf []byte, version int) error {
+ g.sszVersion = version
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if g.ExecutionPayload == nil {
+ g.ExecutionPayload = &ExecutionPayload{sszVersion: payloadVersion}
+ }
+ g.ExecutionPayload.sszVersion = payloadVersion
+ if version == 1 {
+ return g.ExecutionPayload.DecodeSSZ(buf, payloadVersion)
+ }
+ blobsBundle := &BlobsBundle{}
+ executionRequests := &StructuredRequestsSSZ{
+ Deposits: &ByteListSSZ{}, Withdrawals: &ByteListSSZ{}, Consolidations: &ByteListSSZ{},
+ }
+ var blockValue [32]byte
+ overrideByte := []byte{0}
+ if err := ssz2.UnmarshalSSZ(buf, version, g.ExecutionPayload, blockValue[:], blobsBundle, overrideByte, executionRequests); err != nil {
+ return fmt.Errorf("GetPayloadResponse SSZ: %w", err)
+ }
+ blockValueInt := sszBytesToUint256(blockValue[:])
+ g.BlockValue = (*hexutil.Big)(blockValueInt)
+ g.ShouldOverrideBuilder = overrideByte[0] != 0
+ g.BlobsBundle = blobsBundle
+ g.ExecutionRequests = executionRequests.toSlice()
+ return nil
+}
+
+func (g *GetPayloadResponse) EncodingSizeSSZ() int {
+ version := g.sszVersion
+ if version == 0 {
+ version = 1
+ }
+ payloadVersion := engineVersionToPayloadVersion(version)
+ g.ExecutionPayload.sszVersion = payloadVersion
+ if version == 1 {
+ return g.ExecutionPayload.EncodingSizeSSZ()
+ }
+ return 45 + g.ExecutionPayload.EncodingSizeSSZ() + g.BlobsBundle.EncodingSizeSSZ() + structuredRequestsFromSlice(g.ExecutionRequests).EncodingSizeSSZ()
+}
+
+func (g *GetPayloadResponse) Static() bool { return false }
+func (g *GetPayloadResponse) Clone() clonable.Clonable { return &GetPayloadResponse{} }
+
+// Convenience wrappers (backward-compatible API).
+func EncodeGetPayloadResponseSSZ(resp *GetPayloadResponse, version int) []byte {
+ resp.sszVersion = version
+ buf, _ := resp.EncodeSSZ(nil)
+ return buf
+}
+
+func DecodeGetPayloadResponseSSZ(buf []byte, version int) (*GetPayloadResponse, error) {
+ resp := &GetPayloadResponse{sszVersion: version}
+ if err := resp.DecodeSSZ(buf, version); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
diff --git a/execution/engineapi/engine_types/ssz_test.go b/execution/engineapi/engine_types/ssz_test.go
new file mode 100644
index 00000000000..05afc8db581
--- /dev/null
+++ b/execution/engineapi/engine_types/ssz_test.go
@@ -0,0 +1,589 @@
+// Copyright 2025 The Erigon Authors
+// 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 engine_types
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/hexutil"
+ "github.com/erigontech/erigon/execution/types"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPayloadStatusSSZRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ // Test with all fields set
+ hash := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
+ ps := &PayloadStatus{
+ Status: ValidStatus,
+ LatestValidHash: &hash,
+ ValidationError: NewStringifiedErrorFromString("test error"),
+ }
+
+ encoded, err := ps.EncodeSSZ(nil)
+ req.NoError(err)
+ decoded, err := DecodePayloadStatus(encoded)
+ req.NoError(err)
+ req.Equal(ps.Status, decoded.Status)
+ req.NotNil(decoded.LatestValidHash)
+ req.Equal(*ps.LatestValidHash, *decoded.LatestValidHash)
+ req.Equal(ps.ValidationError.Error().Error(), decoded.ValidationError.Error().Error())
+
+ // Test with nil LatestValidHash
+ ps2 := &PayloadStatus{
+ Status: SyncingStatus,
+ LatestValidHash: nil,
+ }
+
+ encoded2, err := ps2.EncodeSSZ(nil)
+ req.NoError(err)
+ decoded2, err := DecodePayloadStatus(encoded2)
+ req.NoError(err)
+ req.Equal(SyncingStatus, decoded2.Status)
+ req.Nil(decoded2.LatestValidHash)
+ req.Nil(decoded2.ValidationError)
+}
+
+func TestPayloadStatusConversion(t *testing.T) {
+ req := require.New(t)
+
+ hash := common.HexToHash("0xabcdef")
+ ps := &PayloadStatus{
+ Status: ValidStatus,
+ LatestValidHash: &hash,
+ ValidationError: NewStringifiedErrorFromString("block invalid"),
+ }
+
+ encoded, err := ps.EncodeSSZ(nil)
+ req.NoError(err)
+ back, err := DecodePayloadStatus(encoded)
+ req.NoError(err)
+ req.Equal(ValidStatus, back.Status)
+ req.Equal(hash, *back.LatestValidHash)
+ req.NotNil(back.ValidationError)
+ req.Equal("block invalid", back.ValidationError.Error().Error())
+}
+
+func TestEngineStatusSSZConversion(t *testing.T) {
+ req := require.New(t)
+
+ tests := []struct {
+ status EngineStatus
+ sszValue uint8
+ }{
+ {ValidStatus, SSZStatusValid},
+ {InvalidStatus, SSZStatusInvalid},
+ {SyncingStatus, SSZStatusSyncing},
+ {AcceptedStatus, SSZStatusAccepted},
+ {InvalidBlockHashStatus, SSZStatusInvalidBlockHash},
+ }
+
+ for _, tt := range tests {
+ req.Equal(tt.sszValue, EngineStatusToSSZ(tt.status), "EngineStatusToSSZ(%s)", tt.status)
+ req.Equal(tt.status, SSZToEngineStatus(tt.sszValue), "SSZToEngineStatus(%d)", tt.sszValue)
+ }
+}
+
+func TestForkchoiceStateRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ fcs := &ForkChoiceState{
+ HeadHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"),
+ SafeBlockHash: common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222"),
+ FinalizedBlockHash: common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"),
+ }
+
+ encoded := EncodeForkchoiceState(fcs)
+ req.Len(encoded, 96)
+
+ decoded, err := DecodeForkchoiceState(encoded)
+ req.NoError(err)
+ req.Equal(fcs.HeadHash, decoded.HeadHash)
+ req.Equal(fcs.SafeBlockHash, decoded.SafeBlockHash)
+ req.Equal(fcs.FinalizedBlockHash, decoded.FinalizedBlockHash)
+}
+
+func TestForkchoiceStateDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeForkchoiceState(make([]byte, 50))
+ req.Error(err)
+}
+
+func TestCapabilitiesRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ caps := []string{
+ "engine_newPayloadV4",
+ "engine_forkchoiceUpdatedV3",
+ "engine_getPayloadV4",
+ }
+
+ encoded := EncodeCapabilities(caps)
+ decoded, err := DecodeCapabilities(encoded)
+ req.NoError(err)
+ req.Equal(caps, decoded)
+}
+
+func TestClientVersionRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ cv := &ClientVersionV1{
+ Code: "EG",
+ Name: "Erigon",
+ Version: "3.0.0",
+ Commit: "0xdeadbeef",
+ }
+
+ encoded := EncodeClientVersion(cv)
+ decoded, err := DecodeClientVersion(encoded)
+ req.NoError(err)
+ req.Equal(cv.Code, decoded.Code)
+ req.Equal(cv.Name, decoded.Name)
+ req.Equal(cv.Version, decoded.Version)
+ req.Equal(cv.Commit, decoded.Commit)
+}
+
+func TestClientVersionsRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ versions := []ClientVersionV1{
+ {Code: "EG", Name: "Erigon", Version: "3.0.0", Commit: "0xdeadbeef"},
+ {Code: "GE", Name: "Geth", Version: "1.14.0", Commit: "0xabcdef01"},
+ }
+
+ encoded := EncodeClientVersions(versions)
+ decoded, err := DecodeClientVersions(encoded)
+ req.NoError(err)
+ req.Len(decoded, 2)
+ req.Equal(versions[0].Code, decoded[0].Code)
+ req.Equal(versions[0].Name, decoded[0].Name)
+ req.Equal(versions[0].Commit, decoded[0].Commit)
+ req.Equal(versions[1].Code, decoded[1].Code)
+ req.Equal(versions[1].Version, decoded[1].Version)
+ req.Equal(versions[1].Commit, decoded[1].Commit)
+}
+
+func TestGetBlobsRequestRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ hashes := []common.Hash{
+ common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"),
+ common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222"),
+ common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"),
+ }
+
+ encoded := EncodeGetBlobsRequest(hashes)
+ decoded, err := DecodeGetBlobsRequest(encoded)
+ req.NoError(err)
+ req.Len(decoded, 3)
+ for i := range hashes {
+ req.Equal(hashes[i], decoded[i])
+ }
+}
+
+func TestGetBlobsRequestEmpty(t *testing.T) {
+ req := require.New(t)
+
+ encoded := EncodeGetBlobsRequest(nil)
+ decoded, err := DecodeGetBlobsRequest(encoded)
+ req.NoError(err)
+ req.Empty(decoded)
+}
+
+func TestPayloadStatusSSZDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodePayloadStatus(make([]byte, 5))
+ req.Error(err)
+}
+
+func TestCapabilitiesDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeCapabilities(make([]byte, 2))
+ req.Error(err)
+}
+
+func TestClientVersionDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeClientVersion(make([]byte, 4))
+ req.Error(err)
+}
+
+func TestGetBlobsRequestDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeGetBlobsRequest(make([]byte, 2))
+ req.Error(err)
+}
+
+// --- ForkchoiceUpdatedResponse round-trip tests ---
+
+func TestForkchoiceUpdatedResponseRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ hash := common.HexToHash("0xabcdef")
+ ps := &PayloadStatus{
+ Status: ValidStatus,
+ LatestValidHash: &hash,
+ }
+ resp := &ForkChoiceUpdatedResponse{
+ PayloadStatus: ps,
+ PayloadId: nil,
+ }
+
+ encoded := EncodeForkchoiceUpdatedResponse(resp)
+ decoded, err := DecodeForkchoiceUpdatedResponse(encoded)
+ req.NoError(err)
+ req.Equal(ValidStatus, decoded.PayloadStatus.Status)
+ req.Equal(hash, *decoded.PayloadStatus.LatestValidHash)
+ req.Nil(decoded.PayloadStatus.ValidationError)
+ req.Nil(decoded.PayloadId)
+}
+
+func TestForkchoiceUpdatedResponseWithPayloadId(t *testing.T) {
+ req := require.New(t)
+
+ hash := common.HexToHash("0x1234")
+ pidBytes := make(hexutil.Bytes, 8)
+ pidBytes[0] = 0x00
+ pidBytes[1] = 0x00
+ pidBytes[2] = 0x00
+ pidBytes[3] = 0x00
+ pidBytes[4] = 0x00
+ pidBytes[5] = 0x00
+ pidBytes[6] = 0x00
+ pidBytes[7] = 0x42
+ ps := &PayloadStatus{
+ Status: SyncingStatus,
+ LatestValidHash: &hash,
+ }
+ resp := &ForkChoiceUpdatedResponse{
+ PayloadStatus: ps,
+ PayloadId: &pidBytes,
+ }
+
+ encoded := EncodeForkchoiceUpdatedResponse(resp)
+ decoded, err := DecodeForkchoiceUpdatedResponse(encoded)
+ req.NoError(err)
+ req.Equal(SyncingStatus, decoded.PayloadStatus.Status)
+ req.NotNil(decoded.PayloadId)
+ req.Equal(pidBytes, *decoded.PayloadId)
+}
+
+func TestForkchoiceUpdatedResponseWithValidationError(t *testing.T) {
+ req := require.New(t)
+
+ hash := common.HexToHash("0xdeadbeef")
+ pidBytes := make(hexutil.Bytes, 8)
+ pidBytes[7] = 0xFF
+ ps := &PayloadStatus{
+ Status: InvalidStatus,
+ LatestValidHash: &hash,
+ ValidationError: NewStringifiedErrorFromString("block gas limit exceeded by a very long error message that makes the buffer larger"),
+ }
+ resp := &ForkChoiceUpdatedResponse{
+ PayloadStatus: ps,
+ PayloadId: &pidBytes,
+ }
+
+ encoded := EncodeForkchoiceUpdatedResponse(resp)
+ decoded, err := DecodeForkchoiceUpdatedResponse(encoded)
+ req.NoError(err)
+ req.Equal(InvalidStatus, decoded.PayloadStatus.Status)
+ req.Equal(hash, *decoded.PayloadStatus.LatestValidHash)
+ req.Equal("block gas limit exceeded by a very long error message that makes the buffer larger", decoded.PayloadStatus.ValidationError.Error().Error())
+ req.NotNil(decoded.PayloadId)
+ req.Equal(pidBytes, *decoded.PayloadId)
+}
+
+func TestForkchoiceUpdatedResponseShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeForkchoiceUpdatedResponse(make([]byte, 4))
+ req.Error(err)
+}
+
+// --- ExecutionPayload SSZ round-trip tests ---
+
+func makeTestExecutionPayloadV1() *ExecutionPayload {
+ baseFee := big.NewInt(1000000000) // 1 gwei
+ return &ExecutionPayload{
+ ParentHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"),
+ FeeRecipient: common.HexToAddress("0x2222222222222222222222222222222222222222"),
+ StateRoot: common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"),
+ ReceiptsRoot: common.HexToHash("0x4444444444444444444444444444444444444444444444444444444444444444"),
+ LogsBloom: make(hexutil.Bytes, 256),
+ PrevRandao: common.HexToHash("0x5555555555555555555555555555555555555555555555555555555555555555"),
+ BlockNumber: hexutil.Uint64(100),
+ GasLimit: hexutil.Uint64(30000000),
+ GasUsed: hexutil.Uint64(21000),
+ Timestamp: hexutil.Uint64(1700000000),
+ ExtraData: hexutil.Bytes{0x01, 0x02, 0x03},
+ BaseFeePerGas: (*hexutil.Big)(baseFee),
+ BlockHash: common.HexToHash("0x6666666666666666666666666666666666666666666666666666666666666666"),
+ Transactions: []hexutil.Bytes{
+ {0xf8, 0x50, 0x80, 0x01, 0x82, 0x52, 0x08},
+ {0xf8, 0x60, 0x80, 0x02, 0x83, 0x01, 0x00, 0x00},
+ },
+ }
+}
+
+func TestExecutionPayloadV1RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+
+ encoded := EncodeExecutionPayloadSSZ(ep, 1)
+ decoded, err := DecodeExecutionPayloadSSZ(encoded, 1)
+ req.NoError(err)
+
+ req.Equal(ep.ParentHash, decoded.ParentHash)
+ req.Equal(ep.FeeRecipient, decoded.FeeRecipient)
+ req.Equal(ep.StateRoot, decoded.StateRoot)
+ req.Equal(ep.ReceiptsRoot, decoded.ReceiptsRoot)
+ req.Equal(ep.PrevRandao, decoded.PrevRandao)
+ req.Equal(ep.BlockNumber, decoded.BlockNumber)
+ req.Equal(ep.GasLimit, decoded.GasLimit)
+ req.Equal(ep.GasUsed, decoded.GasUsed)
+ req.Equal(ep.Timestamp, decoded.Timestamp)
+ req.Equal([]byte(ep.ExtraData), []byte(decoded.ExtraData))
+ req.Equal(ep.BaseFeePerGas.ToInt().String(), decoded.BaseFeePerGas.ToInt().String())
+ req.Equal(ep.BlockHash, decoded.BlockHash)
+ req.Len(decoded.Transactions, 2)
+ req.Equal([]byte(ep.Transactions[0]), []byte(decoded.Transactions[0]))
+ req.Equal([]byte(ep.Transactions[1]), []byte(decoded.Transactions[1]))
+}
+
+func TestExecutionPayloadV2RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Withdrawals = []*types.Withdrawal{
+ {Index: 1, Validator: 100, Address: common.HexToAddress("0xaaaa"), Amount: 32000000000},
+ {Index: 2, Validator: 200, Address: common.HexToAddress("0xbbbb"), Amount: 64000000000},
+ }
+
+ encoded := EncodeExecutionPayloadSSZ(ep, 2)
+ decoded, err := DecodeExecutionPayloadSSZ(encoded, 2)
+ req.NoError(err)
+
+ req.Equal(ep.ParentHash, decoded.ParentHash)
+ req.Equal(ep.BlockHash, decoded.BlockHash)
+ req.Len(decoded.Transactions, 2)
+ req.Len(decoded.Withdrawals, 2)
+ req.Equal(ep.Withdrawals[0].Index, decoded.Withdrawals[0].Index)
+ req.Equal(ep.Withdrawals[0].Validator, decoded.Withdrawals[0].Validator)
+ req.Equal(ep.Withdrawals[0].Address, decoded.Withdrawals[0].Address)
+ req.Equal(ep.Withdrawals[0].Amount, decoded.Withdrawals[0].Amount)
+ req.Equal(ep.Withdrawals[1].Index, decoded.Withdrawals[1].Index)
+}
+
+func TestExecutionPayloadV3RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Withdrawals = []*types.Withdrawal{}
+ blobGasUsed := hexutil.Uint64(131072)
+ excessBlobGas := hexutil.Uint64(262144)
+ ep.BlobGasUsed = &blobGasUsed
+ ep.ExcessBlobGas = &excessBlobGas
+
+ encoded := EncodeExecutionPayloadSSZ(ep, 3)
+ decoded, err := DecodeExecutionPayloadSSZ(encoded, 3)
+ req.NoError(err)
+
+ req.Equal(ep.ParentHash, decoded.ParentHash)
+ req.NotNil(decoded.BlobGasUsed)
+ req.Equal(uint64(131072), uint64(*decoded.BlobGasUsed))
+ req.NotNil(decoded.ExcessBlobGas)
+ req.Equal(uint64(262144), uint64(*decoded.ExcessBlobGas))
+}
+
+func TestExecutionPayloadV3EmptyTransactions(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Transactions = []hexutil.Bytes{}
+ ep.Withdrawals = []*types.Withdrawal{}
+ blobGasUsed := hexutil.Uint64(0)
+ excessBlobGas := hexutil.Uint64(0)
+ ep.BlobGasUsed = &blobGasUsed
+ ep.ExcessBlobGas = &excessBlobGas
+
+ encoded := EncodeExecutionPayloadSSZ(ep, 3)
+ decoded, err := DecodeExecutionPayloadSSZ(encoded, 3)
+ req.NoError(err)
+ req.Empty(decoded.Transactions)
+}
+
+func TestExecutionPayloadSSZDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeExecutionPayloadSSZ(make([]byte, 100), 1)
+ req.Error(err)
+}
+
+// --- NewPayload request SSZ round-trip tests ---
+
+func TestNewPayloadRequestV1RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ encoded := EncodeNewPayloadRequest(ep, nil, nil, nil, 1)
+ decodedEp, blobHashes, parentRoot, execReqs, err := DecodeNewPayloadRequest(encoded, 1)
+ req.NoError(err)
+ req.Nil(blobHashes)
+ req.Nil(parentRoot)
+ req.Nil(execReqs)
+ req.Equal(ep.BlockHash, decodedEp.BlockHash)
+ req.Len(decodedEp.Transactions, 2)
+}
+
+func TestNewPayloadRequestV3RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Withdrawals = []*types.Withdrawal{}
+ blobGasUsed := hexutil.Uint64(0)
+ excessBlobGas := hexutil.Uint64(0)
+ ep.BlobGasUsed = &blobGasUsed
+ ep.ExcessBlobGas = &excessBlobGas
+
+ hashes := []common.Hash{
+ common.HexToHash("0xaaaa"),
+ common.HexToHash("0xbbbb"),
+ }
+ root := common.HexToHash("0xcccc")
+
+ encoded := EncodeNewPayloadRequest(ep, hashes, &root, nil, 3)
+ decodedEp, decodedHashes, decodedRoot, _, err := DecodeNewPayloadRequest(encoded, 3)
+ req.NoError(err)
+ req.Equal(ep.BlockHash, decodedEp.BlockHash)
+ req.Len(decodedHashes, 2)
+ req.Equal(hashes[0], decodedHashes[0])
+ req.Equal(hashes[1], decodedHashes[1])
+ req.Equal(root, *decodedRoot)
+}
+
+func TestNewPayloadRequestV4RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Withdrawals = []*types.Withdrawal{}
+ blobGasUsed := hexutil.Uint64(0)
+ excessBlobGas := hexutil.Uint64(0)
+ ep.BlobGasUsed = &blobGasUsed
+ ep.ExcessBlobGas = &excessBlobGas
+
+ hashes := []common.Hash{common.HexToHash("0xdddd")}
+ root := common.HexToHash("0xeeee")
+ execReqs := []hexutil.Bytes{
+ {0x00, 0x01, 0x02, 0x03},
+ {0x01, 0x04, 0x05},
+ }
+
+ encoded := EncodeNewPayloadRequest(ep, hashes, &root, execReqs, 4)
+ decodedEp, decodedHashes, decodedRoot, decodedReqs, err := DecodeNewPayloadRequest(encoded, 4)
+ req.NoError(err)
+ req.Equal(ep.BlockHash, decodedEp.BlockHash)
+ req.Len(decodedHashes, 1)
+ req.Equal(hashes[0], decodedHashes[0])
+ req.Equal(root, *decodedRoot)
+ req.Len(decodedReqs, 2)
+ req.Equal([]byte(execReqs[0]), []byte(decodedReqs[0]))
+ req.Equal([]byte(execReqs[1]), []byte(decodedReqs[1]))
+}
+
+// --- GetPayload response SSZ round-trip tests ---
+
+func TestGetPayloadResponseV1RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ resp := &GetPayloadResponse{ExecutionPayload: ep}
+
+ encoded := EncodeGetPayloadResponseSSZ(resp, 1)
+ decoded, err := DecodeGetPayloadResponseSSZ(encoded, 1)
+ req.NoError(err)
+ req.Equal(ep.BlockHash, decoded.ExecutionPayload.BlockHash)
+ req.Len(decoded.ExecutionPayload.Transactions, 2)
+}
+
+func TestGetPayloadResponseV3RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ ep.Withdrawals = []*types.Withdrawal{}
+ blobGasUsed := hexutil.Uint64(131072)
+ excessBlobGas := hexutil.Uint64(0)
+ ep.BlobGasUsed = &blobGasUsed
+ ep.ExcessBlobGas = &excessBlobGas
+
+ blockValue := big.NewInt(1234567890)
+ resp := &GetPayloadResponse{
+ ExecutionPayload: ep,
+ BlockValue: (*hexutil.Big)(blockValue),
+ BlobsBundle: &BlobsBundle{},
+ ShouldOverrideBuilder: true,
+ }
+
+ encoded := EncodeGetPayloadResponseSSZ(resp, 3)
+ decoded, err := DecodeGetPayloadResponseSSZ(encoded, 3)
+ req.NoError(err)
+ req.Equal(ep.BlockHash, decoded.ExecutionPayload.BlockHash)
+ req.Equal(blockValue.String(), decoded.BlockValue.ToInt().String())
+ req.True(decoded.ShouldOverrideBuilder)
+}
+
+func TestGetPayloadResponseShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeGetPayloadResponseSSZ(make([]byte, 10), 2)
+ req.Error(err)
+}
+
+// --- uint256 SSZ conversion tests ---
+
+func TestUint256SSZRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ tests := []*big.Int{
+ big.NewInt(0),
+ big.NewInt(1),
+ big.NewInt(1000000000),
+ new(big.Int).SetBytes(common.Hex2Bytes("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
+ }
+
+ for _, val := range tests {
+ encoded := uint256ToSSZBytes(val)
+ req.Len(encoded, 32)
+ decoded := sszBytesToUint256(encoded[:])
+ req.Equal(val.String(), decoded.String(), "round-trip failed for %s", val.String())
+ }
+
+ // Test nil
+ encoded := uint256ToSSZBytes(nil)
+ req.Len(encoded, 32)
+ decoded := sszBytesToUint256(encoded[:])
+ req.Equal("0", decoded.String())
+}
diff --git a/network_params.yaml b/network_params.yaml
new file mode 100644
index 00000000000..779cfc288e1
--- /dev/null
+++ b/network_params.yaml
@@ -0,0 +1,13 @@
+participants:
+ - el_type: erigon
+ el_image: eip8161-el-erigon:latest
+ cl_type: prysm
+ cl_image: eip8161-cl-prysm:latest
+ vc_type: prysm
+ vc_image: eip8161-vc-prysm:latest
+ count: 1
+network_params:
+ network_id: "3151908"
+ seconds_per_slot: 3
+ electra_fork_epoch: 0
+additional_services: []