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