diff --git a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
index 1b2331be769..96e1fd38bae 100644
--- a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
+++ b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go
@@ -111,4 +111,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
+ SszRestEnabled bool // Enable SSZ-REST Engine API server alongside JSON-RPC
+ SszRestPort int // Port for the SSZ-REST Engine API server (default: AuthRpcPort + 1)
}
diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go
index 81f5326518f..021103dd0ac 100644
--- a/cmd/utils/flags.go
+++ b/cmd/utils/flags.go
@@ -326,6 +326,16 @@ var (
Value: "",
}
+ SszRestEnabledFlag = cli.BoolFlag{
+ Name: "authrpc.ssz-rest",
+ Usage: "Enable the SSZ-REST Engine API transport (EIP-8161) alongside JSON-RPC",
+ }
+ SszRestPortFlag = cli.UintFlag{
+ Name: "authrpc.ssz-rest-port",
+ Usage: "HTTP port for the SSZ-REST Engine API server (default: authrpc.port + 1)",
+ Value: 0,
+ }
+
HttpCompressionFlag = cli.BoolFlag{
Name: "http.compression",
Usage: "Enable compression over HTTP-RPC. Use --http.compression=false to disable it",
diff --git a/execution/engineapi/engine_api_methods.go b/execution/engineapi/engine_api_methods.go
index e10267cbf66..44099f67d32 100644
--- a/execution/engineapi/engine_api_methods.go
+++ b/execution/engineapi/engine_api_methods.go
@@ -284,3 +284,4 @@ func (e *EngineServer) GetBlobsV3(ctx context.Context, blobHashes []common.Hash)
}
return nil, err
}
+
diff --git a/execution/engineapi/engine_server.go b/execution/engineapi/engine_server.go
index 177867b8159..d1e8186deea 100644
--- a/execution/engineapi/engine_server.go
+++ b/execution/engineapi/engine_server.go
@@ -87,6 +87,8 @@ type EngineServer struct {
// TODO Remove this on next release
printPectraBanner bool
maxReorgDepth uint64
+ httpConfig *httpcfg.HttpCfg
+ sszRestPort int // EIP-8161: port the SSZ-REST server is listening on
}
func NewEngineServer(
@@ -140,6 +142,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.RangeLimit)
ethImpl := jsonrpc.NewEthAPI(base, db, eth, e.txpool, mining, jsonrpc.NewEthApiConfig(httpConfig), e.logger)
@@ -164,6 +167,39 @@ func (e *EngineServer) Start(
}
return err
})
+
+ // EIP-8161: Start SSZ-REST Engine API server if enabled
+ if httpConfig.SszRestEnabled {
+ eg.Go(func() error {
+ defer e.logger.Debug("[EngineServer] SSZ-REST server goroutine terminated")
+ jwtSecret, err := cli.ObtainJWTSecret(httpConfig, e.logger)
+ if err != nil {
+ e.logger.Error("[EngineServer] failed to obtain JWT secret for SSZ-REST server", "err", err)
+ return err
+ }
+
+ addr := httpConfig.AuthRpcHTTPListenAddress
+ if addr == "" {
+ addr = "127.0.0.1"
+ }
+ port := httpConfig.SszRestPort
+ if port == 0 {
+ port = httpConfig.AuthRpcPort + 1
+ if httpConfig.AuthRpcPort == 0 {
+ port = 8552
+ }
+ }
+ e.sszRestPort = port
+
+ sszServer := NewSszRestServer(e, e.logger, jwtSecret, addr, port)
+ err = sszServer.Start(ctx)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ e.logger.Error("[EngineServer] SSZ-REST server background goroutine failed", "err", err)
+ }
+ 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..70d6274d620
--- /dev/null
+++ b/execution/engineapi/engine_ssz_rest_server.go
@@ -0,0 +1,621 @@
+// 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 (
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/common"
+ "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/execution/types"
+ "github.com/erigontech/erigon/rpc"
+)
+
+// SszRestServer implements the EIP-8161 SSZ-REST Engine API transport.
+// It runs alongside the JSON-RPC Engine API server and shares the same
+// EngineServer for method dispatch.
+type SszRestServer struct {
+ engine *EngineServer
+ logger log.Logger
+ jwtSecret []byte
+ addr string
+ port int
+ server *http.Server
+}
+
+// NewSszRestServer creates a new SSZ-REST server.
+func NewSszRestServer(engine *EngineServer, logger log.Logger, jwtSecret []byte, addr string, port int) *SszRestServer {
+ return &SszRestServer{
+ engine: engine,
+ logger: logger,
+ jwtSecret: jwtSecret,
+ addr: addr,
+ port: port,
+ }
+}
+
+// sszErrorResponse writes a JSON error response for non-200 status codes per EIP-8161.
+func sszErrorResponse(w http.ResponseWriter, code int, jsonRpcCode int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ resp := struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }{
+ Code: jsonRpcCode,
+ Message: message,
+ }
+ json.NewEncoder(w).Encode(resp) //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
+}
+
+// Start starts the SSZ-REST HTTP server. It blocks until ctx is cancelled.
+func (s *SszRestServer) Start(ctx context.Context) error {
+ mux := http.NewServeMux()
+ s.registerRoutes(mux)
+
+ handler := s.jwtMiddleware(mux)
+
+ listenAddr := fmt.Sprintf("%s:%d", s.addr, s.port)
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return fmt.Errorf("SSZ-REST server failed to listen on %s: %w", listenAddr, err)
+ }
+
+ s.server = &http.Server{
+ Handler: handler,
+ }
+
+ s.logger.Info("[SSZ-REST] Engine API server started", "addr", listenAddr)
+
+ errCh := make(chan error, 1)
+ go func() {
+ if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ errCh <- err
+ }
+ close(errCh)
+ }()
+
+ select {
+ case <-ctx.Done():
+ s.server.Close()
+ return ctx.Err()
+ case err := <-errCh:
+ return err
+ }
+}
+
+// jwtMiddleware wraps an http.Handler with JWT authentication using the same
+// secret and validation logic as the JSON-RPC Engine API (EIP-8161 requirement).
+func (s *SszRestServer) jwtMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !rpc.CheckJwtSecret(w, r, s.jwtSecret) {
+ return // CheckJwtSecret already wrote the error response
+ }
+ // Recover from panics in handlers (e.g., nil pointer dereferences
+ // when engine dependencies are not fully initialized)
+ defer func() {
+ if rec := recover(); rec != nil {
+ s.logger.Error("[SSZ-REST] panic in handler", "panic", rec, "path", r.URL.Path)
+ sszErrorResponse(w, http.StatusInternalServerError, -32603, fmt.Sprintf("internal error: %v", rec))
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+// registerRoutes registers all SSZ-REST endpoint routes per EIP-8161.
+func (s *SszRestServer) registerRoutes(mux *http.ServeMux) {
+ // newPayload versions
+ 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)
+
+ // forkchoiceUpdated versions
+ 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)
+
+ // getPayload versions
+ mux.HandleFunc("POST /engine/v1/get_payload", s.handleGetPayloadV1)
+ mux.HandleFunc("POST /engine/v2/get_payload", s.handleGetPayloadV2)
+ mux.HandleFunc("POST /engine/v3/get_payload", s.handleGetPayloadV3)
+ mux.HandleFunc("POST /engine/v4/get_payload", s.handleGetPayloadV4)
+ mux.HandleFunc("POST /engine/v5/get_payload", s.handleGetPayloadV5)
+
+ // getBlobs
+ mux.HandleFunc("POST /engine/v1/get_blobs", s.handleGetBlobsV1)
+
+ // exchangeCapabilities
+ mux.HandleFunc("POST /engine/v1/exchange_capabilities", s.handleExchangeCapabilities)
+
+ // getClientVersion
+ 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.DecodeNewPayloadRequestSSZ(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
+ }
+
+ // Encode PayloadStatus response
+ ps := engine_types.PayloadStatusToSSZ(result)
+ sszResponse(w, ps.EncodeSSZ())
+}
+
+// --- 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
+ }
+
+ // SSZ Container layout:
+ // Fixed: forkchoice_state(96) + attributes_offset(4) = 100 bytes
+ // Variable: Union[None, PayloadAttributes]
+ const fixedSize = 100
+
+ if len(body) < 96 {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "request body too short for ForkchoiceState")
+ return
+ }
+
+ // Decode ForkchoiceState (first 96 bytes)
+ fcs, err := engine_types.DecodeForkchoiceState(body[:96])
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ var payloadAttributes *engine_types.PayloadAttributes
+
+ if len(body) >= fixedSize {
+ attrOffset := binary.LittleEndian.Uint32(body[96:100])
+ if attrOffset <= uint32(len(body)) && attrOffset < uint32(len(body)) {
+ // Union data at attrOffset
+ unionData := body[attrOffset:]
+ if len(unionData) > 0 {
+ selector := unionData[0]
+ if selector == 1 && len(unionData) > 1 {
+ pa, err := decodePayloadAttributesSSZ(unionData[1:], version)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+ payloadAttributes = pa
+ }
+ // selector == 0 means None
+ }
+ }
+ }
+
+ 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)
+}
+
+// decodePayloadAttributesSSZ decodes PayloadAttributes from SSZ bytes.
+// The version determines the layout:
+// - V1 (Bellatrix): timestamp(8) + prev_randao(32) + fee_recipient(20) = 60 bytes fixed
+// - V2 (Capella): timestamp(8) + prev_randao(32) + fee_recipient(20) + withdrawals_offset(4) = 64 bytes fixed + withdrawals
+// - V3 (Deneb/Electra): same as V2 + parent_beacon_block_root(32) = 96 bytes fixed + withdrawals
+func decodePayloadAttributesSSZ(buf []byte, version int) (*engine_types.PayloadAttributes, error) {
+ if len(buf) < 60 {
+ return nil, fmt.Errorf("PayloadAttributes: buffer too short (%d < 60)", len(buf))
+ }
+
+ timestamp := binary.LittleEndian.Uint64(buf[0:8])
+ pa := &engine_types.PayloadAttributes{
+ Timestamp: hexutil.Uint64(timestamp),
+ }
+ copy(pa.PrevRandao[:], buf[8:40])
+ copy(pa.SuggestedFeeRecipient[:], buf[40:60])
+
+ if version == 1 {
+ return pa, nil
+ }
+
+ // V2+: has withdrawals_offset at byte 60
+ if len(buf) < 64 {
+ return nil, fmt.Errorf("PayloadAttributes V2+: buffer too short (%d < 64)", len(buf))
+ }
+ withdrawalsOffset := binary.LittleEndian.Uint32(buf[60:64])
+
+ if version >= 3 {
+ // V3: has parent_beacon_block_root at bytes 64-96
+ if len(buf) < 96 {
+ return nil, fmt.Errorf("PayloadAttributes V3: buffer too short (%d < 96)", len(buf))
+ }
+ root := common.BytesToHash(buf[64:96])
+ pa.ParentBeaconBlockRoot = &root
+ }
+
+ // Decode withdrawals from the offset
+ if withdrawalsOffset <= uint32(len(buf)) {
+ wdBuf := buf[withdrawalsOffset:]
+ if len(wdBuf) > 0 {
+ // Each withdrawal = 44 bytes (index:8 + validator:8 + address:20 + amount:8)
+ if len(wdBuf)%44 != 0 {
+ return nil, fmt.Errorf("PayloadAttributes: withdrawals buffer length %d not divisible by 44", len(wdBuf))
+ }
+ count := len(wdBuf) / 44
+ pa.Withdrawals = make([]*types.Withdrawal, count)
+ for i := 0; i < count; i++ {
+ off := i * 44
+ w := &types.Withdrawal{
+ Index: binary.LittleEndian.Uint64(wdBuf[off : off+8]),
+ Validator: binary.LittleEndian.Uint64(wdBuf[off+8 : off+16]),
+ Amount: binary.LittleEndian.Uint64(wdBuf[off+36 : off+44]),
+ }
+ copy(w.Address[:], wdBuf[off+16:off+36])
+ pa.Withdrawals[i] = w
+ }
+ } else {
+ pa.Withdrawals = []*types.Withdrawal{}
+ }
+ }
+
+ return pa, nil
+}
+
+// --- getPayload handlers ---
+
+func (s *SszRestServer) handleGetPayloadV1(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayload(w, r, 1)
+}
+
+func (s *SszRestServer) handleGetPayloadV2(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayload(w, r, 2)
+}
+
+func (s *SszRestServer) handleGetPayloadV3(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayload(w, r, 3)
+}
+
+func (s *SszRestServer) handleGetPayloadV4(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayload(w, r, 4)
+}
+
+func (s *SszRestServer) handleGetPayloadV5(w http.ResponseWriter, r *http.Request) {
+ s.handleGetPayload(w, r, 5)
+}
+
+func (s *SszRestServer) handleGetPayload(w http.ResponseWriter, r *http.Request, version int) {
+ s.logger.Info("[SSZ-REST] Received GetPayload", "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
+ }
+
+ // Payload ID is 8 bytes. The Engine API internally uses big-endian payload IDs
+ // (see ConvertPayloadId), so we pass the raw bytes directly.
+ payloadIdBytes := make(hexutil.Bytes, 8)
+ copy(payloadIdBytes, body)
+
+ ctx := r.Context()
+
+ switch version {
+ case 1:
+ result, err := s.engine.GetPayloadV1(ctx, payloadIdBytes)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ resp := &engine_types.GetPayloadResponse{ExecutionPayload: result}
+ sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(resp, 1))
+ case 2, 3, 4, 5:
+ var result *engine_types.GetPayloadResponse
+ // For SSZ encoding, v5 (Fulu) uses same payload format as v4 (Electra/Deneb).
+ 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:
+ // Fulu uses same payload layout as Electra (Deneb format for SSZ encoding).
+ result, err = s.engine.GetPayloadV5(ctx, payloadIdBytes)
+ encodeVersion = 4
+ }
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(result, encodeVersion))
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported getPayload version: %d", version))
+ }
+}
+
+// --- 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()
+ result, err := s.engine.GetBlobsV1(ctx, hashes)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ // Encode blobs response: count(4) + for each blob: has_blob(1) + blob(131072) + proof(48)
+ respBuf := encodeGetBlobsV1Response(result)
+ sszResponse(w, respBuf)
+}
+
+// encodeGetBlobsV1Response encodes the GetBlobsV1 response as an SSZ Container.
+// Layout: list_offset(4) + N * BlobAndProof (each 131120 bytes = blob:131072 + proof:48)
+// Only non-nil blobs are included in the list.
+func encodeGetBlobsV1Response(blobs []*engine_types.BlobAndProofV1) []byte {
+ const blobAndProofSize = 131072 + 48 // blob + KZG proof
+
+ // Count non-nil blobs
+ var count int
+ for _, b := range blobs {
+ if b != nil {
+ count++
+ }
+ }
+
+ // SSZ Container with a single List field
+ fixedSize := 4 // list_offset
+ listSize := count * blobAndProofSize
+ buf := make([]byte, fixedSize+listSize)
+
+ // Offset to the list data
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize))
+
+ // Write each non-nil BlobAndProof as fixed-size items
+ pos := fixedSize
+ for _, b := range blobs {
+ if b == nil {
+ continue
+ }
+ // Blob (131072 bytes, zero-padded if shorter)
+ copy(buf[pos:pos+131072], b.Blob)
+ pos += 131072
+ // Proof (48 bytes, zero-padded if shorter)
+ copy(buf[pos:pos+48], b.Proof)
+ pos += 48
+ }
+
+ return buf
+}
+
+// --- 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:
+ sszErrorResponse(w, http.StatusInternalServerError, -32603, err.Error())
+ }
+}
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..5aaa518a95e
--- /dev/null
+++ b/execution/engineapi/engine_ssz_rest_server_test.go
@@ -0,0 +1,502 @@
+// 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/direct"
+ "github.com/erigontech/erigon/node/ethconfig"
+)
+
+// 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 an SSZ-REST server for testing.
+type sszRestTestSetup struct {
+ engineServer *EngineServer
+ sszServer *SszRestServer
+ 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))
+
+ executionRpc := direct.NewExecutionClientDirect(mockSentry.ExecModule)
+ maxReorgDepth := ethconfig.Defaults.MaxReorgDepth
+ engineServer := NewEngineServer(mockSentry.Log, mockSentry.ChainConfig, executionRpc, nil, false, false, true, nil, ethconfig.Defaults.FcuTimeout, maxReorgDepth)
+
+ port := getFreePort(t)
+ engineServer.httpConfig = &httpcfg.HttpCfg{
+ AuthRpcHTTPListenAddress: "127.0.0.1",
+ AuthRpcPort: 8551,
+ SszRestEnabled: true,
+ SszRestPort: port,
+ }
+ engineServer.sszRestPort = port
+
+ jwtSecret := make([]byte, 32)
+ rand.Read(jwtSecret)
+
+ sszServer := NewSszRestServer(engineServer, log.New(), jwtSecret, "127.0.0.1", port)
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ go func() {
+ sszServer.Start(ctx) //nolint:errcheck
+ }()
+
+ // Wait for server to start
+ baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
+ waitForServer(t, baseURL, jwtSecret)
+
+ return &sszRestTestSetup{
+ engineServer: engineServer,
+ sszServer: sszServer,
+ 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)
+ resp.Body.Close()
+
+ 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)
+ 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)
+ 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)
+ 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)
+ // 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)
+ // 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})
+ 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)
+ // 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))
+ 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))
+ 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)
+ 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))
+ 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)
+
+ // 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 a PayloadStatusSSZ 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.DecodePayloadStatusSSZ(respBody)
+ req.NoError(err)
+ req.True(ps.Status <= engine_types.SSZStatusInvalidBlockHash)
+ } 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)
+
+ // 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)
+
+ 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/ssz.go b/execution/engineapi/engine_types/ssz.go
new file mode 100644
index 00000000000..eaec816a78c
--- /dev/null
+++ b/execution/engineapi/engine_types/ssz.go
@@ -0,0 +1,1479 @@
+// 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 (
+ "encoding/binary"
+ "fmt"
+ "math/big"
+
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/hexutil"
+ "github.com/erigontech/erigon/execution/types"
+)
+
+// SSZ status codes for PayloadStatusSSZ (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
+ }
+}
+
+// PayloadStatusSSZ is the SSZ-encoded version of PayloadStatus for EIP-8161.
+//
+// SSZ layout (fixed part = 9 bytes):
+// - status: 1 byte (uint8)
+// - latest_valid_hash_offset: 4 bytes (offset to Union[None, Hash32])
+// - validation_error_offset: 4 bytes (offset to List[uint8, 1024])
+//
+// SSZ variable part:
+// - Union[None, Hash32]: selector(1) + hash(32) if selector==1; selector(1) if selector==0
+// - validation_error: List[uint8, 1024] — UTF-8 bytes
+type PayloadStatusSSZ struct {
+ Status uint8
+ LatestValidHash *common.Hash
+ ValidationError string
+}
+
+const payloadStatusFixedSize = 9 // status(1) + hash_offset(4) + err_offset(4)
+
+// EncodeSSZ encodes the PayloadStatusSSZ to SSZ bytes per EIP-8161.
+func (p *PayloadStatusSSZ) EncodeSSZ() []byte {
+ // Build Union[None, Hash32] variable data
+ var hashUnion []byte
+ if p.LatestValidHash != nil {
+ hashUnion = make([]byte, 33) // selector(1) + hash(32)
+ hashUnion[0] = 1
+ copy(hashUnion[1:33], p.LatestValidHash[:])
+ } else {
+ hashUnion = []byte{0} // selector(0) = None
+ }
+
+ errorBytes := []byte(p.ValidationError)
+
+ buf := make([]byte, payloadStatusFixedSize+len(hashUnion)+len(errorBytes))
+
+ buf[0] = p.Status
+
+ // Offset to Union[None, Hash32] (starts after fixed part)
+ binary.LittleEndian.PutUint32(buf[1:5], uint32(payloadStatusFixedSize))
+ // Offset to validation_error
+ binary.LittleEndian.PutUint32(buf[5:9], uint32(payloadStatusFixedSize+len(hashUnion)))
+
+ copy(buf[payloadStatusFixedSize:], hashUnion)
+ copy(buf[payloadStatusFixedSize+len(hashUnion):], errorBytes)
+ return buf
+}
+
+// DecodePayloadStatusSSZ decodes SSZ bytes into a PayloadStatusSSZ.
+func DecodePayloadStatusSSZ(buf []byte) (*PayloadStatusSSZ, error) {
+ if len(buf) < payloadStatusFixedSize {
+ return nil, fmt.Errorf("PayloadStatusSSZ: buffer too short (%d < %d)", len(buf), payloadStatusFixedSize)
+ }
+
+ p := &PayloadStatusSSZ{
+ Status: buf[0],
+ }
+
+ hashOffset := binary.LittleEndian.Uint32(buf[1:5])
+ errOffset := binary.LittleEndian.Uint32(buf[5:9])
+
+ if hashOffset > uint32(len(buf)) || errOffset > uint32(len(buf)) || hashOffset > errOffset {
+ return nil, fmt.Errorf("PayloadStatusSSZ: offsets out of bounds")
+ }
+
+ // Decode Union[None, Hash32]
+ unionData := buf[hashOffset:errOffset]
+ if len(unionData) > 0 {
+ selector := unionData[0]
+ if selector == 1 {
+ if len(unionData) < 33 {
+ return nil, fmt.Errorf("PayloadStatusSSZ: Union hash data too short")
+ }
+ hash := common.BytesToHash(unionData[1:33])
+ p.LatestValidHash = &hash
+ }
+ // selector == 0 means None, LatestValidHash stays nil
+ }
+
+ // Decode validation_error
+ if errOffset < uint32(len(buf)) {
+ errLen := uint32(len(buf)) - errOffset
+ if errLen > 1024 {
+ return nil, fmt.Errorf("PayloadStatusSSZ: validation error too long (%d > 1024)", errLen)
+ }
+ p.ValidationError = string(buf[errOffset:])
+ }
+
+ return p, nil
+}
+
+// ToPayloadStatus converts SSZ format to the standard JSON-RPC PayloadStatus.
+func (p *PayloadStatusSSZ) ToPayloadStatus() *PayloadStatus {
+ ps := &PayloadStatus{
+ Status: SSZToEngineStatus(p.Status),
+ LatestValidHash: p.LatestValidHash,
+ }
+ if p.ValidationError != "" {
+ ps.ValidationError = NewStringifiedErrorFromString(p.ValidationError)
+ }
+ return ps
+}
+
+// PayloadStatusToSSZ converts a JSON-RPC PayloadStatus to the SSZ format.
+func PayloadStatusToSSZ(ps *PayloadStatus) *PayloadStatusSSZ {
+ s := &PayloadStatusSSZ{
+ Status: EngineStatusToSSZ(ps.Status),
+ LatestValidHash: ps.LatestValidHash,
+ }
+ if ps.ValidationError != nil && ps.ValidationError.Error() != nil {
+ s.ValidationError = ps.ValidationError.Error().Error()
+ }
+ return s
+}
+
+// ForkchoiceStateSSZ is the SSZ encoding of ForkchoiceState.
+// Fixed layout: head_block_hash(32) + safe_block_hash(32) + finalized_block_hash(32) = 96 bytes
+type ForkchoiceStateSSZ struct {
+ HeadBlockHash common.Hash
+ SafeBlockHash common.Hash
+ FinalizedBlockHash common.Hash
+}
+
+func EncodeForkchoiceState(fcs *ForkChoiceState) []byte {
+ buf := make([]byte, 96)
+ copy(buf[0:32], fcs.HeadHash[:])
+ copy(buf[32:64], fcs.SafeBlockHash[:])
+ copy(buf[64:96], fcs.FinalizedBlockHash[:])
+ return buf
+}
+
+func DecodeForkchoiceState(buf []byte) (*ForkChoiceState, error) {
+ if len(buf) < 96 {
+ return nil, fmt.Errorf("ForkchoiceState: buffer too short (%d < 96)", len(buf))
+ }
+ fcs := &ForkChoiceState{}
+ copy(fcs.HeadHash[:], buf[0:32])
+ copy(fcs.SafeBlockHash[:], buf[32:64])
+ copy(fcs.FinalizedBlockHash[:], buf[64:96])
+ return fcs, nil
+}
+
+// ForkchoiceUpdatedResponseSSZ is the SSZ-encoded forkchoice updated response.
+//
+// SSZ layout (fixed part = 8 bytes):
+// - payload_status_offset: 4 bytes (uint32 LE, points to variable PayloadStatusSSZ data)
+// - payload_id_offset: 4 bytes (uint32 LE, points to Union[None, uint64])
+//
+// Variable part:
+// - PayloadStatusSSZ data (variable length due to validation_error)
+// - Union[None, uint64]: selector(1) + uint64(8) if selector==1; selector(1) if selector==0
+type ForkchoiceUpdatedResponseSSZ struct {
+ PayloadStatus *PayloadStatusSSZ
+ PayloadId *uint64
+}
+
+const forkchoiceUpdatedResponseFixedSize = 8 // 4 + 4
+
+func EncodeForkchoiceUpdatedResponse(resp *ForkChoiceUpdatedResponse) []byte {
+ ps := PayloadStatusToSSZ(resp.PayloadStatus)
+ psBytes := ps.EncodeSSZ()
+
+ // Build Union[None, uint64] for payload ID
+ var pidUnion []byte
+ if resp.PayloadId != nil {
+ pidUnion = make([]byte, 9) // selector(1) + uint64(8)
+ pidUnion[0] = 1
+ payloadIdBytes := []byte(*resp.PayloadId)
+ if len(payloadIdBytes) == 8 {
+ copy(pidUnion[1:9], payloadIdBytes)
+ }
+ } else {
+ pidUnion = []byte{0} // selector(0) = None
+ }
+
+ buf := make([]byte, forkchoiceUpdatedResponseFixedSize+len(psBytes)+len(pidUnion))
+
+ // Offset to PayloadStatus variable data (starts after fixed part)
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(forkchoiceUpdatedResponseFixedSize))
+ // Offset to Union[None, uint64] (after PayloadStatus data)
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(forkchoiceUpdatedResponseFixedSize+len(psBytes)))
+
+ // Variable part
+ copy(buf[forkchoiceUpdatedResponseFixedSize:], psBytes)
+ copy(buf[forkchoiceUpdatedResponseFixedSize+len(psBytes):], pidUnion)
+
+ return buf
+}
+
+func DecodeForkchoiceUpdatedResponse(buf []byte) (*ForkchoiceUpdatedResponseSSZ, error) {
+ if len(buf) < forkchoiceUpdatedResponseFixedSize {
+ return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: buffer too short (%d < %d)", len(buf), forkchoiceUpdatedResponseFixedSize)
+ }
+
+ psOffset := binary.LittleEndian.Uint32(buf[0:4])
+ pidOffset := binary.LittleEndian.Uint32(buf[4:8])
+
+ if psOffset > uint32(len(buf)) || pidOffset > uint32(len(buf)) || psOffset > pidOffset {
+ return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: offsets out of bounds")
+ }
+
+ resp := &ForkchoiceUpdatedResponseSSZ{}
+
+ // Decode PayloadStatus from psOffset to pidOffset
+ ps, err := DecodePayloadStatusSSZ(buf[psOffset:pidOffset])
+ if err != nil {
+ return nil, err
+ }
+ resp.PayloadStatus = ps
+
+ // Decode Union[None, uint64] from pidOffset to end
+ pidData := buf[pidOffset:]
+ if len(pidData) > 0 {
+ selector := pidData[0]
+ if selector == 1 {
+ if len(pidData) < 9 {
+ return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: Union payload_id data too short")
+ }
+ pid := binary.BigEndian.Uint64(pidData[1:9])
+ resp.PayloadId = &pid
+ }
+ // selector == 0 means None
+ }
+
+ return resp, nil
+}
+
+// ExchangeCapabilitiesSSZ encodes/decodes a list of capability strings for SSZ transport.
+func EncodeCapabilities(capabilities []string) []byte {
+ // count(4) + for each: len(4) + bytes
+ var totalSize int
+ for _, cap := range capabilities {
+ totalSize += 4 + len(cap)
+ }
+
+ buf := make([]byte, 4+totalSize)
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(len(capabilities)))
+
+ offset := 4
+ for _, cap := range capabilities {
+ capBytes := []byte(cap)
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(capBytes)))
+ offset += 4
+ copy(buf[offset:], capBytes)
+ offset += len(capBytes)
+ }
+
+ return buf
+}
+
+func DecodeCapabilities(buf []byte) ([]string, error) {
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("Capabilities: buffer too short")
+ }
+
+ count := binary.LittleEndian.Uint32(buf[0:4])
+ if count > 128 {
+ return nil, fmt.Errorf("Capabilities: too many capabilities (%d > 128)", count)
+ }
+
+ capabilities := make([]string, 0, count)
+ offset := uint32(4)
+
+ for i := uint32(0); i < count; i++ {
+ if offset+4 > uint32(len(buf)) {
+ return nil, fmt.Errorf("Capabilities: unexpected end of buffer")
+ }
+ capLen := binary.LittleEndian.Uint32(buf[offset : offset+4])
+ offset += 4
+ if capLen > 64 || offset+capLen > uint32(len(buf)) {
+ return nil, fmt.Errorf("Capabilities: capability too long or truncated")
+ }
+ capabilities = append(capabilities, string(buf[offset:offset+capLen]))
+ offset += capLen
+ }
+
+ return capabilities, nil
+}
+
+// ClientVersionSSZ encodes/decodes a ClientVersionV1 for SSZ transport.
+func EncodeClientVersion(cv *ClientVersionV1) []byte {
+ codeBytes := []byte(cv.Code)
+ nameBytes := []byte(cv.Name)
+ versionBytes := []byte(cv.Version)
+ commitBytes := []byte(cv.Commit)
+
+ // code_len(4) + code + name_len(4) + name + version_len(4) + version + commit_len(4) + commit
+ totalLen := 4 + len(codeBytes) + 4 + len(nameBytes) + 4 + len(versionBytes) + 4 + len(commitBytes)
+ buf := make([]byte, totalLen)
+
+ offset := 0
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(codeBytes)))
+ offset += 4
+ copy(buf[offset:], codeBytes)
+ offset += len(codeBytes)
+
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(nameBytes)))
+ offset += 4
+ copy(buf[offset:], nameBytes)
+ offset += len(nameBytes)
+
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(versionBytes)))
+ offset += 4
+ copy(buf[offset:], versionBytes)
+ offset += len(versionBytes)
+
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(commitBytes)))
+ offset += 4
+ copy(buf[offset:], commitBytes)
+
+ return buf
+}
+
+func DecodeClientVersion(buf []byte) (*ClientVersionV1, error) {
+ if len(buf) < 16 { // minimum: 4 length fields
+ return nil, fmt.Errorf("ClientVersion: buffer too short")
+ }
+
+ cv := &ClientVersionV1{}
+ offset := uint32(0)
+
+ readString := func(maxLen uint32) (string, error) {
+ if offset+4 > uint32(len(buf)) {
+ return "", fmt.Errorf("ClientVersion: unexpected end of buffer")
+ }
+ strLen := binary.LittleEndian.Uint32(buf[offset : offset+4])
+ offset += 4
+ if strLen > maxLen || offset+strLen > uint32(len(buf)) {
+ return "", fmt.Errorf("ClientVersion: string too long or truncated")
+ }
+ s := string(buf[offset : offset+strLen])
+ offset += strLen
+ return s, nil
+ }
+
+ var err error
+ if cv.Code, err = readString(8); err != nil {
+ return nil, err
+ }
+ if cv.Name, err = readString(64); err != nil {
+ return nil, err
+ }
+ if cv.Version, err = readString(64); err != nil {
+ return nil, err
+ }
+ if cv.Commit, err = readString(64); err != nil {
+ return nil, err
+ }
+
+ return cv, nil
+}
+
+// EncodeClientVersions encodes a list of ClientVersionV1 for SSZ transport.
+func EncodeClientVersions(versions []ClientVersionV1) []byte {
+ var parts [][]byte
+ for i := range versions {
+ parts = append(parts, EncodeClientVersion(&versions[i]))
+ }
+
+ // count(4) + for each: len(4) + encoded
+ totalLen := 4
+ for _, p := range parts {
+ totalLen += 4 + len(p)
+ }
+
+ buf := make([]byte, totalLen)
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(len(versions)))
+
+ offset := 4
+ for _, p := range parts {
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(p)))
+ offset += 4
+ copy(buf[offset:], p)
+ offset += len(p)
+ }
+
+ return buf
+}
+
+// DecodeClientVersions decodes a list of ClientVersionV1 from SSZ bytes.
+func DecodeClientVersions(buf []byte) ([]ClientVersionV1, error) {
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("ClientVersions: buffer too short")
+ }
+
+ count := binary.LittleEndian.Uint32(buf[0:4])
+ if count > 16 {
+ return nil, fmt.Errorf("ClientVersions: too many versions (%d > 16)", count)
+ }
+
+ versions := make([]ClientVersionV1, 0, count)
+ offset := uint32(4)
+
+ for i := uint32(0); i < count; i++ {
+ if offset+4 > uint32(len(buf)) {
+ return nil, fmt.Errorf("ClientVersions: unexpected end of buffer")
+ }
+ cvLen := binary.LittleEndian.Uint32(buf[offset : offset+4])
+ offset += 4
+ if offset+cvLen > uint32(len(buf)) {
+ return nil, fmt.Errorf("ClientVersions: truncated")
+ }
+ cv, err := DecodeClientVersion(buf[offset : offset+cvLen])
+ if err != nil {
+ return nil, err
+ }
+ versions = append(versions, *cv)
+ offset += cvLen
+ }
+
+ return versions, nil
+}
+
+// engineVersionToPayloadVersion maps Engine API versions to ExecutionPayload SSZ versions.
+// Engine V4 = Electra, which reuses the Deneb payload layout (version 3).
+// Engine V5 = Gloas, which adds slot_number + block_access_list (version 4).
+func engineVersionToPayloadVersion(engineVersion int) int {
+ if engineVersion == 4 {
+ return 3 // Electra uses Deneb payload layout
+ }
+ if engineVersion >= 5 {
+ return 4 // Gloas and beyond use the extended layout
+ }
+ return engineVersion
+}
+
+// --- ExecutionPayload SSZ encoding/decoding ---
+//
+// The ExecutionPayload SSZ encoding follows the Ethereum consensus specs SSZ container layout.
+// Fields are version-dependent:
+// - V1 (Bellatrix): base fields
+// - V2 (Capella): + withdrawals
+// - V3 (Deneb): + blob_gas_used, excess_blob_gas
+// - V4 (Gloas): + slot_number, block_access_list
+//
+// The SSZ container has a fixed part (with offsets for variable-length fields)
+// followed by a variable part containing the actual variable-length data.
+
+// executionPayloadFixedSize returns the fixed part size for a given version.
+func executionPayloadFixedSize(version int) int {
+ // Base (V1/Bellatrix): parent_hash(32) + fee_recipient(20) + state_root(32) +
+ // receipts_root(32) + logs_bloom(256) + prev_randao(32) + block_number(8) +
+ // gas_limit(8) + gas_used(8) + timestamp(8) + extra_data_offset(4) +
+ // base_fee_per_gas(32) + block_hash(32) + transactions_offset(4) = 508
+ size := 508
+ if version >= 2 {
+ size += 4 // withdrawals_offset
+ }
+ if version >= 3 {
+ size += 8 + 8 // blob_gas_used + excess_blob_gas
+ }
+ if version >= 4 {
+ size += 8 + 4 // slot_number + block_access_list_offset
+ }
+ return size
+}
+
+// uint256ToSSZBytes converts a big.Int to 32-byte little-endian SSZ representation.
+func uint256ToSSZBytes(val *big.Int) []byte {
+ buf := make([]byte, 32)
+ if val == nil {
+ return buf
+ }
+ b := val.Bytes() // big-endian, minimal
+ // Copy into buf in reverse (little-endian)
+ 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 {
+ // Convert from little-endian to big-endian
+ be := make([]byte, 32)
+ for i := 0; i < 32; i++ {
+ be[31-i] = buf[i]
+ }
+ return new(big.Int).SetBytes(be)
+}
+
+// encodeTransactionsSSZ encodes a list of transactions as an SSZ list of variable-length items.
+// Layout: N offsets (4 bytes each) followed by transaction data.
+func encodeTransactionsSSZ(txs []hexutil.Bytes) []byte {
+ if len(txs) == 0 {
+ return nil
+ }
+ // Calculate total size
+ offsetsSize := len(txs) * 4
+ dataSize := 0
+ for _, tx := range txs {
+ dataSize += len(tx)
+ }
+ buf := make([]byte, offsetsSize+dataSize)
+
+ // Write offsets (relative to start of this list data)
+ dataStart := offsetsSize
+ for i, tx := range txs {
+ binary.LittleEndian.PutUint32(buf[i*4:(i+1)*4], uint32(dataStart))
+ dataStart += len(tx)
+ }
+ // Write transaction data
+ pos := offsetsSize
+ for _, tx := range txs {
+ copy(buf[pos:], tx)
+ pos += len(tx)
+ }
+ return buf
+}
+
+// decodeTransactionsSSZ decodes an SSZ-encoded list of variable-length transactions.
+func decodeTransactionsSSZ(buf []byte) ([]hexutil.Bytes, error) {
+ if len(buf) == 0 {
+ return nil, nil
+ }
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("transactions SSZ: buffer too short")
+ }
+ // The first offset tells us how many offsets there are
+ firstOffset := binary.LittleEndian.Uint32(buf[0:4])
+ if firstOffset%4 != 0 {
+ return nil, fmt.Errorf("transactions SSZ: first offset not aligned (%d)", firstOffset)
+ }
+ count := firstOffset / 4
+ if count == 0 {
+ return nil, nil
+ }
+ if firstOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("transactions SSZ: first offset out of bounds")
+ }
+
+ // Read all offsets
+ offsets := make([]uint32, count)
+ for i := uint32(0); i < count; i++ {
+ offsets[i] = binary.LittleEndian.Uint32(buf[i*4 : (i+1)*4])
+ }
+
+ txs := make([]hexutil.Bytes, count)
+ for i := uint32(0); i < count; i++ {
+ start := offsets[i]
+ var end uint32
+ if i+1 < count {
+ end = offsets[i+1]
+ } else {
+ end = uint32(len(buf))
+ }
+ if start > uint32(len(buf)) || end > uint32(len(buf)) || start > end {
+ return nil, fmt.Errorf("transactions SSZ: invalid offset at index %d", i)
+ }
+ tx := make(hexutil.Bytes, end-start)
+ copy(tx, buf[start:end])
+ txs[i] = tx
+ }
+ return txs, nil
+}
+
+// SSZ Withdrawal layout: index(8) + validator_index(8) + address(20) + amount(8) = 44 bytes
+const withdrawalSSZSize = 44
+
+func encodeWithdrawalsSSZ(withdrawals []*types.Withdrawal) []byte {
+ if withdrawals == nil {
+ return nil
+ }
+ buf := make([]byte, len(withdrawals)*withdrawalSSZSize)
+ for i, w := range withdrawals {
+ off := i * withdrawalSSZSize
+ binary.LittleEndian.PutUint64(buf[off:off+8], w.Index)
+ binary.LittleEndian.PutUint64(buf[off+8:off+16], w.Validator)
+ copy(buf[off+16:off+36], w.Address[:])
+ binary.LittleEndian.PutUint64(buf[off+36:off+44], w.Amount)
+ }
+ return buf
+}
+
+func decodeWithdrawalsSSZ(buf []byte) ([]*types.Withdrawal, error) {
+ if len(buf) == 0 {
+ return []*types.Withdrawal{}, nil
+ }
+ if len(buf)%withdrawalSSZSize != 0 {
+ return nil, fmt.Errorf("withdrawals SSZ: buffer length %d not divisible by %d", len(buf), withdrawalSSZSize)
+ }
+ count := len(buf) / withdrawalSSZSize
+ withdrawals := make([]*types.Withdrawal, count)
+ for i := 0; i < count; i++ {
+ off := i * withdrawalSSZSize
+ withdrawals[i] = &types.Withdrawal{
+ Index: binary.LittleEndian.Uint64(buf[off : off+8]),
+ Validator: binary.LittleEndian.Uint64(buf[off+8 : off+16]),
+ Amount: binary.LittleEndian.Uint64(buf[off+36 : off+44]),
+ }
+ copy(withdrawals[i].Address[:], buf[off+16:off+36])
+ }
+ return withdrawals, nil
+}
+
+// EncodeExecutionPayloadSSZ encodes an ExecutionPayload to SSZ bytes.
+// The version parameter determines which fields are included:
+//
+// 1=Bellatrix, 2=Capella, 3=Deneb, 4=Gloas
+func EncodeExecutionPayloadSSZ(ep *ExecutionPayload, version int) []byte {
+ fixedSize := executionPayloadFixedSize(version)
+
+ // Prepare variable-length field data
+ extraData := []byte(ep.ExtraData)
+ txData := encodeTransactionsSSZ(ep.Transactions)
+ var withdrawalData []byte
+ if version >= 2 {
+ withdrawalData = encodeWithdrawalsSSZ(ep.Withdrawals)
+ }
+ var blockAccessListData []byte
+ if version >= 4 {
+ blockAccessListData = []byte(ep.BlockAccessList)
+ }
+
+ totalVarSize := len(extraData) + len(txData)
+ if version >= 2 {
+ totalVarSize += len(withdrawalData)
+ }
+ if version >= 4 {
+ totalVarSize += len(blockAccessListData)
+ }
+
+ buf := make([]byte, fixedSize+totalVarSize)
+ pos := 0
+
+ // Fixed fields
+ copy(buf[pos:pos+32], ep.ParentHash[:])
+ pos += 32
+ copy(buf[pos:pos+20], ep.FeeRecipient[:])
+ pos += 20
+ copy(buf[pos:pos+32], ep.StateRoot[:])
+ pos += 32
+ copy(buf[pos:pos+32], ep.ReceiptsRoot[:])
+ pos += 32
+ // LogsBloom is always 256 bytes
+ if len(ep.LogsBloom) >= 256 {
+ copy(buf[pos:pos+256], ep.LogsBloom[:256])
+ }
+ pos += 256
+ copy(buf[pos:pos+32], ep.PrevRandao[:])
+ pos += 32
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.BlockNumber))
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.GasLimit))
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.GasUsed))
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.Timestamp))
+ pos += 8
+
+ // extra_data offset
+ extraDataOffset := fixedSize
+ binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(extraDataOffset))
+ pos += 4
+
+ // base_fee_per_gas (uint256, 32 bytes LE)
+ var baseFee *big.Int
+ if ep.BaseFeePerGas != nil {
+ baseFee = ep.BaseFeePerGas.ToInt()
+ }
+ copy(buf[pos:pos+32], uint256ToSSZBytes(baseFee))
+ pos += 32
+
+ copy(buf[pos:pos+32], ep.BlockHash[:])
+ pos += 32
+
+ // transactions offset
+ txOffset := extraDataOffset + len(extraData)
+ binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(txOffset))
+ pos += 4
+
+ if version >= 2 {
+ // withdrawals offset
+ wdOffset := txOffset + len(txData)
+ binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(wdOffset))
+ pos += 4
+ }
+
+ if version >= 3 {
+ var blobGasUsed, excessBlobGas uint64
+ if ep.BlobGasUsed != nil {
+ blobGasUsed = uint64(*ep.BlobGasUsed)
+ }
+ if ep.ExcessBlobGas != nil {
+ excessBlobGas = uint64(*ep.ExcessBlobGas)
+ }
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], blobGasUsed)
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], excessBlobGas)
+ pos += 8
+ }
+
+ if version >= 4 {
+ var slotNumber uint64
+ if ep.SlotNumber != nil {
+ slotNumber = uint64(*ep.SlotNumber)
+ }
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], slotNumber)
+ pos += 8
+
+ // block_access_list offset
+ balOffset := extraDataOffset + len(extraData) + len(txData)
+ if version >= 2 {
+ balOffset += len(withdrawalData)
+ }
+ binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(balOffset))
+ pos += 4
+ }
+
+ // Variable part
+ copy(buf[extraDataOffset:], extraData)
+ copy(buf[txOffset:], txData)
+ if version >= 2 {
+ wdOffset := txOffset + len(txData)
+ copy(buf[wdOffset:], withdrawalData)
+ }
+ if version >= 4 {
+ balOffset := extraDataOffset + len(extraData) + len(txData)
+ if version >= 2 {
+ balOffset += len(withdrawalData)
+ }
+ copy(buf[balOffset:], blockAccessListData)
+ }
+
+ return buf
+}
+
+// DecodeExecutionPayloadSSZ decodes SSZ bytes into an ExecutionPayload.
+func DecodeExecutionPayloadSSZ(buf []byte, version int) (*ExecutionPayload, error) {
+ fixedSize := executionPayloadFixedSize(version)
+ if len(buf) < fixedSize {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: buffer too short (%d < %d)", len(buf), fixedSize)
+ }
+
+ ep := &ExecutionPayload{}
+ pos := 0
+
+ copy(ep.ParentHash[:], buf[pos:pos+32])
+ pos += 32
+ copy(ep.FeeRecipient[:], buf[pos:pos+20])
+ pos += 20
+ copy(ep.StateRoot[:], buf[pos:pos+32])
+ pos += 32
+ copy(ep.ReceiptsRoot[:], buf[pos:pos+32])
+ pos += 32
+ ep.LogsBloom = make(hexutil.Bytes, 256)
+ copy(ep.LogsBloom, buf[pos:pos+256])
+ pos += 256
+ copy(ep.PrevRandao[:], buf[pos:pos+32])
+ pos += 32
+ ep.BlockNumber = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ pos += 8
+ ep.GasLimit = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ pos += 8
+ ep.GasUsed = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ pos += 8
+ ep.Timestamp = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ pos += 8
+
+ extraDataOffset := binary.LittleEndian.Uint32(buf[pos : pos+4])
+ pos += 4
+
+ baseFee := sszBytesToUint256(buf[pos : pos+32])
+ ep.BaseFeePerGas = (*hexutil.Big)(baseFee)
+ pos += 32
+
+ copy(ep.BlockHash[:], buf[pos:pos+32])
+ pos += 32
+
+ txOffset := binary.LittleEndian.Uint32(buf[pos : pos+4])
+ pos += 4
+
+ var wdOffset uint32
+ if version >= 2 {
+ wdOffset = binary.LittleEndian.Uint32(buf[pos : pos+4])
+ pos += 4
+ }
+
+ if version >= 3 {
+ blobGasUsed := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ ep.BlobGasUsed = &blobGasUsed
+ pos += 8
+ excessBlobGas := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ ep.ExcessBlobGas = &excessBlobGas
+ pos += 8
+ }
+
+ var balOffset uint32
+ if version >= 4 {
+ slotNumber := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8]))
+ ep.SlotNumber = &slotNumber
+ pos += 8
+ balOffset = binary.LittleEndian.Uint32(buf[pos : pos+4])
+ pos += 4
+ }
+
+ // Decode variable-length fields using offsets
+ // extra_data: from extraDataOffset to txOffset
+ if extraDataOffset > uint32(len(buf)) || txOffset > uint32(len(buf)) || extraDataOffset > txOffset {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: invalid extra_data/transactions offsets")
+ }
+ ep.ExtraData = make(hexutil.Bytes, txOffset-extraDataOffset)
+ copy(ep.ExtraData, buf[extraDataOffset:txOffset])
+
+ // Determine end of transactions
+ var txEnd uint32
+ if version >= 2 {
+ txEnd = wdOffset
+ } else {
+ txEnd = uint32(len(buf))
+ }
+ if txOffset > txEnd {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: transactions offset > end")
+ }
+
+ // Decode transactions
+ txBuf := buf[txOffset:txEnd]
+ txs, err := decodeTransactionsSSZ(txBuf)
+ if err != nil {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: %w", err)
+ }
+ ep.Transactions = txs
+ if ep.Transactions == nil {
+ ep.Transactions = []hexutil.Bytes{}
+ }
+
+ // Decode withdrawals
+ if version >= 2 {
+ var wdEnd uint32
+ if version >= 4 {
+ wdEnd = balOffset
+ } else {
+ wdEnd = uint32(len(buf))
+ }
+ if wdOffset > wdEnd || wdEnd > uint32(len(buf)) {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: invalid withdrawals offset")
+ }
+ wds, err := decodeWithdrawalsSSZ(buf[wdOffset:wdEnd])
+ if err != nil {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: %w", err)
+ }
+ ep.Withdrawals = wds
+ }
+
+ // Decode block access list
+ if version >= 4 {
+ if balOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("ExecutionPayload SSZ: block_access_list offset out of bounds")
+ }
+ ep.BlockAccessList = make(hexutil.Bytes, uint32(len(buf))-balOffset)
+ copy(ep.BlockAccessList, buf[balOffset:])
+ }
+
+ return ep, nil
+}
+
+// --- NewPayload request SSZ encoding/decoding ---
+//
+// V1/V2: The request body is just the SSZ-encoded ExecutionPayload.
+//
+// V3 NewPayloadRequest SSZ container:
+// Fixed part: ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) = 40 bytes
+// Variable: ExecutionPayload data, blob hashes (N * 32 bytes)
+//
+// V4 NewPayloadRequest SSZ container:
+// Fixed part: ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) + requests_offset(4) = 44 bytes
+// Variable: ExecutionPayload data, blob hashes, execution requests
+
+// EncodeNewPayloadRequestSSZ encodes a newPayload request to SSZ.
+func EncodeNewPayloadRequestSSZ(
+ ep *ExecutionPayload,
+ blobHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests []hexutil.Bytes,
+ version int,
+) []byte {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if version <= 2 {
+ return EncodeExecutionPayloadSSZ(ep, payloadVersion)
+ }
+
+ epBytes := EncodeExecutionPayloadSSZ(ep, payloadVersion)
+ blobHashBytes := make([]byte, len(blobHashes)*32)
+ for i, h := range blobHashes {
+ copy(blobHashBytes[i*32:(i+1)*32], h[:])
+ }
+
+ if version == 3 {
+ fixedSize := 40 // ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32)
+ buf := make([]byte, fixedSize+len(epBytes)+len(blobHashBytes))
+
+ // ep offset
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize))
+ // blob hashes offset
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(fixedSize+len(epBytes)))
+ // parent beacon block root
+ if parentBeaconBlockRoot != nil {
+ copy(buf[8:40], parentBeaconBlockRoot[:])
+ }
+ // Variable
+ copy(buf[fixedSize:], epBytes)
+ copy(buf[fixedSize+len(epBytes):], blobHashBytes)
+ return buf
+ }
+
+ // V4+
+ // Encode execution requests as structured SSZ Container for Prysm compatibility
+ reqBytes := encodeStructuredExecutionRequestsSSZ(executionRequests)
+
+ fixedSize := 44 // ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) + requests_offset(4)
+ buf := make([]byte, fixedSize+len(epBytes)+len(blobHashBytes)+len(reqBytes))
+
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize))
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(fixedSize+len(epBytes)))
+ if parentBeaconBlockRoot != nil {
+ copy(buf[8:40], parentBeaconBlockRoot[:])
+ }
+ binary.LittleEndian.PutUint32(buf[40:44], uint32(fixedSize+len(epBytes)+len(blobHashBytes)))
+
+ copy(buf[fixedSize:], epBytes)
+ copy(buf[fixedSize+len(epBytes):], blobHashBytes)
+ copy(buf[fixedSize+len(epBytes)+len(blobHashBytes):], reqBytes)
+ return buf
+}
+
+// DecodeNewPayloadRequestSSZ decodes a newPayload request from SSZ.
+func DecodeNewPayloadRequestSSZ(buf []byte, version int) (
+ ep *ExecutionPayload,
+ blobHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests []hexutil.Bytes,
+ err error,
+) {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if version <= 2 {
+ ep, err = DecodeExecutionPayloadSSZ(buf, payloadVersion)
+ return
+ }
+
+ if version == 3 {
+ if len(buf) < 40 {
+ err = fmt.Errorf("NewPayloadV3 SSZ: buffer too short (%d < 40)", len(buf))
+ return
+ }
+ epOffset := binary.LittleEndian.Uint32(buf[0:4])
+ blobHashOffset := binary.LittleEndian.Uint32(buf[4:8])
+ root := common.BytesToHash(buf[8:40])
+ parentBeaconBlockRoot = &root
+
+ if epOffset > uint32(len(buf)) || blobHashOffset > uint32(len(buf)) || epOffset > blobHashOffset {
+ err = fmt.Errorf("NewPayloadV3 SSZ: invalid offsets")
+ return
+ }
+ ep, err = DecodeExecutionPayloadSSZ(buf[epOffset:blobHashOffset], payloadVersion)
+ if err != nil {
+ return
+ }
+ blobHashBuf := buf[blobHashOffset:]
+ if len(blobHashBuf)%32 != 0 {
+ err = fmt.Errorf("NewPayloadV3 SSZ: blob hashes not aligned")
+ return
+ }
+ blobHashes = make([]common.Hash, len(blobHashBuf)/32)
+ for i := range blobHashes {
+ copy(blobHashes[i][:], blobHashBuf[i*32:(i+1)*32])
+ }
+ return
+ }
+
+ // V4+
+ if len(buf) < 44 {
+ err = fmt.Errorf("NewPayloadV4 SSZ: buffer too short (%d < 44)", len(buf))
+ return
+ }
+ epOffset := binary.LittleEndian.Uint32(buf[0:4])
+ blobHashOffset := binary.LittleEndian.Uint32(buf[4:8])
+ root := common.BytesToHash(buf[8:40])
+ parentBeaconBlockRoot = &root
+ reqOffset := binary.LittleEndian.Uint32(buf[40:44])
+
+ if epOffset > uint32(len(buf)) || blobHashOffset > uint32(len(buf)) || reqOffset > uint32(len(buf)) {
+ err = fmt.Errorf("NewPayloadV4 SSZ: offsets out of bounds")
+ return
+ }
+ ep, err = DecodeExecutionPayloadSSZ(buf[epOffset:blobHashOffset], payloadVersion)
+ if err != nil {
+ return
+ }
+ blobHashBuf := buf[blobHashOffset:reqOffset]
+ if len(blobHashBuf)%32 != 0 {
+ err = fmt.Errorf("NewPayloadV4 SSZ: blob hashes not aligned")
+ return
+ }
+ blobHashes = make([]common.Hash, len(blobHashBuf)/32)
+ for i := range blobHashes {
+ copy(blobHashes[i][:], blobHashBuf[i*32:(i+1)*32])
+ }
+
+ executionRequests, err = decodeStructuredExecutionRequestsSSZ(buf[reqOffset:])
+ return
+}
+
+// encodeExecutionRequestsSSZ encodes execution requests as SSZ list of variable items.
+func encodeExecutionRequestsSSZ(reqs []hexutil.Bytes) []byte {
+ if len(reqs) == 0 {
+ return nil
+ }
+ offsetsSize := len(reqs) * 4
+ dataSize := 0
+ for _, r := range reqs {
+ dataSize += len(r)
+ }
+ buf := make([]byte, offsetsSize+dataSize)
+ dataStart := offsetsSize
+ for i, r := range reqs {
+ binary.LittleEndian.PutUint32(buf[i*4:(i+1)*4], uint32(dataStart))
+ dataStart += len(r)
+ }
+ pos := offsetsSize
+ for _, r := range reqs {
+ copy(buf[pos:], r)
+ pos += len(r)
+ }
+ return buf
+}
+
+func decodeExecutionRequestsSSZ(buf []byte) ([]hexutil.Bytes, error) {
+ if len(buf) == 0 {
+ return nil, nil
+ }
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("execution requests SSZ: buffer too short")
+ }
+ firstOffset := binary.LittleEndian.Uint32(buf[0:4])
+ if firstOffset%4 != 0 || firstOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("execution requests SSZ: invalid first offset")
+ }
+ count := firstOffset / 4
+ offsets := make([]uint32, count)
+ for i := uint32(0); i < count; i++ {
+ offsets[i] = binary.LittleEndian.Uint32(buf[i*4 : (i+1)*4])
+ }
+ reqs := make([]hexutil.Bytes, count)
+ for i := uint32(0); i < count; i++ {
+ start := offsets[i]
+ var end uint32
+ if i+1 < count {
+ end = offsets[i+1]
+ } else {
+ end = uint32(len(buf))
+ }
+ if start > uint32(len(buf)) || end > uint32(len(buf)) || start > end {
+ return nil, fmt.Errorf("execution requests SSZ: invalid offset at index %d", i)
+ }
+ r := make(hexutil.Bytes, end-start)
+ copy(r, buf[start:end])
+ reqs[i] = r
+ }
+ return reqs, nil
+}
+
+// encodeStructuredExecutionRequestsSSZ encodes execution requests as a structured SSZ Container
+// that Prysm can UnmarshalSSZ. The container has 3 offsets (deposits, withdrawals, consolidations)
+// followed by the raw SSZ data for each list.
+//
+// Container layout:
+//
+// Fixed: deposits_offset(4) + withdrawals_offset(4) + consolidations_offset(4) = 12 bytes
+// Variable: deposits_ssz + withdrawals_ssz + consolidations_ssz
+//
+// The input flat format is []hexutil.Bytes where each item is: type_byte + ssz_data
+func encodeStructuredExecutionRequestsSSZ(reqs []hexutil.Bytes) []byte {
+ var depositsData, withdrawalsData, consolidationsData []byte
+
+ for _, r := range reqs {
+ if len(r) < 1 {
+ continue
+ }
+ switch r[0] {
+ case 0x00: // deposits
+ depositsData = append(depositsData, r[1:]...)
+ case 0x01: // withdrawals
+ withdrawalsData = append(withdrawalsData, r[1:]...)
+ case 0x02: // consolidations
+ consolidationsData = append(consolidationsData, r[1:]...)
+ }
+ }
+
+ fixedSize := 12 // 3 offsets * 4 bytes
+ totalVar := len(depositsData) + len(withdrawalsData) + len(consolidationsData)
+ buf := make([]byte, fixedSize+totalVar)
+
+ depositsOffset := fixedSize
+ withdrawalsOffset := depositsOffset + len(depositsData)
+ consolidationsOffset := withdrawalsOffset + len(withdrawalsData)
+
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(depositsOffset))
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(withdrawalsOffset))
+ binary.LittleEndian.PutUint32(buf[8:12], uint32(consolidationsOffset))
+
+ copy(buf[depositsOffset:], depositsData)
+ copy(buf[withdrawalsOffset:], withdrawalsData)
+ copy(buf[consolidationsOffset:], consolidationsData)
+
+ return buf
+}
+
+// decodeStructuredExecutionRequestsSSZ decodes a structured SSZ Container of execution requests
+// into the flat format used by Erigon ([]hexutil.Bytes where each item is type_byte + ssz_data).
+func decodeStructuredExecutionRequestsSSZ(buf []byte) ([]hexutil.Bytes, error) {
+ if len(buf) == 0 {
+ return []hexutil.Bytes{}, nil
+ }
+ if len(buf) < 12 {
+ return nil, fmt.Errorf("structured execution requests SSZ: buffer too short (%d < 12)", len(buf))
+ }
+
+ depositsOffset := binary.LittleEndian.Uint32(buf[0:4])
+ withdrawalsOffset := binary.LittleEndian.Uint32(buf[4:8])
+ consolidationsOffset := binary.LittleEndian.Uint32(buf[8:12])
+
+ if depositsOffset > uint32(len(buf)) || withdrawalsOffset > uint32(len(buf)) || consolidationsOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("structured execution requests SSZ: offsets out of bounds")
+ }
+ if depositsOffset > withdrawalsOffset || withdrawalsOffset > consolidationsOffset {
+ return nil, fmt.Errorf("structured execution requests SSZ: offsets not in order")
+ }
+
+ // Always return non-nil slice (engine requires non-nil for V4+ even if empty).
+ reqs := make([]hexutil.Bytes, 0, 3)
+
+ // Deposits (type 0x00)
+ depositsData := buf[depositsOffset:withdrawalsOffset]
+ if len(depositsData) > 0 {
+ r := make(hexutil.Bytes, 1+len(depositsData))
+ r[0] = 0x00
+ copy(r[1:], depositsData)
+ reqs = append(reqs, r)
+ }
+
+ // Withdrawals (type 0x01)
+ withdrawalsData := buf[withdrawalsOffset:consolidationsOffset]
+ if len(withdrawalsData) > 0 {
+ r := make(hexutil.Bytes, 1+len(withdrawalsData))
+ r[0] = 0x01
+ copy(r[1:], withdrawalsData)
+ reqs = append(reqs, r)
+ }
+
+ // Consolidations (type 0x02)
+ consolidationsData := buf[consolidationsOffset:]
+ if len(consolidationsData) > 0 {
+ r := make(hexutil.Bytes, 1+len(consolidationsData))
+ r[0] = 0x02
+ copy(r[1:], consolidationsData)
+ reqs = append(reqs, r)
+ }
+
+ return reqs, nil
+}
+
+// --- GetPayload response SSZ encoding ---
+//
+// V1: The response body is just the SSZ-encoded ExecutionPayload.
+//
+// V2+ GetPayloadResponse SSZ container:
+// Fixed part: ep_offset(4) + block_value(32) + blobs_bundle_offset(4) +
+// should_override_builder(1) + requests_offset(4) = 45 bytes
+// Variable: ExecutionPayload, BlobsBundle, ExecutionRequests
+
+const getPayloadResponseFixedSize = 45
+
+// EncodeGetPayloadResponseSSZ encodes a GetPayloadResponse to SSZ.
+func EncodeGetPayloadResponseSSZ(resp *GetPayloadResponse, version int) []byte {
+ if version == 1 {
+ return EncodeExecutionPayloadSSZ(resp.ExecutionPayload, 1)
+ }
+
+ payloadVersion := engineVersionToPayloadVersion(version)
+ epBytes := EncodeExecutionPayloadSSZ(resp.ExecutionPayload, payloadVersion)
+ blobsBytes := encodeBlobsBundleSSZ(resp.BlobsBundle)
+ reqBytes := encodeStructuredExecutionRequestsSSZ(resp.ExecutionRequests)
+
+ buf := make([]byte, getPayloadResponseFixedSize+len(epBytes)+len(blobsBytes)+len(reqBytes))
+
+ // ep offset
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(getPayloadResponseFixedSize))
+
+ // block_value (uint256 LE)
+ if resp.BlockValue != nil {
+ copy(buf[4:36], uint256ToSSZBytes(resp.BlockValue.ToInt()))
+ }
+
+ // blobs_bundle offset
+ blobsOffset := getPayloadResponseFixedSize + len(epBytes)
+ binary.LittleEndian.PutUint32(buf[36:40], uint32(blobsOffset))
+
+ // should_override_builder
+ if resp.ShouldOverrideBuilder {
+ buf[40] = 1
+ }
+
+ // execution_requests offset
+ reqOffset := blobsOffset + len(blobsBytes)
+ binary.LittleEndian.PutUint32(buf[41:45], uint32(reqOffset))
+
+ // Variable data
+ copy(buf[getPayloadResponseFixedSize:], epBytes)
+ copy(buf[blobsOffset:], blobsBytes)
+ copy(buf[reqOffset:], reqBytes)
+
+ return buf
+}
+
+// DecodeGetPayloadResponseSSZ decodes SSZ bytes into a GetPayloadResponse.
+func DecodeGetPayloadResponseSSZ(buf []byte, version int) (*GetPayloadResponse, error) {
+ if version == 1 {
+ ep, err := DecodeExecutionPayloadSSZ(buf, 1)
+ if err != nil {
+ return nil, err
+ }
+ return &GetPayloadResponse{ExecutionPayload: ep}, nil
+ }
+
+ if len(buf) < getPayloadResponseFixedSize {
+ return nil, fmt.Errorf("GetPayloadResponse SSZ: buffer too short (%d < %d)", len(buf), getPayloadResponseFixedSize)
+ }
+
+ resp := &GetPayloadResponse{}
+
+ epOffset := binary.LittleEndian.Uint32(buf[0:4])
+ blockValue := sszBytesToUint256(buf[4:36])
+ resp.BlockValue = (*hexutil.Big)(blockValue)
+ blobsOffset := binary.LittleEndian.Uint32(buf[36:40])
+ resp.ShouldOverrideBuilder = buf[40] == 1
+ reqOffset := binary.LittleEndian.Uint32(buf[41:45])
+
+ // Decode ExecutionPayload
+ if epOffset > uint32(len(buf)) || blobsOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("GetPayloadResponse SSZ: offsets out of bounds")
+ }
+ payloadVersion := engineVersionToPayloadVersion(version)
+ ep, err := DecodeExecutionPayloadSSZ(buf[epOffset:blobsOffset], payloadVersion)
+ if err != nil {
+ return nil, err
+ }
+ resp.ExecutionPayload = ep
+
+ // Decode BlobsBundle
+ if blobsOffset > reqOffset || reqOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("GetPayloadResponse SSZ: invalid blobs/requests offsets")
+ }
+ bundle, err := decodeBlobsBundleSSZ(buf[blobsOffset:reqOffset])
+ if err != nil {
+ return nil, err
+ }
+ resp.BlobsBundle = bundle
+
+ // Decode ExecutionRequests
+ if reqOffset < uint32(len(buf)) {
+ reqs, err := decodeStructuredExecutionRequestsSSZ(buf[reqOffset:])
+ if err != nil {
+ return nil, err
+ }
+ resp.ExecutionRequests = reqs
+ }
+
+ return resp, nil
+}
+
+// --- BlobsBundle SSZ encoding ---
+//
+// SSZ container:
+// Fixed part: commitments_offset(4) + proofs_offset(4) + blobs_offset(4) = 12 bytes
+// Variable: commitments (N*48), proofs (N*48), blobs (N*131072)
+
+const blobsBundleFixedSize = 12
+
+func encodeBlobsBundleSSZ(bundle *BlobsBundle) []byte {
+ if bundle == nil {
+ return nil
+ }
+
+ commitmentsData := encodeFixedSizeList(bundle.Commitments)
+ proofsData := encodeFixedSizeList(bundle.Proofs)
+ blobsData := encodeFixedSizeList(bundle.Blobs)
+
+ totalVar := len(commitmentsData) + len(proofsData) + len(blobsData)
+ buf := make([]byte, blobsBundleFixedSize+totalVar)
+
+ commitmentsOffset := blobsBundleFixedSize
+ proofsOffset := commitmentsOffset + len(commitmentsData)
+ blobsOffset := proofsOffset + len(proofsData)
+
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(commitmentsOffset))
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(proofsOffset))
+ binary.LittleEndian.PutUint32(buf[8:12], uint32(blobsOffset))
+
+ copy(buf[commitmentsOffset:], commitmentsData)
+ copy(buf[proofsOffset:], proofsData)
+ copy(buf[blobsOffset:], blobsData)
+
+ return buf
+}
+
+func decodeBlobsBundleSSZ(buf []byte) (*BlobsBundle, error) {
+ if len(buf) == 0 {
+ return nil, nil
+ }
+ if len(buf) < blobsBundleFixedSize {
+ return nil, fmt.Errorf("BlobsBundle SSZ: buffer too short")
+ }
+
+ commitmentsOffset := binary.LittleEndian.Uint32(buf[0:4])
+ proofsOffset := binary.LittleEndian.Uint32(buf[4:8])
+ blobsOffset := binary.LittleEndian.Uint32(buf[8:12])
+
+ if commitmentsOffset > uint32(len(buf)) || proofsOffset > uint32(len(buf)) || blobsOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("BlobsBundle SSZ: offsets out of bounds")
+ }
+
+ bundle := &BlobsBundle{}
+
+ // Commitments (each 48 bytes)
+ commBuf := buf[commitmentsOffset:proofsOffset]
+ if len(commBuf) > 0 {
+ if len(commBuf)%48 != 0 {
+ return nil, fmt.Errorf("BlobsBundle SSZ: commitments not aligned to 48 bytes")
+ }
+ bundle.Commitments = make([]hexutil.Bytes, len(commBuf)/48)
+ for i := range bundle.Commitments {
+ c := make(hexutil.Bytes, 48)
+ copy(c, commBuf[i*48:(i+1)*48])
+ bundle.Commitments[i] = c
+ }
+ }
+
+ // Proofs (each 48 bytes)
+ proofBuf := buf[proofsOffset:blobsOffset]
+ if len(proofBuf) > 0 {
+ if len(proofBuf)%48 != 0 {
+ return nil, fmt.Errorf("BlobsBundle SSZ: proofs not aligned to 48 bytes")
+ }
+ bundle.Proofs = make([]hexutil.Bytes, len(proofBuf)/48)
+ for i := range bundle.Proofs {
+ p := make(hexutil.Bytes, 48)
+ copy(p, proofBuf[i*48:(i+1)*48])
+ bundle.Proofs[i] = p
+ }
+ }
+
+ // Blobs (each 131072 bytes)
+ blobBuf := buf[blobsOffset:]
+ if len(blobBuf) > 0 {
+ if len(blobBuf)%131072 != 0 {
+ return nil, fmt.Errorf("BlobsBundle SSZ: blobs not aligned to 131072 bytes")
+ }
+ bundle.Blobs = make([]hexutil.Bytes, len(blobBuf)/131072)
+ for i := range bundle.Blobs {
+ b := make(hexutil.Bytes, 131072)
+ copy(b, blobBuf[i*131072:(i+1)*131072])
+ bundle.Blobs[i] = b
+ }
+ }
+
+ return bundle, nil
+}
+
+// encodeFixedSizeList concatenates a list of byte slices.
+func encodeFixedSizeList(items []hexutil.Bytes) []byte {
+ totalLen := 0
+ for _, item := range items {
+ totalLen += len(item)
+ }
+ buf := make([]byte, totalLen)
+ pos := 0
+ for _, item := range items {
+ copy(buf[pos:], item)
+ pos += len(item)
+ }
+ return buf
+}
+
+// EncodeGetBlobsRequest encodes a list of versioned hashes for the get_blobs SSZ request.
+func EncodeGetBlobsRequest(hashes []common.Hash) []byte {
+ buf := make([]byte, 4+len(hashes)*32)
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(len(hashes)))
+ for i, h := range hashes {
+ copy(buf[4+i*32:4+(i+1)*32], h[:])
+ }
+ return buf
+}
+
+// DecodeGetBlobsRequest decodes a list of versioned hashes from SSZ bytes.
+func DecodeGetBlobsRequest(buf []byte) ([]common.Hash, error) {
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("GetBlobsRequest: buffer too short")
+ }
+ count := binary.LittleEndian.Uint32(buf[0:4])
+ if 4+count*32 > uint32(len(buf)) {
+ return nil, fmt.Errorf("GetBlobsRequest: buffer too short for %d hashes", count)
+ }
+ hashes := make([]common.Hash, count)
+ for i := uint32(0); i < count; i++ {
+ copy(hashes[i][:], buf[4+i*32:4+(i+1)*32])
+ }
+ return hashes, 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..12174ae2162
--- /dev/null
+++ b/execution/engineapi/engine_types/ssz_test.go
@@ -0,0 +1,600 @@
+// 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 := &PayloadStatusSSZ{
+ Status: SSZStatusValid,
+ LatestValidHash: &hash,
+ ValidationError: "test error",
+ }
+
+ encoded := ps.EncodeSSZ()
+ decoded, err := DecodePayloadStatusSSZ(encoded)
+ req.NoError(err)
+ req.Equal(ps.Status, decoded.Status)
+ req.NotNil(decoded.LatestValidHash)
+ req.Equal(*ps.LatestValidHash, *decoded.LatestValidHash)
+ req.Equal(ps.ValidationError, decoded.ValidationError)
+
+ // Test with nil LatestValidHash
+ ps2 := &PayloadStatusSSZ{
+ Status: SSZStatusSyncing,
+ LatestValidHash: nil,
+ ValidationError: "",
+ }
+
+ encoded2 := ps2.EncodeSSZ()
+ decoded2, err := DecodePayloadStatusSSZ(encoded2)
+ req.NoError(err)
+ req.Equal(SSZStatusSyncing, decoded2.Status)
+ req.Nil(decoded2.LatestValidHash)
+ req.Empty(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"),
+ }
+
+ ssz := PayloadStatusToSSZ(ps)
+ req.Equal(SSZStatusValid, ssz.Status)
+ req.Equal(hash, *ssz.LatestValidHash)
+ req.Equal("block invalid", ssz.ValidationError)
+
+ back := ssz.ToPayloadStatus()
+ 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)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+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[1].Code, decoded[1].Code)
+ req.Equal(versions[1].Version, decoded[1].Version)
+}
+
+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 := DecodePayloadStatusSSZ(make([]byte, 5))
+ req.Error(err)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+func TestCapabilitiesDecodeShortBuffer(t *testing.T) {
+ req := require.New(t)
+
+ _, err := DecodeCapabilities(make([]byte, 2))
+ req.Error(err)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+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)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+// --- ForkchoiceUpdatedResponse round-trip tests (verifies offset bug fix) ---
+
+func TestForkchoiceUpdatedResponseRoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ // Test with no validation error and no payload ID
+ 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(SSZStatusValid, decoded.PayloadStatus.Status)
+ req.Equal(hash, *decoded.PayloadStatus.LatestValidHash)
+ req.Empty(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(SSZStatusSyncing, decoded.PayloadStatus.Status)
+ req.NotNil(decoded.PayloadId)
+ req.Equal(uint64(0x42), *decoded.PayloadId)
+}
+
+func TestForkchoiceUpdatedResponseWithValidationError(t *testing.T) {
+ req := require.New(t)
+
+ // This is the key test for the byte offset bug fix:
+ // When PayloadStatus has a validation error (variable-length),
+ // the payload_id must still be decoded correctly.
+ 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(SSZStatusInvalid, 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)
+ req.NotNil(decoded.PayloadId)
+ req.Equal(uint64(0xFF), *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)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+// --- NewPayload request SSZ round-trip tests ---
+
+func TestNewPayloadRequestV1RoundTrip(t *testing.T) {
+ req := require.New(t)
+
+ ep := makeTestExecutionPayloadV1()
+ encoded := EncodeNewPayloadRequestSSZ(ep, nil, nil, nil, 1)
+ decodedEp, blobHashes, parentRoot, execReqs, err := DecodeNewPayloadRequestSSZ(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 := EncodeNewPayloadRequestSSZ(ep, hashes, &root, nil, 3)
+ decodedEp, decodedHashes, decodedRoot, _, err := DecodeNewPayloadRequestSSZ(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)
+
+ // V4 = Electra, which uses Deneb payload layout (version 3)
+ // No SlotNumber or BlockAccessList
+ 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 := EncodeNewPayloadRequestSSZ(ep, hashes, &root, execReqs, 4)
+ decodedEp, decodedHashes, decodedRoot, decodedReqs, err := DecodeNewPayloadRequestSSZ(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)
+ req.Contains(err.Error(), "buffer too short")
+}
+
+// --- 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/node/cli/default_flags.go b/node/cli/default_flags.go
index a72e8504973..ba63ad7e223 100644
--- a/node/cli/default_flags.go
+++ b/node/cli/default_flags.go
@@ -69,6 +69,8 @@ var DefaultFlags = []cli.Flag{
&utils.AuthRpcAddr,
&utils.AuthRpcPort,
&utils.JWTSecretPath,
+ &utils.SszRestEnabledFlag,
+ &utils.SszRestPortFlag,
&utils.HttpCompressionFlag,
&utils.HTTPCORSDomainFlag,
&utils.HTTPVirtualHostsFlag,
diff --git a/node/cli/flags.go b/node/cli/flags.go
index 962734196a8..2ba713b8641 100644
--- a/node/cli/flags.go
+++ b/node/cli/flags.go
@@ -422,6 +422,8 @@ func setEmbeddedRpcDaemon(ctx *cli.Context, cfg *nodecfg.Config, logger log.Logg
AuthRpcHTTPListenAddress: ctx.String(utils.AuthRpcAddr.Name),
AuthRpcPort: ctx.Int(utils.AuthRpcPort.Name),
JWTSecretPath: jwtSecretPath,
+ SszRestEnabled: ctx.Bool(utils.SszRestEnabledFlag.Name),
+ SszRestPort: ctx.Int(utils.SszRestPortFlag.Name),
TraceRequests: ctx.Bool(utils.HTTPTraceFlag.Name),
DebugSingleRequest: ctx.Bool(utils.HTTPDebugSingleFlag.Name),
HttpCORSDomain: common.CliString2Array(ctx.String(utils.HTTPCORSDomainFlag.Name)),