From 712ed9f97bd0ecc609fd4ac1ba463245d3c1c971 Mon Sep 17 00:00:00 2001 From: Giulio Date: Mon, 2 Mar 2026 00:21:10 +0100 Subject: [PATCH 1/2] [WIP] EIP-8161: SSZ-REST Engine API transport (EL side) Implements the EL side of EIP-8161, adding an SSZ-REST HTTP server alongside the existing JSON-RPC Engine API. All engine_* methods are mapped to REST endpoints with SSZ-encoded request/response bodies, cutting payload sizes ~50% and eliminating JSON encode/decode overhead. - New SSZ-REST HTTP server with JWT auth (same secret as JSON-RPC) - SSZ encode/decode for all Engine API types (PayloadStatus, ForkchoiceUpdatedResponse, NewPayloadRequest, GetPayloadResponse, GetBlobs, ExchangeCapabilities, ClientVersion, CommunicationChannels) - CLI flags: --authrpc.ssz-rest, --authrpc.ssz-rest-port - EIP-8160 integration: advertises ssz_rest channel via engine_getClientCommunicationChannelsV1 - Handles V4 (Electra) and V5 (Fulu) with correct fork version mapping - Proper SSZ Union types for optional fields (latest_valid_hash, payload_id) Co-Authored-By: Claude Opus 4.6 --- cmd/rpcdaemon/cli/httpcfg/http_cfg.go | 4 + cmd/utils/flags.go | 10 + .../engineapi/engine_api_jsonrpc_client.go | 11 + execution/engineapi/engine_api_methods.go | 35 + execution/engineapi/engine_server.go | 36 + execution/engineapi/engine_server_test.go | 29 + execution/engineapi/engine_ssz_rest_server.go | 639 +++++++ .../engineapi/engine_ssz_rest_server_test.go | 556 ++++++ execution/engineapi/engine_types/jsonrpc.go | 7 + execution/engineapi/engine_types/ssz.go | 1573 +++++++++++++++++ execution/engineapi/engine_types/ssz_test.go | 625 +++++++ execution/engineapi/interface.go | 1 + node/cli/default_flags.go | 2 + node/cli/flags.go | 2 + 14 files changed, 3530 insertions(+) create mode 100644 execution/engineapi/engine_ssz_rest_server.go create mode 100644 execution/engineapi/engine_ssz_rest_server_test.go create mode 100644 execution/engineapi/engine_types/ssz.go create mode 100644 execution/engineapi/engine_types/ssz_test.go diff --git a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go index 1b2331be769..96e1fd38bae 100644 --- a/cmd/rpcdaemon/cli/httpcfg/http_cfg.go +++ b/cmd/rpcdaemon/cli/httpcfg/http_cfg.go @@ -111,4 +111,8 @@ type HttpCfg struct { RpcTxSyncDefaultTimeout time.Duration // Default timeout for eth_sendRawTransactionSync RpcTxSyncMaxTimeout time.Duration // Maximum timeout for eth_sendRawTransactionSync + + // EIP-8161: SSZ-REST Engine API Transport + SszRestEnabled bool // Enable SSZ-REST Engine API server alongside JSON-RPC + SszRestPort int // Port for the SSZ-REST Engine API server (default: AuthRpcPort + 1) } diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 81f5326518f..021103dd0ac 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -326,6 +326,16 @@ var ( Value: "", } + SszRestEnabledFlag = cli.BoolFlag{ + Name: "authrpc.ssz-rest", + Usage: "Enable the SSZ-REST Engine API transport (EIP-8161) alongside JSON-RPC", + } + SszRestPortFlag = cli.UintFlag{ + Name: "authrpc.ssz-rest-port", + Usage: "HTTP port for the SSZ-REST Engine API server (default: authrpc.port + 1)", + Value: 0, + } + HttpCompressionFlag = cli.BoolFlag{ Name: "http.compression", Usage: "Enable compression over HTTP-RPC. Use --http.compression=false to disable it", diff --git a/execution/engineapi/engine_api_jsonrpc_client.go b/execution/engineapi/engine_api_jsonrpc_client.go index f990c0c8359..dcf4b3373df 100644 --- a/execution/engineapi/engine_api_jsonrpc_client.go +++ b/execution/engineapi/engine_api_jsonrpc_client.go @@ -396,6 +396,17 @@ func (c *JsonRpcClient) GetClientVersionV1(ctx context.Context, callerVersion *e }, c.backOff(ctx)) } +func (c *JsonRpcClient) GetClientCommunicationChannelsV1(ctx context.Context) ([]enginetypes.CommunicationChannel, error) { + return backoff.RetryWithData(func() ([]enginetypes.CommunicationChannel, error) { + var result []enginetypes.CommunicationChannel + err := c.rpcClient.CallContext(ctx, &result, "engine_getClientCommunicationChannelsV1") + if err != nil { + return nil, c.maybeMakePermanent(err) + } + return result, nil + }, c.backOff(ctx)) +} + func (c *JsonRpcClient) backOff(ctx context.Context) backoff.BackOff { var backOff backoff.BackOff backOff = backoff.NewConstantBackOff(c.retryBackOff) diff --git a/execution/engineapi/engine_api_methods.go b/execution/engineapi/engine_api_methods.go index e10267cbf66..7aa0065e289 100644 --- a/execution/engineapi/engine_api_methods.go +++ b/execution/engineapi/engine_api_methods.go @@ -54,6 +54,7 @@ var ourCapabilities = []string{ "engine_getBlobsV1", "engine_getBlobsV2", "engine_getBlobsV3", + "engine_getClientCommunicationChannelsV1", } // Returns the most recent version of the payload(for the payloadID) at the time of receiving the call @@ -284,3 +285,37 @@ func (e *EngineServer) GetBlobsV3(ctx context.Context, blobHashes []common.Hash) } return nil, err } + +// GetClientCommunicationChannelsV1 returns the communication protocols and endpoints supported by the EL. +// See EIP-8160 and EIP-8161 +func (e *EngineServer) GetClientCommunicationChannelsV1(ctx context.Context) ([]engine_types.CommunicationChannel, error) { + e.engineLogSpamer.RecordRequest() + + addr := "localhost" + port := 8551 + if e.httpConfig != nil { + if e.httpConfig.AuthRpcHTTPListenAddress != "" { + addr = e.httpConfig.AuthRpcHTTPListenAddress + } + if e.httpConfig.AuthRpcPort != 0 { + port = e.httpConfig.AuthRpcPort + } + } + + channels := []engine_types.CommunicationChannel{ + { + Protocol: "json_rpc", + URL: fmt.Sprintf("%s:%d", addr, port), + }, + } + + // EIP-8161: Advertise the SSZ-REST channel if the server is running + if e.httpConfig != nil && e.httpConfig.SszRestEnabled && e.sszRestPort > 0 { + channels = append(channels, engine_types.CommunicationChannel{ + Protocol: "ssz_rest", + URL: fmt.Sprintf("http://%s:%d", addr, e.sszRestPort), + }) + } + + return channels, nil +} diff --git a/execution/engineapi/engine_server.go b/execution/engineapi/engine_server.go index 177867b8159..d1e8186deea 100644 --- a/execution/engineapi/engine_server.go +++ b/execution/engineapi/engine_server.go @@ -87,6 +87,8 @@ type EngineServer struct { // TODO Remove this on next release printPectraBanner bool maxReorgDepth uint64 + httpConfig *httpcfg.HttpCfg + sszRestPort int // EIP-8161: port the SSZ-REST server is listening on } func NewEngineServer( @@ -140,6 +142,7 @@ func (e *EngineServer) Start( return nil }) } + e.httpConfig = httpConfig base := jsonrpc.NewBaseApi(filters, stateCache, blockReader, httpConfig.WithDatadir, httpConfig.EvmCallTimeout, engineReader, httpConfig.Dirs, nil, httpConfig.RangeLimit) ethImpl := jsonrpc.NewEthAPI(base, db, eth, e.txpool, mining, jsonrpc.NewEthApiConfig(httpConfig), e.logger) @@ -164,6 +167,39 @@ func (e *EngineServer) Start( } return err }) + + // EIP-8161: Start SSZ-REST Engine API server if enabled + if httpConfig.SszRestEnabled { + eg.Go(func() error { + defer e.logger.Debug("[EngineServer] SSZ-REST server goroutine terminated") + jwtSecret, err := cli.ObtainJWTSecret(httpConfig, e.logger) + if err != nil { + e.logger.Error("[EngineServer] failed to obtain JWT secret for SSZ-REST server", "err", err) + return err + } + + addr := httpConfig.AuthRpcHTTPListenAddress + if addr == "" { + addr = "127.0.0.1" + } + port := httpConfig.SszRestPort + if port == 0 { + port = httpConfig.AuthRpcPort + 1 + if httpConfig.AuthRpcPort == 0 { + port = 8552 + } + } + e.sszRestPort = port + + sszServer := NewSszRestServer(e, e.logger, jwtSecret, addr, port) + err = sszServer.Start(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + e.logger.Error("[EngineServer] SSZ-REST server background goroutine failed", "err", err) + } + return err + }) + } + return eg.Wait() } diff --git a/execution/engineapi/engine_server_test.go b/execution/engineapi/engine_server_test.go index e122b24a015..9e7d422284b 100644 --- a/execution/engineapi/engine_server_test.go +++ b/execution/engineapi/engine_server_test.go @@ -348,6 +348,35 @@ func TestGetPayloadBodiesByHashV2(t *testing.T) { req.Equal(hexutil.Bytes(balBytes), bodies[0].BlockAccessList) } +func TestGetClientCommunicationChannelsV1(t *testing.T) { + mockSentry := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges)) + req := require.New(t) + + executionRpc := direct.NewExecutionClientDirect(mockSentry.ExecModule) + maxReorgDepth := ethconfig.Defaults.MaxReorgDepth + engineServer := NewEngineServer(mockSentry.Log, mockSentry.ChainConfig, executionRpc, nil, false, false, true, nil, ethconfig.Defaults.FcuTimeout, maxReorgDepth) + + ctx := context.Background() + + // Before Start (no httpConfig set) — should return defaults + channels, err := engineServer.GetClientCommunicationChannelsV1(ctx) + req.NoError(err) + req.Len(channels, 1) + req.Equal("json_rpc", channels[0].Protocol) + req.Equal("localhost:8551", channels[0].URL) + + // After setting httpConfig via Start-like initialization + engineServer.httpConfig = &httpcfg.HttpCfg{ + AuthRpcHTTPListenAddress: "0.0.0.0", + AuthRpcPort: 9551, + } + channels, err = engineServer.GetClientCommunicationChannelsV1(ctx) + req.NoError(err) + req.Len(channels, 1) + req.Equal("json_rpc", channels[0].Protocol) + req.Equal("0.0.0.0:9551", channels[0].URL) +} + func TestGetPayloadBodiesByRangeV2(t *testing.T) { mockSentry := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges)) req := require.New(t) diff --git a/execution/engineapi/engine_ssz_rest_server.go b/execution/engineapi/engine_ssz_rest_server.go new file mode 100644 index 00000000000..c7c2a6871c3 --- /dev/null +++ b/execution/engineapi/engine_ssz_rest_server.go @@ -0,0 +1,639 @@ +// Copyright 2025 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package engineapi + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc" +) + +// SszRestServer implements the EIP-8161 SSZ-REST Engine API transport. +// It runs alongside the JSON-RPC Engine API server and shares the same +// EngineServer for method dispatch. +type SszRestServer struct { + engine *EngineServer + logger log.Logger + jwtSecret []byte + addr string + port int + server *http.Server +} + +// NewSszRestServer creates a new SSZ-REST server. +func NewSszRestServer(engine *EngineServer, logger log.Logger, jwtSecret []byte, addr string, port int) *SszRestServer { + return &SszRestServer{ + engine: engine, + logger: logger, + jwtSecret: jwtSecret, + addr: addr, + port: port, + } +} + +// sszErrorResponse writes a JSON error response for non-200 status codes per EIP-8161. +func sszErrorResponse(w http.ResponseWriter, code int, jsonRpcCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + resp := struct { + Code int `json:"code"` + Message string `json:"message"` + }{ + Code: jsonRpcCode, + Message: message, + } + json.NewEncoder(w).Encode(resp) //nolint:errcheck +} + +// sszResponse writes a successful SSZ-encoded response. +func sszResponse(w http.ResponseWriter, data []byte) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck +} + +// Start starts the SSZ-REST HTTP server. It blocks until ctx is cancelled. +func (s *SszRestServer) Start(ctx context.Context) error { + mux := http.NewServeMux() + s.registerRoutes(mux) + + handler := s.jwtMiddleware(mux) + + listenAddr := fmt.Sprintf("%s:%d", s.addr, s.port) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return fmt.Errorf("SSZ-REST server failed to listen on %s: %w", listenAddr, err) + } + + s.server = &http.Server{ + Handler: handler, + } + + s.logger.Info("[SSZ-REST] Engine API server started", "addr", listenAddr) + + errCh := make(chan error, 1) + go func() { + if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { + errCh <- err + } + close(errCh) + }() + + select { + case <-ctx.Done(): + s.server.Close() + return ctx.Err() + case err := <-errCh: + return err + } +} + +// jwtMiddleware wraps an http.Handler with JWT authentication using the same +// secret and validation logic as the JSON-RPC Engine API (EIP-8161 requirement). +func (s *SszRestServer) jwtMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !rpc.CheckJwtSecret(w, r, s.jwtSecret) { + return // CheckJwtSecret already wrote the error response + } + // Recover from panics in handlers (e.g., nil pointer dereferences + // when engine dependencies are not fully initialized) + defer func() { + if rec := recover(); rec != nil { + s.logger.Error("[SSZ-REST] panic in handler", "panic", rec, "path", r.URL.Path) + sszErrorResponse(w, http.StatusInternalServerError, -32603, fmt.Sprintf("internal error: %v", rec)) + } + }() + next.ServeHTTP(w, r) + }) +} + +// registerRoutes registers all SSZ-REST endpoint routes per EIP-8161. +func (s *SszRestServer) registerRoutes(mux *http.ServeMux) { + // newPayload versions + mux.HandleFunc("POST /engine/v1/new_payload", s.handleNewPayloadV1) + mux.HandleFunc("POST /engine/v2/new_payload", s.handleNewPayloadV2) + mux.HandleFunc("POST /engine/v3/new_payload", s.handleNewPayloadV3) + mux.HandleFunc("POST /engine/v4/new_payload", s.handleNewPayloadV4) + mux.HandleFunc("POST /engine/v5/new_payload", s.handleNewPayloadV5) + + // forkchoiceUpdated versions + mux.HandleFunc("POST /engine/v1/forkchoice_updated", s.handleForkchoiceUpdatedV1) + mux.HandleFunc("POST /engine/v2/forkchoice_updated", s.handleForkchoiceUpdatedV2) + mux.HandleFunc("POST /engine/v3/forkchoice_updated", s.handleForkchoiceUpdatedV3) + + // getPayload versions + mux.HandleFunc("POST /engine/v1/get_payload", s.handleGetPayloadV1) + mux.HandleFunc("POST /engine/v2/get_payload", s.handleGetPayloadV2) + mux.HandleFunc("POST /engine/v3/get_payload", s.handleGetPayloadV3) + mux.HandleFunc("POST /engine/v4/get_payload", s.handleGetPayloadV4) + mux.HandleFunc("POST /engine/v5/get_payload", s.handleGetPayloadV5) + + // getBlobs + mux.HandleFunc("POST /engine/v1/get_blobs", s.handleGetBlobsV1) + + // exchangeCapabilities + mux.HandleFunc("POST /engine/v1/exchange_capabilities", s.handleExchangeCapabilities) + + // getClientVersion + mux.HandleFunc("POST /engine/v1/get_client_version", s.handleGetClientVersion) + + // getClientCommunicationChannels + mux.HandleFunc("POST /engine/v1/get_client_communication_channels", s.handleGetClientCommunicationChannels) +} + +// readBody reads the request body with a size limit. +func readBody(r *http.Request, maxSize int64) ([]byte, error) { + return io.ReadAll(io.LimitReader(r.Body, maxSize)) +} + +// --- newPayload handlers --- + +func (s *SszRestServer) handleNewPayloadV1(w http.ResponseWriter, r *http.Request) { + s.handleNewPayload(w, r, 1) +} + +func (s *SszRestServer) handleNewPayloadV2(w http.ResponseWriter, r *http.Request) { + s.handleNewPayload(w, r, 2) +} + +func (s *SszRestServer) handleNewPayloadV3(w http.ResponseWriter, r *http.Request) { + s.handleNewPayload(w, r, 3) +} + +func (s *SszRestServer) handleNewPayloadV4(w http.ResponseWriter, r *http.Request) { + s.handleNewPayload(w, r, 4) +} + +func (s *SszRestServer) handleNewPayloadV5(w http.ResponseWriter, r *http.Request) { + s.handleNewPayload(w, r, 5) +} + +func (s *SszRestServer) handleNewPayload(w http.ResponseWriter, r *http.Request, version int) { + s.logger.Info("[SSZ-REST] Received NewPayload", "version", version) + + body, err := readBody(r, 16*1024*1024) // 16 MB max + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + if len(body) == 0 { + sszErrorResponse(w, http.StatusBadRequest, -32602, "empty request body") + return + } + + // Decode the SSZ request: V1/V2 is just ExecutionPayload, V3/V4 is a wrapper container + ep, blobHashes, parentBeaconBlockRoot, executionRequests, err := engine_types.DecodeNewPayloadRequestSSZ(body, version) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("SSZ decode error: %v", err)) + return + } + + ctx := r.Context() + var result *engine_types.PayloadStatus + + switch version { + case 1: + result, err = s.engine.NewPayloadV1(ctx, ep) + case 2: + result, err = s.engine.NewPayloadV2(ctx, ep) + case 3: + result, err = s.engine.NewPayloadV3(ctx, ep, blobHashes, parentBeaconBlockRoot) + case 4, 5: + // Determine the correct fork version from the payload timestamp. + // The SSZ payload format is the same (Deneb) for V4/V5, but the engine + // does a fork-version check internally. + ts := uint64(ep.Timestamp) + forkVersion := clparams.ElectraVersion + if s.engine.config.IsAmsterdam(ts) { + forkVersion = clparams.GloasVersion + } else if s.engine.config.IsOsaka(ts) { + forkVersion = clparams.FuluVersion + } else if s.engine.config.IsPrague(ts) { + forkVersion = clparams.ElectraVersion + } + s.logger.Info("[SSZ-REST] NewPayload fork check", "timestamp", ts, "forkVersion", forkVersion, "urlVersion", version) + result, err = s.engine.newPayload(ctx, ep, blobHashes, parentBeaconBlockRoot, executionRequests, forkVersion) + default: + sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported newPayload version: %d", version)) + return + } + + if err != nil { + s.handleEngineError(w, err) + return + } + + // Encode PayloadStatus response + ps := engine_types.PayloadStatusToSSZ(result) + sszResponse(w, ps.EncodeSSZ()) +} + +// --- forkchoiceUpdated handlers --- + +func (s *SszRestServer) handleForkchoiceUpdatedV1(w http.ResponseWriter, r *http.Request) { + s.handleForkchoiceUpdated(w, r, 1) +} + +func (s *SszRestServer) handleForkchoiceUpdatedV2(w http.ResponseWriter, r *http.Request) { + s.handleForkchoiceUpdated(w, r, 2) +} + +func (s *SszRestServer) handleForkchoiceUpdatedV3(w http.ResponseWriter, r *http.Request) { + s.handleForkchoiceUpdated(w, r, 3) +} + +func (s *SszRestServer) handleForkchoiceUpdated(w http.ResponseWriter, r *http.Request, version int) { + s.logger.Info("[SSZ-REST] Received ForkchoiceUpdated", "version", version) + + body, err := readBody(r, 1024*1024) // 1 MB max + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + // SSZ Container layout: + // Fixed: forkchoice_state(96) + attributes_offset(4) = 100 bytes + // Variable: Union[None, PayloadAttributes] + const fixedSize = 100 + + if len(body) < 96 { + sszErrorResponse(w, http.StatusBadRequest, -32602, "request body too short for ForkchoiceState") + return + } + + // Decode ForkchoiceState (first 96 bytes) + fcs, err := engine_types.DecodeForkchoiceState(body[:96]) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + + var payloadAttributes *engine_types.PayloadAttributes + + if len(body) >= fixedSize { + attrOffset := binary.LittleEndian.Uint32(body[96:100]) + if attrOffset <= uint32(len(body)) && attrOffset < uint32(len(body)) { + // Union data at attrOffset + unionData := body[attrOffset:] + if len(unionData) > 0 { + selector := unionData[0] + if selector == 1 && len(unionData) > 1 { + pa, err := decodePayloadAttributesSSZ(unionData[1:], version) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + payloadAttributes = pa + } + // selector == 0 means None + } + } + } + + ctx := r.Context() + var resp *engine_types.ForkChoiceUpdatedResponse + + switch version { + case 1: + resp, err = s.engine.ForkchoiceUpdatedV1(ctx, fcs, payloadAttributes) + case 2: + resp, err = s.engine.ForkchoiceUpdatedV2(ctx, fcs, payloadAttributes) + case 3: + resp, err = s.engine.ForkchoiceUpdatedV3(ctx, fcs, payloadAttributes) + default: + sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported forkchoiceUpdated version: %d", version)) + return + } + + if err != nil { + s.handleEngineError(w, err) + return + } + + // Encode response + if resp.PayloadId != nil { + s.logger.Info("[SSZ-REST] ForkchoiceUpdated response", "payloadId", fmt.Sprintf("%x", []byte(*resp.PayloadId)), "status", resp.PayloadStatus.Status) + } else { + s.logger.Info("[SSZ-REST] ForkchoiceUpdated response", "payloadId", "nil", "status", resp.PayloadStatus.Status) + } + respBytes := engine_types.EncodeForkchoiceUpdatedResponse(resp) + s.logger.Info("[SSZ-REST] ForkchoiceUpdated encoded", "len", len(respBytes), "first20", fmt.Sprintf("%x", respBytes[:min(20, len(respBytes))])) + sszResponse(w, respBytes) +} + +// decodePayloadAttributesSSZ decodes PayloadAttributes from SSZ bytes. +// The version determines the layout: +// - V1 (Bellatrix): timestamp(8) + prev_randao(32) + fee_recipient(20) = 60 bytes fixed +// - V2 (Capella): timestamp(8) + prev_randao(32) + fee_recipient(20) + withdrawals_offset(4) = 64 bytes fixed + withdrawals +// - V3 (Deneb/Electra): same as V2 + parent_beacon_block_root(32) = 96 bytes fixed + withdrawals +func decodePayloadAttributesSSZ(buf []byte, version int) (*engine_types.PayloadAttributes, error) { + if len(buf) < 60 { + return nil, fmt.Errorf("PayloadAttributes: buffer too short (%d < 60)", len(buf)) + } + + timestamp := binary.LittleEndian.Uint64(buf[0:8]) + pa := &engine_types.PayloadAttributes{ + Timestamp: hexutil.Uint64(timestamp), + } + copy(pa.PrevRandao[:], buf[8:40]) + copy(pa.SuggestedFeeRecipient[:], buf[40:60]) + + if version == 1 { + return pa, nil + } + + // V2+: has withdrawals_offset at byte 60 + if len(buf) < 64 { + return nil, fmt.Errorf("PayloadAttributes V2+: buffer too short (%d < 64)", len(buf)) + } + withdrawalsOffset := binary.LittleEndian.Uint32(buf[60:64]) + + if version >= 3 { + // V3: has parent_beacon_block_root at bytes 64-96 + if len(buf) < 96 { + return nil, fmt.Errorf("PayloadAttributes V3: buffer too short (%d < 96)", len(buf)) + } + root := common.BytesToHash(buf[64:96]) + pa.ParentBeaconBlockRoot = &root + } + + // Decode withdrawals from the offset + if withdrawalsOffset <= uint32(len(buf)) { + wdBuf := buf[withdrawalsOffset:] + if len(wdBuf) > 0 { + // Each withdrawal = 44 bytes (index:8 + validator:8 + address:20 + amount:8) + if len(wdBuf)%44 != 0 { + return nil, fmt.Errorf("PayloadAttributes: withdrawals buffer length %d not divisible by 44", len(wdBuf)) + } + count := len(wdBuf) / 44 + pa.Withdrawals = make([]*types.Withdrawal, count) + for i := 0; i < count; i++ { + off := i * 44 + w := &types.Withdrawal{ + Index: binary.LittleEndian.Uint64(wdBuf[off : off+8]), + Validator: binary.LittleEndian.Uint64(wdBuf[off+8 : off+16]), + Amount: binary.LittleEndian.Uint64(wdBuf[off+36 : off+44]), + } + copy(w.Address[:], wdBuf[off+16:off+36]) + pa.Withdrawals[i] = w + } + } else { + pa.Withdrawals = []*types.Withdrawal{} + } + } + + return pa, nil +} + +// --- getPayload handlers --- + +func (s *SszRestServer) handleGetPayloadV1(w http.ResponseWriter, r *http.Request) { + s.handleGetPayload(w, r, 1) +} + +func (s *SszRestServer) handleGetPayloadV2(w http.ResponseWriter, r *http.Request) { + s.handleGetPayload(w, r, 2) +} + +func (s *SszRestServer) handleGetPayloadV3(w http.ResponseWriter, r *http.Request) { + s.handleGetPayload(w, r, 3) +} + +func (s *SszRestServer) handleGetPayloadV4(w http.ResponseWriter, r *http.Request) { + s.handleGetPayload(w, r, 4) +} + +func (s *SszRestServer) handleGetPayloadV5(w http.ResponseWriter, r *http.Request) { + s.handleGetPayload(w, r, 5) +} + +func (s *SszRestServer) handleGetPayload(w http.ResponseWriter, r *http.Request, version int) { + s.logger.Info("[SSZ-REST] Received GetPayload", "version", version) + + body, err := readBody(r, 64) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + if len(body) != 8 { + sszErrorResponse(w, http.StatusBadRequest, -32602, fmt.Sprintf("expected 8 bytes for payload ID, got %d", len(body))) + return + } + + // Payload ID is 8 bytes. The Engine API internally uses big-endian payload IDs + // (see ConvertPayloadId), so we pass the raw bytes directly. + payloadIdBytes := make(hexutil.Bytes, 8) + copy(payloadIdBytes, body) + + ctx := r.Context() + + switch version { + case 1: + result, err := s.engine.GetPayloadV1(ctx, payloadIdBytes) + if err != nil { + s.handleEngineError(w, err) + return + } + resp := &engine_types.GetPayloadResponse{ExecutionPayload: result} + sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(resp, 1)) + case 2, 3, 4, 5: + var result *engine_types.GetPayloadResponse + // For SSZ encoding, v5 (Fulu) uses same payload format as v4 (Electra/Deneb). + encodeVersion := version + switch version { + case 2: + result, err = s.engine.GetPayloadV2(ctx, payloadIdBytes) + case 3: + result, err = s.engine.GetPayloadV3(ctx, payloadIdBytes) + case 4: + result, err = s.engine.GetPayloadV4(ctx, payloadIdBytes) + case 5: + // Fulu uses same payload layout as Electra (Deneb format for SSZ encoding). + result, err = s.engine.GetPayloadV5(ctx, payloadIdBytes) + encodeVersion = 4 + } + if err != nil { + s.handleEngineError(w, err) + return + } + sszResponse(w, engine_types.EncodeGetPayloadResponseSSZ(result, encodeVersion)) + default: + sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported getPayload version: %d", version)) + } +} + +// --- getBlobs handler --- + +func (s *SszRestServer) handleGetBlobsV1(w http.ResponseWriter, r *http.Request) { + s.logger.Info("[SSZ-REST] Received GetBlobsV1") + + body, err := readBody(r, 1024*1024) // 1 MB max + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + hashes, err := engine_types.DecodeGetBlobsRequest(body) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + + ctx := r.Context() + result, err := s.engine.GetBlobsV1(ctx, hashes) + if err != nil { + s.handleEngineError(w, err) + return + } + + // Encode blobs response: count(4) + for each blob: has_blob(1) + blob(131072) + proof(48) + respBuf := encodeGetBlobsV1Response(result) + sszResponse(w, respBuf) +} + +// encodeGetBlobsV1Response encodes the GetBlobsV1 response as an SSZ Container. +// Layout: list_offset(4) + N * BlobAndProof (each 131120 bytes = blob:131072 + proof:48) +// Only non-nil blobs are included in the list. +func encodeGetBlobsV1Response(blobs []*engine_types.BlobAndProofV1) []byte { + const blobAndProofSize = 131072 + 48 // blob + KZG proof + + // Count non-nil blobs + var count int + for _, b := range blobs { + if b != nil { + count++ + } + } + + // SSZ Container with a single List field + fixedSize := 4 // list_offset + listSize := count * blobAndProofSize + buf := make([]byte, fixedSize+listSize) + + // Offset to the list data + binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize)) + + // Write each non-nil BlobAndProof as fixed-size items + pos := fixedSize + for _, b := range blobs { + if b == nil { + continue + } + // Blob (131072 bytes, zero-padded if shorter) + copy(buf[pos:pos+131072], b.Blob) + pos += 131072 + // Proof (48 bytes, zero-padded if shorter) + copy(buf[pos:pos+48], b.Proof) + pos += 48 + } + + return buf +} + +// --- exchangeCapabilities handler --- + +func (s *SszRestServer) handleExchangeCapabilities(w http.ResponseWriter, r *http.Request) { + s.logger.Info("[SSZ-REST] Received ExchangeCapabilities") + + body, err := readBody(r, 1024*1024) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + capabilities, err := engine_types.DecodeCapabilities(body) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + + result := s.engine.ExchangeCapabilities(capabilities) + sszResponse(w, engine_types.EncodeCapabilities(result)) +} + +// --- getClientVersion handler --- + +func (s *SszRestServer) handleGetClientVersion(w http.ResponseWriter, r *http.Request) { + s.logger.Info("[SSZ-REST] Received GetClientVersion") + + body, err := readBody(r, 1024*1024) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body") + return + } + + var callerVersion *engine_types.ClientVersionV1 + if len(body) > 0 { + cv, err := engine_types.DecodeClientVersion(body) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + callerVersion = cv + } + + ctx := r.Context() + result, err := s.engine.GetClientVersionV1(ctx, callerVersion) + if err != nil { + s.handleEngineError(w, err) + return + } + + sszResponse(w, engine_types.EncodeClientVersions(result)) +} + +// --- getClientCommunicationChannels handler --- + +func (s *SszRestServer) handleGetClientCommunicationChannels(w http.ResponseWriter, r *http.Request) { + s.logger.Info("[SSZ-REST] Received GetClientCommunicationChannels") + + ctx := r.Context() + result, err := s.engine.GetClientCommunicationChannelsV1(ctx) + if err != nil { + s.handleEngineError(w, err) + return + } + + sszResponse(w, engine_types.EncodeCommunicationChannels(result)) +} + +// handleEngineError converts engine errors to appropriate HTTP error responses. +func (s *SszRestServer) handleEngineError(w http.ResponseWriter, err error) { + s.logger.Warn("[SSZ-REST] Engine error", "err", err) + switch e := err.(type) { + case *rpc.InvalidParamsError: + sszErrorResponse(w, http.StatusBadRequest, -32602, e.Message) + case *rpc.UnsupportedForkError: + sszErrorResponse(w, http.StatusBadRequest, -32000, e.Message) + default: + sszErrorResponse(w, http.StatusInternalServerError, -32603, err.Error()) + } +} diff --git a/execution/engineapi/engine_ssz_rest_server_test.go b/execution/engineapi/engine_ssz_rest_server_test.go new file mode 100644 index 00000000000..caea22eb6ea --- /dev/null +++ b/execution/engineapi/engine_ssz_rest_server_test.go @@ -0,0 +1,556 @@ +// Copyright 2025 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package engineapi + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/cli/httpcfg" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/node/direct" + "github.com/erigontech/erigon/node/ethconfig" +) + +// getFreePort returns a free TCP port for testing. +func getFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} + +// makeJWTToken creates a valid JWT token for testing. +func makeJWTToken(secret []byte) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iat": time.Now().Unix(), + }) + tokenString, _ := token.SignedString(secret) + return tokenString +} + +// sszRestTestSetup creates an EngineServer and an SSZ-REST server for testing. +type sszRestTestSetup struct { + engineServer *EngineServer + sszServer *SszRestServer + jwtSecret []byte + baseURL string + cancel context.CancelFunc +} + +func newSszRestTestSetup(t *testing.T) *sszRestTestSetup { + t.Helper() + + mockSentry := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges)) + + executionRpc := direct.NewExecutionClientDirect(mockSentry.ExecModule) + maxReorgDepth := ethconfig.Defaults.MaxReorgDepth + engineServer := NewEngineServer(mockSentry.Log, mockSentry.ChainConfig, executionRpc, nil, false, false, true, nil, ethconfig.Defaults.FcuTimeout, maxReorgDepth) + + port := getFreePort(t) + engineServer.httpConfig = &httpcfg.HttpCfg{ + AuthRpcHTTPListenAddress: "127.0.0.1", + AuthRpcPort: 8551, + SszRestEnabled: true, + SszRestPort: port, + } + engineServer.sszRestPort = port + + jwtSecret := make([]byte, 32) + rand.Read(jwtSecret) + + sszServer := NewSszRestServer(engineServer, log.New(), jwtSecret, "127.0.0.1", port) + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + sszServer.Start(ctx) //nolint:errcheck + }() + + // Wait for server to start + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + waitForServer(t, baseURL, jwtSecret) + + return &sszRestTestSetup{ + engineServer: engineServer, + sszServer: sszServer, + jwtSecret: jwtSecret, + baseURL: baseURL, + cancel: cancel, + } +} + +func waitForServer(t *testing.T, baseURL string, jwtSecret []byte) { + t.Helper() + client := &http.Client{Timeout: time.Second} + for i := 0; i < 50; i++ { + req, _ := http.NewRequest("POST", baseURL+"/engine/v1/exchange_capabilities", nil) + req.Header.Set("Authorization", "Bearer "+makeJWTToken(jwtSecret)) + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("SSZ-REST server did not start in time") +} + +func (s *sszRestTestSetup) doRequest(t *testing.T, path string, body []byte) (*http.Response, []byte) { + t.Helper() + return s.doRequestWithToken(t, path, body, makeJWTToken(s.jwtSecret)) +} + +func (s *sszRestTestSetup) doRequestWithToken(t *testing.T, path string, body []byte, token string) (*http.Response, []byte) { + t.Helper() + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequest("POST", s.baseURL+path, bodyReader) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body.Close() + + return resp, respBody +} + +func TestSszRestJWTAuth(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Request without token should fail + httpReq, err := http.NewRequest("POST", setup.baseURL+"/engine/v1/exchange_capabilities", nil) + req.NoError(err) + httpReq.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(httpReq) + req.NoError(err) + resp.Body.Close() + req.Equal(http.StatusForbidden, resp.StatusCode) + + // Request with invalid token should fail + httpReq2, err := http.NewRequest("POST", setup.baseURL+"/engine/v1/exchange_capabilities", nil) + req.NoError(err) + httpReq2.Header.Set("Content-Type", "application/octet-stream") + httpReq2.Header.Set("Authorization", "Bearer invalidtoken") + + resp2, err := client.Do(httpReq2) + req.NoError(err) + resp2.Body.Close() + req.Equal(http.StatusForbidden, resp2.StatusCode) + + // Request with valid token should succeed + body := engine_types.EncodeCapabilities([]string{"engine_newPayloadV4"}) + resp3, _ := setup.doRequest(t, "/engine/v1/exchange_capabilities", body) + req.Equal(http.StatusOK, resp3.StatusCode) +} + +func TestSszRestExchangeCapabilities(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + clCapabilities := []string{ + "engine_newPayloadV4", + "engine_forkchoiceUpdatedV3", + "engine_getPayloadV4", + } + + body := engine_types.EncodeCapabilities(clCapabilities) + resp, respBody := setup.doRequest(t, "/engine/v1/exchange_capabilities", body) + req.Equal(http.StatusOK, resp.StatusCode) + req.Equal("application/octet-stream", resp.Header.Get("Content-Type")) + + decoded, err := engine_types.DecodeCapabilities(respBody) + req.NoError(err) + req.NotEmpty(decoded) + // Should contain at least the capabilities we sent (EL returns its own list) + req.Contains(decoded, "engine_newPayloadV4") + req.Contains(decoded, "engine_forkchoiceUpdatedV3") +} + +func TestSszRestGetClientVersion(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + callerVersion := &engine_types.ClientVersionV1{ + Code: "CL", + Name: "TestClient", + Version: "1.0.0", + Commit: "0x12345678", + } + + body := engine_types.EncodeClientVersion(callerVersion) + resp, respBody := setup.doRequest(t, "/engine/v1/get_client_version", body) + req.Equal(http.StatusOK, resp.StatusCode) + + versions, err := engine_types.DecodeClientVersions(respBody) + req.NoError(err) + req.Len(versions, 1) + req.Equal("EG", versions[0].Code) // Erigon's client code +} + +func TestSszRestGetClientCommunicationChannels(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + resp, respBody := setup.doRequest(t, "/engine/v1/get_client_communication_channels", nil) + req.Equal(http.StatusOK, resp.StatusCode) + + channels, err := engine_types.DecodeCommunicationChannels(respBody) + req.NoError(err) + req.Len(channels, 2) // json_rpc + ssz_rest + req.Equal("json_rpc", channels[0].Protocol) + req.Equal("ssz_rest", channels[1].Protocol) + req.Contains(channels[1].URL, "http://") +} + +func TestSszRestGetBlobsV1(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Request with empty hashes — may return 200 or 500 depending on txpool availability + hashes := []common.Hash{} + body := engine_types.EncodeGetBlobsRequest(hashes) + resp, _ := setup.doRequest(t, "/engine/v1/get_blobs", body) + // The test setup doesn't have a fully initialized txpool/blockDownloader, + // so the handler may panic (recovered) or return an engine error. + // We verify the SSZ-REST transport layer handled it gracefully. + req.True(resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusInternalServerError) +} + +func TestSszRestNotFoundEndpoint(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + resp, _ := setup.doRequest(t, "/engine/v99/nonexistent_method", nil) + // Go 1.22+ mux returns 404 for unmatched routes, or 405 for wrong methods + req.True(resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed) +} + +func TestSszRestErrorResponseFormat(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Send malformed body to get_blobs + resp, respBody := setup.doRequest(t, "/engine/v1/get_blobs", []byte{0x01}) + req.Equal(http.StatusBadRequest, resp.StatusCode) + req.Equal("application/json", resp.Header.Get("Content-Type")) + + // Parse the JSON error response + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + err := json.Unmarshal(respBody, &errResp) + req.NoError(err) + req.Equal(-32602, errResp.Code) + req.NotEmpty(errResp.Message) +} + +func TestSszRestForkchoiceUpdatedV3(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Build a ForkchoiceState SSZ Container: + // forkchoice_state(96) + attributes_offset(4) + Union[None](1) = 101 bytes + fcs := &engine_types.ForkChoiceState{ + HeadHash: common.Hash{}, + SafeBlockHash: common.Hash{}, + FinalizedBlockHash: common.Hash{}, + } + fcsBytes := engine_types.EncodeForkchoiceState(fcs) + req.Len(fcsBytes, 96) + + // Build the full container: fcs(96) + attr_offset(4) + union_selector(1) + body := make([]byte, 101) + copy(body[0:96], fcsBytes) + // attributes_offset = 100 (points to byte 100, the union selector) + body[96] = 100 + body[97] = 0 + body[98] = 0 + body[99] = 0 + body[100] = 0 // Union selector = 0 (None) + + // ForkchoiceUpdatedV3 with no payload attributes + resp, respBody := setup.doRequest(t, "/engine/v3/forkchoice_updated", body) + // The test setup doesn't have a fully initialized blockDownloader, + // so the engine may panic (recovered by SSZ-REST middleware) or return an error. + // We verify the SSZ-REST transport layer handled it gracefully without crashing. + if resp.StatusCode == http.StatusOK { + req.Equal("application/octet-stream", resp.Header.Get("Content-Type")) + req.NotEmpty(respBody) + } else { + // Engine errors or recovered panics are returned as JSON + req.Equal("application/json", resp.Header.Get("Content-Type")) + req.True(resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusInternalServerError) + } +} + +func TestSszRestForkchoiceUpdatedShortBody(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Send a body that's too short for ForkchoiceState + resp, respBody := setup.doRequest(t, "/engine/v3/forkchoice_updated", make([]byte, 50)) + req.Equal(http.StatusBadRequest, resp.StatusCode) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + err := json.Unmarshal(respBody, &errResp) + req.NoError(err) + req.Contains(errResp.Message, "too short") +} + +func TestSszRestGetPayloadWrongBodySize(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Send wrong-sized body (not 8 bytes) + resp, respBody := setup.doRequest(t, "/engine/v4/get_payload", make([]byte, 10)) + req.Equal(http.StatusBadRequest, resp.StatusCode) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + err := json.Unmarshal(respBody, &errResp) + req.NoError(err) + req.Contains(errResp.Message, "expected 8 bytes") +} + +func TestSszRestNewPayloadV1EmptyBody(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Empty body should return 400 + resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", nil) + req.Equal(http.StatusBadRequest, resp.StatusCode) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + err := json.Unmarshal(respBody, &errResp) + req.NoError(err) + req.Equal(-32602, errResp.Code) +} + +func TestSszRestNewPayloadV1MalformedBody(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Body too short to be a valid ExecutionPayload SSZ + resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", make([]byte, 100)) + req.Equal(http.StatusBadRequest, resp.StatusCode) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + err := json.Unmarshal(respBody, &errResp) + req.NoError(err) + req.Contains(errResp.Message, "SSZ decode error") +} + +func TestSszRestNewPayloadV1ValidSSZ(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Build a minimal ExecutionPayload and encode it to SSZ + ep := &engine_types.ExecutionPayload{ + ParentHash: common.Hash{}, + FeeRecipient: common.Address{}, + StateRoot: common.Hash{}, + ReceiptsRoot: common.Hash{}, + LogsBloom: make([]byte, 256), + PrevRandao: common.Hash{}, + BlockNumber: 0, + GasLimit: 30000000, + GasUsed: 0, + Timestamp: 1700000000, + ExtraData: []byte{}, + BaseFeePerGas: (*hexutil.Big)(common.Big0), + BlockHash: common.Hash{}, + Transactions: []hexutil.Bytes{}, + } + + body := engine_types.EncodeExecutionPayloadSSZ(ep, 1) + resp, respBody := setup.doRequest(t, "/engine/v1/new_payload", body) + + // The engine may return a real PayloadStatus or an error. + // With the mock setup, it might fail because engine consumption is not enabled. + // We verify the SSZ-REST transport layer correctly decoded and dispatched the request. + if resp.StatusCode == http.StatusOK { + req.Equal("application/octet-stream", resp.Header.Get("Content-Type")) + // Should be a PayloadStatusSSZ response (minimum 9 bytes fixed + 1 byte union selector) + req.GreaterOrEqual(len(respBody), 10) + // Decode the response to verify it's valid SSZ + ps, err := engine_types.DecodePayloadStatusSSZ(respBody) + req.NoError(err) + req.True(ps.Status <= engine_types.SSZStatusInvalidBlockHash) + } else { + // Engine errors come back as JSON + req.Equal("application/json", resp.Header.Get("Content-Type")) + } +} + +func TestSszRestGetPayloadV1ValidRequest(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + // Send a valid 8-byte payload ID + payloadId := make([]byte, 8) + payloadId[7] = 0x01 // payload ID = 1 + + resp, respBody := setup.doRequest(t, "/engine/v1/get_payload", payloadId) + + // The engine will likely return an error (unknown payload ID) or internal error + // because we haven't built a payload. The important thing is the handler doesn't + // return a "not yet supported" stub error. + if resp.StatusCode == http.StatusOK { + // Should be SSZ-encoded ExecutionPayload + req.Equal("application/octet-stream", resp.Header.Get("Content-Type")) + } else { + // Check that it's NOT the old stub error message + var errResp struct { + Message string `json:"message"` + } + json.Unmarshal(respBody, &errResp) //nolint:errcheck + req.NotContains(errResp.Message, "not yet supported") + req.NotContains(errResp.Message, "SSZ ExecutionPayload encoding") + } +} + +func TestSszRestGetPayloadV4ValidRequest(t *testing.T) { + setup := newSszRestTestSetup(t) + defer setup.cancel() + + req := require.New(t) + + payloadId := make([]byte, 8) + payloadId[7] = 0x01 + + resp, respBody := setup.doRequest(t, "/engine/v4/get_payload", payloadId) + + if resp.StatusCode == http.StatusOK { + req.Equal("application/octet-stream", resp.Header.Get("Content-Type")) + } else { + var errResp struct { + Message string `json:"message"` + } + json.Unmarshal(respBody, &errResp) //nolint:errcheck + req.NotContains(errResp.Message, "not yet supported") + req.NotContains(errResp.Message, "SSZ ExecutionPayload encoding") + } +} + +func TestGetClientCommunicationChannelsV1WithSSZRest(t *testing.T) { + mockSentry := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges)) + req := require.New(t) + + executionRpc := direct.NewExecutionClientDirect(mockSentry.ExecModule) + maxReorgDepth := ethconfig.Defaults.MaxReorgDepth + engineServer := NewEngineServer(mockSentry.Log, mockSentry.ChainConfig, executionRpc, nil, false, false, true, nil, ethconfig.Defaults.FcuTimeout, maxReorgDepth) + + ctx := context.Background() + + // Without SSZ-REST enabled — should return only json_rpc + engineServer.httpConfig = &httpcfg.HttpCfg{ + AuthRpcHTTPListenAddress: "0.0.0.0", + AuthRpcPort: 9551, + SszRestEnabled: false, + } + channels, err := engineServer.GetClientCommunicationChannelsV1(ctx) + req.NoError(err) + req.Len(channels, 1) + req.Equal("json_rpc", channels[0].Protocol) + + // With SSZ-REST enabled — should return both + engineServer.httpConfig = &httpcfg.HttpCfg{ + AuthRpcHTTPListenAddress: "0.0.0.0", + AuthRpcPort: 9551, + SszRestEnabled: true, + SszRestPort: 9552, + } + engineServer.sszRestPort = 9552 + channels, err = engineServer.GetClientCommunicationChannelsV1(ctx) + req.NoError(err) + req.Len(channels, 2) + req.Equal("json_rpc", channels[0].Protocol) + req.Equal("0.0.0.0:9551", channels[0].URL) + req.Equal("ssz_rest", channels[1].Protocol) + req.Equal("http://0.0.0.0:9552", channels[1].URL) +} diff --git a/execution/engineapi/engine_types/jsonrpc.go b/execution/engineapi/engine_types/jsonrpc.go index 045daece936..fe6d2536037 100644 --- a/execution/engineapi/engine_types/jsonrpc.go +++ b/execution/engineapi/engine_types/jsonrpc.go @@ -136,6 +136,13 @@ type ClientVersionV1 struct { Commit string `json:"commit" gencodec:"required"` } +// CommunicationChannel describes a protocol and endpoint supported by the EL. +// See EIP-8160: engine_getClientCommunicationChannelsV1 +type CommunicationChannel struct { + Protocol string `json:"protocol" gencodec:"required"` + URL string `json:"url" gencodec:"required"` +} + func (c ClientVersionV1) String() string { return fmt.Sprintf("ClientCode: %s, %s-%s-%s", c.Code, c.Name, c.Version, c.Commit) } diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go new file mode 100644 index 00000000000..f4625399a45 --- /dev/null +++ b/execution/engineapi/engine_types/ssz.go @@ -0,0 +1,1573 @@ +// Copyright 2025 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package engine_types + +import ( + "encoding/binary" + "fmt" + "math/big" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/types" +) + +// SSZ status codes for PayloadStatusSSZ (EIP-8161) +const ( + SSZStatusValid uint8 = 0 + SSZStatusInvalid uint8 = 1 + SSZStatusSyncing uint8 = 2 + SSZStatusAccepted uint8 = 3 + SSZStatusInvalidBlockHash uint8 = 4 +) + +// EngineStatusToSSZ converts a string EngineStatus to the SSZ uint8 representation. +func EngineStatusToSSZ(status EngineStatus) uint8 { + switch status { + case ValidStatus: + return SSZStatusValid + case InvalidStatus: + return SSZStatusInvalid + case SyncingStatus: + return SSZStatusSyncing + case AcceptedStatus: + return SSZStatusAccepted + case InvalidBlockHashStatus: + return SSZStatusInvalidBlockHash + default: + return SSZStatusInvalid + } +} + +// SSZToEngineStatus converts an SSZ uint8 status to the string EngineStatus. +func SSZToEngineStatus(status uint8) EngineStatus { + switch status { + case SSZStatusValid: + return ValidStatus + case SSZStatusInvalid: + return InvalidStatus + case SSZStatusSyncing: + return SyncingStatus + case SSZStatusAccepted: + return AcceptedStatus + case SSZStatusInvalidBlockHash: + return InvalidBlockHashStatus + default: + return InvalidStatus + } +} + +// PayloadStatusSSZ is the SSZ-encoded version of PayloadStatus for EIP-8161. +// +// SSZ layout (fixed part = 9 bytes): +// - status: 1 byte (uint8) +// - latest_valid_hash_offset: 4 bytes (offset to Union[None, Hash32]) +// - validation_error_offset: 4 bytes (offset to List[uint8, 1024]) +// +// SSZ variable part: +// - Union[None, Hash32]: selector(1) + hash(32) if selector==1; selector(1) if selector==0 +// - validation_error: List[uint8, 1024] — UTF-8 bytes +type PayloadStatusSSZ struct { + Status uint8 + LatestValidHash *common.Hash + ValidationError string +} + +const payloadStatusFixedSize = 9 // status(1) + hash_offset(4) + err_offset(4) + +// EncodeSSZ encodes the PayloadStatusSSZ to SSZ bytes per EIP-8161. +func (p *PayloadStatusSSZ) EncodeSSZ() []byte { + // Build Union[None, Hash32] variable data + var hashUnion []byte + if p.LatestValidHash != nil { + hashUnion = make([]byte, 33) // selector(1) + hash(32) + hashUnion[0] = 1 + copy(hashUnion[1:33], p.LatestValidHash[:]) + } else { + hashUnion = []byte{0} // selector(0) = None + } + + errorBytes := []byte(p.ValidationError) + + buf := make([]byte, payloadStatusFixedSize+len(hashUnion)+len(errorBytes)) + + buf[0] = p.Status + + // Offset to Union[None, Hash32] (starts after fixed part) + binary.LittleEndian.PutUint32(buf[1:5], uint32(payloadStatusFixedSize)) + // Offset to validation_error + binary.LittleEndian.PutUint32(buf[5:9], uint32(payloadStatusFixedSize+len(hashUnion))) + + copy(buf[payloadStatusFixedSize:], hashUnion) + copy(buf[payloadStatusFixedSize+len(hashUnion):], errorBytes) + return buf +} + +// DecodePayloadStatusSSZ decodes SSZ bytes into a PayloadStatusSSZ. +func DecodePayloadStatusSSZ(buf []byte) (*PayloadStatusSSZ, error) { + if len(buf) < payloadStatusFixedSize { + return nil, fmt.Errorf("PayloadStatusSSZ: buffer too short (%d < %d)", len(buf), payloadStatusFixedSize) + } + + p := &PayloadStatusSSZ{ + Status: buf[0], + } + + hashOffset := binary.LittleEndian.Uint32(buf[1:5]) + errOffset := binary.LittleEndian.Uint32(buf[5:9]) + + if hashOffset > uint32(len(buf)) || errOffset > uint32(len(buf)) || hashOffset > errOffset { + return nil, fmt.Errorf("PayloadStatusSSZ: offsets out of bounds") + } + + // Decode Union[None, Hash32] + unionData := buf[hashOffset:errOffset] + if len(unionData) > 0 { + selector := unionData[0] + if selector == 1 { + if len(unionData) < 33 { + return nil, fmt.Errorf("PayloadStatusSSZ: Union hash data too short") + } + hash := common.BytesToHash(unionData[1:33]) + p.LatestValidHash = &hash + } + // selector == 0 means None, LatestValidHash stays nil + } + + // Decode validation_error + if errOffset < uint32(len(buf)) { + errLen := uint32(len(buf)) - errOffset + if errLen > 1024 { + return nil, fmt.Errorf("PayloadStatusSSZ: validation error too long (%d > 1024)", errLen) + } + p.ValidationError = string(buf[errOffset:]) + } + + return p, nil +} + +// ToPayloadStatus converts SSZ format to the standard JSON-RPC PayloadStatus. +func (p *PayloadStatusSSZ) ToPayloadStatus() *PayloadStatus { + ps := &PayloadStatus{ + Status: SSZToEngineStatus(p.Status), + LatestValidHash: p.LatestValidHash, + } + if p.ValidationError != "" { + ps.ValidationError = NewStringifiedErrorFromString(p.ValidationError) + } + return ps +} + +// PayloadStatusToSSZ converts a JSON-RPC PayloadStatus to the SSZ format. +func PayloadStatusToSSZ(ps *PayloadStatus) *PayloadStatusSSZ { + s := &PayloadStatusSSZ{ + Status: EngineStatusToSSZ(ps.Status), + LatestValidHash: ps.LatestValidHash, + } + if ps.ValidationError != nil && ps.ValidationError.Error() != nil { + s.ValidationError = ps.ValidationError.Error().Error() + } + return s +} + +// ForkchoiceStateSSZ is the SSZ encoding of ForkchoiceState. +// Fixed layout: head_block_hash(32) + safe_block_hash(32) + finalized_block_hash(32) = 96 bytes +type ForkchoiceStateSSZ struct { + HeadBlockHash common.Hash + SafeBlockHash common.Hash + FinalizedBlockHash common.Hash +} + +func EncodeForkchoiceState(fcs *ForkChoiceState) []byte { + buf := make([]byte, 96) + copy(buf[0:32], fcs.HeadHash[:]) + copy(buf[32:64], fcs.SafeBlockHash[:]) + copy(buf[64:96], fcs.FinalizedBlockHash[:]) + return buf +} + +func DecodeForkchoiceState(buf []byte) (*ForkChoiceState, error) { + if len(buf) < 96 { + return nil, fmt.Errorf("ForkchoiceState: buffer too short (%d < 96)", len(buf)) + } + fcs := &ForkChoiceState{} + copy(fcs.HeadHash[:], buf[0:32]) + copy(fcs.SafeBlockHash[:], buf[32:64]) + copy(fcs.FinalizedBlockHash[:], buf[64:96]) + return fcs, nil +} + +// ForkchoiceUpdatedResponseSSZ is the SSZ-encoded forkchoice updated response. +// +// SSZ layout (fixed part = 8 bytes): +// - payload_status_offset: 4 bytes (uint32 LE, points to variable PayloadStatusSSZ data) +// - payload_id_offset: 4 bytes (uint32 LE, points to Union[None, uint64]) +// +// Variable part: +// - PayloadStatusSSZ data (variable length due to validation_error) +// - Union[None, uint64]: selector(1) + uint64(8) if selector==1; selector(1) if selector==0 +type ForkchoiceUpdatedResponseSSZ struct { + PayloadStatus *PayloadStatusSSZ + PayloadId *uint64 +} + +const forkchoiceUpdatedResponseFixedSize = 8 // 4 + 4 + +func EncodeForkchoiceUpdatedResponse(resp *ForkChoiceUpdatedResponse) []byte { + ps := PayloadStatusToSSZ(resp.PayloadStatus) + psBytes := ps.EncodeSSZ() + + // Build Union[None, uint64] for payload ID + var pidUnion []byte + if resp.PayloadId != nil { + pidUnion = make([]byte, 9) // selector(1) + uint64(8) + pidUnion[0] = 1 + payloadIdBytes := []byte(*resp.PayloadId) + if len(payloadIdBytes) == 8 { + copy(pidUnion[1:9], payloadIdBytes) + } + } else { + pidUnion = []byte{0} // selector(0) = None + } + + buf := make([]byte, forkchoiceUpdatedResponseFixedSize+len(psBytes)+len(pidUnion)) + + // Offset to PayloadStatus variable data (starts after fixed part) + binary.LittleEndian.PutUint32(buf[0:4], uint32(forkchoiceUpdatedResponseFixedSize)) + // Offset to Union[None, uint64] (after PayloadStatus data) + binary.LittleEndian.PutUint32(buf[4:8], uint32(forkchoiceUpdatedResponseFixedSize+len(psBytes))) + + // Variable part + copy(buf[forkchoiceUpdatedResponseFixedSize:], psBytes) + copy(buf[forkchoiceUpdatedResponseFixedSize+len(psBytes):], pidUnion) + + return buf +} + +func DecodeForkchoiceUpdatedResponse(buf []byte) (*ForkchoiceUpdatedResponseSSZ, error) { + if len(buf) < forkchoiceUpdatedResponseFixedSize { + return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: buffer too short (%d < %d)", len(buf), forkchoiceUpdatedResponseFixedSize) + } + + psOffset := binary.LittleEndian.Uint32(buf[0:4]) + pidOffset := binary.LittleEndian.Uint32(buf[4:8]) + + if psOffset > uint32(len(buf)) || pidOffset > uint32(len(buf)) || psOffset > pidOffset { + return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: offsets out of bounds") + } + + resp := &ForkchoiceUpdatedResponseSSZ{} + + // Decode PayloadStatus from psOffset to pidOffset + ps, err := DecodePayloadStatusSSZ(buf[psOffset:pidOffset]) + if err != nil { + return nil, err + } + resp.PayloadStatus = ps + + // Decode Union[None, uint64] from pidOffset to end + pidData := buf[pidOffset:] + if len(pidData) > 0 { + selector := pidData[0] + if selector == 1 { + if len(pidData) < 9 { + return nil, fmt.Errorf("ForkchoiceUpdatedResponseSSZ: Union payload_id data too short") + } + pid := binary.BigEndian.Uint64(pidData[1:9]) + resp.PayloadId = &pid + } + // selector == 0 means None + } + + return resp, nil +} + +// CommunicationChannelSSZ is the SSZ container for a communication channel. +// Used by get_client_communication_channels. +// +// SSZ layout (fixed part): +// - protocol_offset: 4 bytes +// - url_offset: 4 bytes +// +// Variable part: +// - protocol: List[uint8, 32] +// - url: List[uint8, 256] +type CommunicationChannelSSZ struct { + Protocol string + URL string +} + +func EncodeCommunicationChannels(channels []CommunicationChannel) []byte { + if len(channels) == 0 { + return []byte{} + } + + // Encode as a simple length-prefixed list of channels + // Each channel: protocol_len(4) + protocol + url_len(4) + url + var totalSize int + for _, ch := range channels { + totalSize += 4 + len(ch.Protocol) + 4 + len(ch.URL) + } + + buf := make([]byte, 4+totalSize) // count(4) + channels + binary.LittleEndian.PutUint32(buf[0:4], uint32(len(channels))) + + offset := 4 + for _, ch := range channels { + protBytes := []byte(ch.Protocol) + urlBytes := []byte(ch.URL) + + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(protBytes))) + offset += 4 + copy(buf[offset:], protBytes) + offset += len(protBytes) + + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(urlBytes))) + offset += 4 + copy(buf[offset:], urlBytes) + offset += len(urlBytes) + } + + return buf +} + +func DecodeCommunicationChannels(buf []byte) ([]CommunicationChannel, error) { + if len(buf) < 4 { + return nil, fmt.Errorf("CommunicationChannels: buffer too short") + } + + count := binary.LittleEndian.Uint32(buf[0:4]) + if count > 16 { + return nil, fmt.Errorf("CommunicationChannels: too many channels (%d > 16)", count) + } + + channels := make([]CommunicationChannel, 0, count) + offset := uint32(4) + + for i := uint32(0); i < count; i++ { + if offset+4 > uint32(len(buf)) { + return nil, fmt.Errorf("CommunicationChannels: unexpected end of buffer") + } + protLen := binary.LittleEndian.Uint32(buf[offset : offset+4]) + offset += 4 + if protLen > 32 || offset+protLen > uint32(len(buf)) { + return nil, fmt.Errorf("CommunicationChannels: protocol too long or truncated") + } + protocol := string(buf[offset : offset+protLen]) + offset += protLen + + if offset+4 > uint32(len(buf)) { + return nil, fmt.Errorf("CommunicationChannels: unexpected end of buffer") + } + urlLen := binary.LittleEndian.Uint32(buf[offset : offset+4]) + offset += 4 + if urlLen > 256 || offset+urlLen > uint32(len(buf)) { + return nil, fmt.Errorf("CommunicationChannels: URL too long or truncated") + } + url := string(buf[offset : offset+urlLen]) + offset += urlLen + + channels = append(channels, CommunicationChannel{ + Protocol: protocol, + URL: url, + }) + } + + return channels, nil +} + +// ExchangeCapabilitiesSSZ encodes/decodes a list of capability strings for SSZ transport. +func EncodeCapabilities(capabilities []string) []byte { + // count(4) + for each: len(4) + bytes + var totalSize int + for _, cap := range capabilities { + totalSize += 4 + len(cap) + } + + buf := make([]byte, 4+totalSize) + binary.LittleEndian.PutUint32(buf[0:4], uint32(len(capabilities))) + + offset := 4 + for _, cap := range capabilities { + capBytes := []byte(cap) + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(capBytes))) + offset += 4 + copy(buf[offset:], capBytes) + offset += len(capBytes) + } + + return buf +} + +func DecodeCapabilities(buf []byte) ([]string, error) { + if len(buf) < 4 { + return nil, fmt.Errorf("Capabilities: buffer too short") + } + + count := binary.LittleEndian.Uint32(buf[0:4]) + if count > 128 { + return nil, fmt.Errorf("Capabilities: too many capabilities (%d > 128)", count) + } + + capabilities := make([]string, 0, count) + offset := uint32(4) + + for i := uint32(0); i < count; i++ { + if offset+4 > uint32(len(buf)) { + return nil, fmt.Errorf("Capabilities: unexpected end of buffer") + } + capLen := binary.LittleEndian.Uint32(buf[offset : offset+4]) + offset += 4 + if capLen > 64 || offset+capLen > uint32(len(buf)) { + return nil, fmt.Errorf("Capabilities: capability too long or truncated") + } + capabilities = append(capabilities, string(buf[offset:offset+capLen])) + offset += capLen + } + + return capabilities, nil +} + +// ClientVersionSSZ encodes/decodes a ClientVersionV1 for SSZ transport. +func EncodeClientVersion(cv *ClientVersionV1) []byte { + codeBytes := []byte(cv.Code) + nameBytes := []byte(cv.Name) + versionBytes := []byte(cv.Version) + commitBytes := []byte(cv.Commit) + + // code_len(4) + code + name_len(4) + name + version_len(4) + version + commit_len(4) + commit + totalLen := 4 + len(codeBytes) + 4 + len(nameBytes) + 4 + len(versionBytes) + 4 + len(commitBytes) + buf := make([]byte, totalLen) + + offset := 0 + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(codeBytes))) + offset += 4 + copy(buf[offset:], codeBytes) + offset += len(codeBytes) + + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(nameBytes))) + offset += 4 + copy(buf[offset:], nameBytes) + offset += len(nameBytes) + + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(versionBytes))) + offset += 4 + copy(buf[offset:], versionBytes) + offset += len(versionBytes) + + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(commitBytes))) + offset += 4 + copy(buf[offset:], commitBytes) + + return buf +} + +func DecodeClientVersion(buf []byte) (*ClientVersionV1, error) { + if len(buf) < 16 { // minimum: 4 length fields + return nil, fmt.Errorf("ClientVersion: buffer too short") + } + + cv := &ClientVersionV1{} + offset := uint32(0) + + readString := func(maxLen uint32) (string, error) { + if offset+4 > uint32(len(buf)) { + return "", fmt.Errorf("ClientVersion: unexpected end of buffer") + } + strLen := binary.LittleEndian.Uint32(buf[offset : offset+4]) + offset += 4 + if strLen > maxLen || offset+strLen > uint32(len(buf)) { + return "", fmt.Errorf("ClientVersion: string too long or truncated") + } + s := string(buf[offset : offset+strLen]) + offset += strLen + return s, nil + } + + var err error + if cv.Code, err = readString(8); err != nil { + return nil, err + } + if cv.Name, err = readString(64); err != nil { + return nil, err + } + if cv.Version, err = readString(64); err != nil { + return nil, err + } + if cv.Commit, err = readString(64); err != nil { + return nil, err + } + + return cv, nil +} + +// EncodeClientVersions encodes a list of ClientVersionV1 for SSZ transport. +func EncodeClientVersions(versions []ClientVersionV1) []byte { + var parts [][]byte + for i := range versions { + parts = append(parts, EncodeClientVersion(&versions[i])) + } + + // count(4) + for each: len(4) + encoded + totalLen := 4 + for _, p := range parts { + totalLen += 4 + len(p) + } + + buf := make([]byte, totalLen) + binary.LittleEndian.PutUint32(buf[0:4], uint32(len(versions))) + + offset := 4 + for _, p := range parts { + binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(p))) + offset += 4 + copy(buf[offset:], p) + offset += len(p) + } + + return buf +} + +// DecodeClientVersions decodes a list of ClientVersionV1 from SSZ bytes. +func DecodeClientVersions(buf []byte) ([]ClientVersionV1, error) { + if len(buf) < 4 { + return nil, fmt.Errorf("ClientVersions: buffer too short") + } + + count := binary.LittleEndian.Uint32(buf[0:4]) + if count > 16 { + return nil, fmt.Errorf("ClientVersions: too many versions (%d > 16)", count) + } + + versions := make([]ClientVersionV1, 0, count) + offset := uint32(4) + + for i := uint32(0); i < count; i++ { + if offset+4 > uint32(len(buf)) { + return nil, fmt.Errorf("ClientVersions: unexpected end of buffer") + } + cvLen := binary.LittleEndian.Uint32(buf[offset : offset+4]) + offset += 4 + if offset+cvLen > uint32(len(buf)) { + return nil, fmt.Errorf("ClientVersions: truncated") + } + cv, err := DecodeClientVersion(buf[offset : offset+cvLen]) + if err != nil { + return nil, err + } + versions = append(versions, *cv) + offset += cvLen + } + + return versions, nil +} + +// engineVersionToPayloadVersion maps Engine API versions to ExecutionPayload SSZ versions. +// Engine V4 = Electra, which reuses the Deneb payload layout (version 3). +// Engine V5 = Gloas, which adds slot_number + block_access_list (version 4). +func engineVersionToPayloadVersion(engineVersion int) int { + if engineVersion == 4 { + return 3 // Electra uses Deneb payload layout + } + if engineVersion >= 5 { + return 4 // Gloas and beyond use the extended layout + } + return engineVersion +} + +// --- ExecutionPayload SSZ encoding/decoding --- +// +// The ExecutionPayload SSZ encoding follows the Ethereum consensus specs SSZ container layout. +// Fields are version-dependent: +// - V1 (Bellatrix): base fields +// - V2 (Capella): + withdrawals +// - V3 (Deneb): + blob_gas_used, excess_blob_gas +// - V4 (Gloas): + slot_number, block_access_list +// +// The SSZ container has a fixed part (with offsets for variable-length fields) +// followed by a variable part containing the actual variable-length data. + +// executionPayloadFixedSize returns the fixed part size for a given version. +func executionPayloadFixedSize(version int) int { + // Base (V1/Bellatrix): parent_hash(32) + fee_recipient(20) + state_root(32) + + // receipts_root(32) + logs_bloom(256) + prev_randao(32) + block_number(8) + + // gas_limit(8) + gas_used(8) + timestamp(8) + extra_data_offset(4) + + // base_fee_per_gas(32) + block_hash(32) + transactions_offset(4) = 508 + size := 508 + if version >= 2 { + size += 4 // withdrawals_offset + } + if version >= 3 { + size += 8 + 8 // blob_gas_used + excess_blob_gas + } + if version >= 4 { + size += 8 + 4 // slot_number + block_access_list_offset + } + return size +} + +// uint256ToSSZBytes converts a big.Int to 32-byte little-endian SSZ representation. +func uint256ToSSZBytes(val *big.Int) []byte { + buf := make([]byte, 32) + if val == nil { + return buf + } + b := val.Bytes() // big-endian, minimal + // Copy into buf in reverse (little-endian) + for i, v := range b { + buf[len(b)-1-i] = v + } + return buf +} + +// sszBytesToUint256 converts 32-byte little-endian SSZ bytes to a big.Int. +func sszBytesToUint256(buf []byte) *big.Int { + // Convert from little-endian to big-endian + be := make([]byte, 32) + for i := 0; i < 32; i++ { + be[31-i] = buf[i] + } + return new(big.Int).SetBytes(be) +} + +// encodeTransactionsSSZ encodes a list of transactions as an SSZ list of variable-length items. +// Layout: N offsets (4 bytes each) followed by transaction data. +func encodeTransactionsSSZ(txs []hexutil.Bytes) []byte { + if len(txs) == 0 { + return nil + } + // Calculate total size + offsetsSize := len(txs) * 4 + dataSize := 0 + for _, tx := range txs { + dataSize += len(tx) + } + buf := make([]byte, offsetsSize+dataSize) + + // Write offsets (relative to start of this list data) + dataStart := offsetsSize + for i, tx := range txs { + binary.LittleEndian.PutUint32(buf[i*4:(i+1)*4], uint32(dataStart)) + dataStart += len(tx) + } + // Write transaction data + pos := offsetsSize + for _, tx := range txs { + copy(buf[pos:], tx) + pos += len(tx) + } + return buf +} + +// decodeTransactionsSSZ decodes an SSZ-encoded list of variable-length transactions. +func decodeTransactionsSSZ(buf []byte) ([]hexutil.Bytes, error) { + if len(buf) == 0 { + return nil, nil + } + if len(buf) < 4 { + return nil, fmt.Errorf("transactions SSZ: buffer too short") + } + // The first offset tells us how many offsets there are + firstOffset := binary.LittleEndian.Uint32(buf[0:4]) + if firstOffset%4 != 0 { + return nil, fmt.Errorf("transactions SSZ: first offset not aligned (%d)", firstOffset) + } + count := firstOffset / 4 + if count == 0 { + return nil, nil + } + if firstOffset > uint32(len(buf)) { + return nil, fmt.Errorf("transactions SSZ: first offset out of bounds") + } + + // Read all offsets + offsets := make([]uint32, count) + for i := uint32(0); i < count; i++ { + offsets[i] = binary.LittleEndian.Uint32(buf[i*4 : (i+1)*4]) + } + + txs := make([]hexutil.Bytes, count) + for i := uint32(0); i < count; i++ { + start := offsets[i] + var end uint32 + if i+1 < count { + end = offsets[i+1] + } else { + end = uint32(len(buf)) + } + if start > uint32(len(buf)) || end > uint32(len(buf)) || start > end { + return nil, fmt.Errorf("transactions SSZ: invalid offset at index %d", i) + } + tx := make(hexutil.Bytes, end-start) + copy(tx, buf[start:end]) + txs[i] = tx + } + return txs, nil +} + +// SSZ Withdrawal layout: index(8) + validator_index(8) + address(20) + amount(8) = 44 bytes +const withdrawalSSZSize = 44 + +func encodeWithdrawalsSSZ(withdrawals []*types.Withdrawal) []byte { + if withdrawals == nil { + return nil + } + buf := make([]byte, len(withdrawals)*withdrawalSSZSize) + for i, w := range withdrawals { + off := i * withdrawalSSZSize + binary.LittleEndian.PutUint64(buf[off:off+8], w.Index) + binary.LittleEndian.PutUint64(buf[off+8:off+16], w.Validator) + copy(buf[off+16:off+36], w.Address[:]) + binary.LittleEndian.PutUint64(buf[off+36:off+44], w.Amount) + } + return buf +} + +func decodeWithdrawalsSSZ(buf []byte) ([]*types.Withdrawal, error) { + if len(buf) == 0 { + return []*types.Withdrawal{}, nil + } + if len(buf)%withdrawalSSZSize != 0 { + return nil, fmt.Errorf("withdrawals SSZ: buffer length %d not divisible by %d", len(buf), withdrawalSSZSize) + } + count := len(buf) / withdrawalSSZSize + withdrawals := make([]*types.Withdrawal, count) + for i := 0; i < count; i++ { + off := i * withdrawalSSZSize + withdrawals[i] = &types.Withdrawal{ + Index: binary.LittleEndian.Uint64(buf[off : off+8]), + Validator: binary.LittleEndian.Uint64(buf[off+8 : off+16]), + Amount: binary.LittleEndian.Uint64(buf[off+36 : off+44]), + } + copy(withdrawals[i].Address[:], buf[off+16:off+36]) + } + return withdrawals, nil +} + +// EncodeExecutionPayloadSSZ encodes an ExecutionPayload to SSZ bytes. +// The version parameter determines which fields are included: +// +// 1=Bellatrix, 2=Capella, 3=Deneb, 4=Gloas +func EncodeExecutionPayloadSSZ(ep *ExecutionPayload, version int) []byte { + fixedSize := executionPayloadFixedSize(version) + + // Prepare variable-length field data + extraData := []byte(ep.ExtraData) + txData := encodeTransactionsSSZ(ep.Transactions) + var withdrawalData []byte + if version >= 2 { + withdrawalData = encodeWithdrawalsSSZ(ep.Withdrawals) + } + var blockAccessListData []byte + if version >= 4 { + blockAccessListData = []byte(ep.BlockAccessList) + } + + totalVarSize := len(extraData) + len(txData) + if version >= 2 { + totalVarSize += len(withdrawalData) + } + if version >= 4 { + totalVarSize += len(blockAccessListData) + } + + buf := make([]byte, fixedSize+totalVarSize) + pos := 0 + + // Fixed fields + copy(buf[pos:pos+32], ep.ParentHash[:]) + pos += 32 + copy(buf[pos:pos+20], ep.FeeRecipient[:]) + pos += 20 + copy(buf[pos:pos+32], ep.StateRoot[:]) + pos += 32 + copy(buf[pos:pos+32], ep.ReceiptsRoot[:]) + pos += 32 + // LogsBloom is always 256 bytes + if len(ep.LogsBloom) >= 256 { + copy(buf[pos:pos+256], ep.LogsBloom[:256]) + } + pos += 256 + copy(buf[pos:pos+32], ep.PrevRandao[:]) + pos += 32 + binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.BlockNumber)) + pos += 8 + binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.GasLimit)) + pos += 8 + binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.GasUsed)) + pos += 8 + binary.LittleEndian.PutUint64(buf[pos:pos+8], uint64(ep.Timestamp)) + pos += 8 + + // extra_data offset + extraDataOffset := fixedSize + binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(extraDataOffset)) + pos += 4 + + // base_fee_per_gas (uint256, 32 bytes LE) + var baseFee *big.Int + if ep.BaseFeePerGas != nil { + baseFee = ep.BaseFeePerGas.ToInt() + } + copy(buf[pos:pos+32], uint256ToSSZBytes(baseFee)) + pos += 32 + + copy(buf[pos:pos+32], ep.BlockHash[:]) + pos += 32 + + // transactions offset + txOffset := extraDataOffset + len(extraData) + binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(txOffset)) + pos += 4 + + if version >= 2 { + // withdrawals offset + wdOffset := txOffset + len(txData) + binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(wdOffset)) + pos += 4 + } + + if version >= 3 { + var blobGasUsed, excessBlobGas uint64 + if ep.BlobGasUsed != nil { + blobGasUsed = uint64(*ep.BlobGasUsed) + } + if ep.ExcessBlobGas != nil { + excessBlobGas = uint64(*ep.ExcessBlobGas) + } + binary.LittleEndian.PutUint64(buf[pos:pos+8], blobGasUsed) + pos += 8 + binary.LittleEndian.PutUint64(buf[pos:pos+8], excessBlobGas) + pos += 8 + } + + if version >= 4 { + var slotNumber uint64 + if ep.SlotNumber != nil { + slotNumber = uint64(*ep.SlotNumber) + } + binary.LittleEndian.PutUint64(buf[pos:pos+8], slotNumber) + pos += 8 + + // block_access_list offset + balOffset := extraDataOffset + len(extraData) + len(txData) + if version >= 2 { + balOffset += len(withdrawalData) + } + binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(balOffset)) + pos += 4 + } + + // Variable part + copy(buf[extraDataOffset:], extraData) + copy(buf[txOffset:], txData) + if version >= 2 { + wdOffset := txOffset + len(txData) + copy(buf[wdOffset:], withdrawalData) + } + if version >= 4 { + balOffset := extraDataOffset + len(extraData) + len(txData) + if version >= 2 { + balOffset += len(withdrawalData) + } + copy(buf[balOffset:], blockAccessListData) + } + + return buf +} + +// DecodeExecutionPayloadSSZ decodes SSZ bytes into an ExecutionPayload. +func DecodeExecutionPayloadSSZ(buf []byte, version int) (*ExecutionPayload, error) { + fixedSize := executionPayloadFixedSize(version) + if len(buf) < fixedSize { + return nil, fmt.Errorf("ExecutionPayload SSZ: buffer too short (%d < %d)", len(buf), fixedSize) + } + + ep := &ExecutionPayload{} + pos := 0 + + copy(ep.ParentHash[:], buf[pos:pos+32]) + pos += 32 + copy(ep.FeeRecipient[:], buf[pos:pos+20]) + pos += 20 + copy(ep.StateRoot[:], buf[pos:pos+32]) + pos += 32 + copy(ep.ReceiptsRoot[:], buf[pos:pos+32]) + pos += 32 + ep.LogsBloom = make(hexutil.Bytes, 256) + copy(ep.LogsBloom, buf[pos:pos+256]) + pos += 256 + copy(ep.PrevRandao[:], buf[pos:pos+32]) + pos += 32 + ep.BlockNumber = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + pos += 8 + ep.GasLimit = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + pos += 8 + ep.GasUsed = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + pos += 8 + ep.Timestamp = hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + pos += 8 + + extraDataOffset := binary.LittleEndian.Uint32(buf[pos : pos+4]) + pos += 4 + + baseFee := sszBytesToUint256(buf[pos : pos+32]) + ep.BaseFeePerGas = (*hexutil.Big)(baseFee) + pos += 32 + + copy(ep.BlockHash[:], buf[pos:pos+32]) + pos += 32 + + txOffset := binary.LittleEndian.Uint32(buf[pos : pos+4]) + pos += 4 + + var wdOffset uint32 + if version >= 2 { + wdOffset = binary.LittleEndian.Uint32(buf[pos : pos+4]) + pos += 4 + } + + if version >= 3 { + blobGasUsed := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + ep.BlobGasUsed = &blobGasUsed + pos += 8 + excessBlobGas := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + ep.ExcessBlobGas = &excessBlobGas + pos += 8 + } + + var balOffset uint32 + if version >= 4 { + slotNumber := hexutil.Uint64(binary.LittleEndian.Uint64(buf[pos : pos+8])) + ep.SlotNumber = &slotNumber + pos += 8 + balOffset = binary.LittleEndian.Uint32(buf[pos : pos+4]) + pos += 4 + } + + // Decode variable-length fields using offsets + // extra_data: from extraDataOffset to txOffset + if extraDataOffset > uint32(len(buf)) || txOffset > uint32(len(buf)) || extraDataOffset > txOffset { + return nil, fmt.Errorf("ExecutionPayload SSZ: invalid extra_data/transactions offsets") + } + ep.ExtraData = make(hexutil.Bytes, txOffset-extraDataOffset) + copy(ep.ExtraData, buf[extraDataOffset:txOffset]) + + // Determine end of transactions + var txEnd uint32 + if version >= 2 { + txEnd = wdOffset + } else { + txEnd = uint32(len(buf)) + } + if txOffset > txEnd { + return nil, fmt.Errorf("ExecutionPayload SSZ: transactions offset > end") + } + + // Decode transactions + txBuf := buf[txOffset:txEnd] + txs, err := decodeTransactionsSSZ(txBuf) + if err != nil { + return nil, fmt.Errorf("ExecutionPayload SSZ: %w", err) + } + ep.Transactions = txs + if ep.Transactions == nil { + ep.Transactions = []hexutil.Bytes{} + } + + // Decode withdrawals + if version >= 2 { + var wdEnd uint32 + if version >= 4 { + wdEnd = balOffset + } else { + wdEnd = uint32(len(buf)) + } + if wdOffset > wdEnd || wdEnd > uint32(len(buf)) { + return nil, fmt.Errorf("ExecutionPayload SSZ: invalid withdrawals offset") + } + wds, err := decodeWithdrawalsSSZ(buf[wdOffset:wdEnd]) + if err != nil { + return nil, fmt.Errorf("ExecutionPayload SSZ: %w", err) + } + ep.Withdrawals = wds + } + + // Decode block access list + if version >= 4 { + if balOffset > uint32(len(buf)) { + return nil, fmt.Errorf("ExecutionPayload SSZ: block_access_list offset out of bounds") + } + ep.BlockAccessList = make(hexutil.Bytes, uint32(len(buf))-balOffset) + copy(ep.BlockAccessList, buf[balOffset:]) + } + + return ep, nil +} + +// --- NewPayload request SSZ encoding/decoding --- +// +// V1/V2: The request body is just the SSZ-encoded ExecutionPayload. +// +// V3 NewPayloadRequest SSZ container: +// Fixed part: ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) = 40 bytes +// Variable: ExecutionPayload data, blob hashes (N * 32 bytes) +// +// V4 NewPayloadRequest SSZ container: +// Fixed part: ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) + requests_offset(4) = 44 bytes +// Variable: ExecutionPayload data, blob hashes, execution requests + +// EncodeNewPayloadRequestSSZ encodes a newPayload request to SSZ. +func EncodeNewPayloadRequestSSZ( + ep *ExecutionPayload, + blobHashes []common.Hash, + parentBeaconBlockRoot *common.Hash, + executionRequests []hexutil.Bytes, + version int, +) []byte { + payloadVersion := engineVersionToPayloadVersion(version) + if version <= 2 { + return EncodeExecutionPayloadSSZ(ep, payloadVersion) + } + + epBytes := EncodeExecutionPayloadSSZ(ep, payloadVersion) + blobHashBytes := make([]byte, len(blobHashes)*32) + for i, h := range blobHashes { + copy(blobHashBytes[i*32:(i+1)*32], h[:]) + } + + if version == 3 { + fixedSize := 40 // ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) + buf := make([]byte, fixedSize+len(epBytes)+len(blobHashBytes)) + + // ep offset + binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize)) + // blob hashes offset + binary.LittleEndian.PutUint32(buf[4:8], uint32(fixedSize+len(epBytes))) + // parent beacon block root + if parentBeaconBlockRoot != nil { + copy(buf[8:40], parentBeaconBlockRoot[:]) + } + // Variable + copy(buf[fixedSize:], epBytes) + copy(buf[fixedSize+len(epBytes):], blobHashBytes) + return buf + } + + // V4+ + // Encode execution requests as structured SSZ Container for Prysm compatibility + reqBytes := encodeStructuredExecutionRequestsSSZ(executionRequests) + + fixedSize := 44 // ep_offset(4) + blob_hashes_offset(4) + parent_beacon_block_root(32) + requests_offset(4) + buf := make([]byte, fixedSize+len(epBytes)+len(blobHashBytes)+len(reqBytes)) + + binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(fixedSize+len(epBytes))) + if parentBeaconBlockRoot != nil { + copy(buf[8:40], parentBeaconBlockRoot[:]) + } + binary.LittleEndian.PutUint32(buf[40:44], uint32(fixedSize+len(epBytes)+len(blobHashBytes))) + + copy(buf[fixedSize:], epBytes) + copy(buf[fixedSize+len(epBytes):], blobHashBytes) + copy(buf[fixedSize+len(epBytes)+len(blobHashBytes):], reqBytes) + return buf +} + +// DecodeNewPayloadRequestSSZ decodes a newPayload request from SSZ. +func DecodeNewPayloadRequestSSZ(buf []byte, version int) ( + ep *ExecutionPayload, + blobHashes []common.Hash, + parentBeaconBlockRoot *common.Hash, + executionRequests []hexutil.Bytes, + err error, +) { + payloadVersion := engineVersionToPayloadVersion(version) + if version <= 2 { + ep, err = DecodeExecutionPayloadSSZ(buf, payloadVersion) + return + } + + if version == 3 { + if len(buf) < 40 { + err = fmt.Errorf("NewPayloadV3 SSZ: buffer too short (%d < 40)", len(buf)) + return + } + epOffset := binary.LittleEndian.Uint32(buf[0:4]) + blobHashOffset := binary.LittleEndian.Uint32(buf[4:8]) + root := common.BytesToHash(buf[8:40]) + parentBeaconBlockRoot = &root + + if epOffset > uint32(len(buf)) || blobHashOffset > uint32(len(buf)) || epOffset > blobHashOffset { + err = fmt.Errorf("NewPayloadV3 SSZ: invalid offsets") + return + } + ep, err = DecodeExecutionPayloadSSZ(buf[epOffset:blobHashOffset], payloadVersion) + if err != nil { + return + } + blobHashBuf := buf[blobHashOffset:] + if len(blobHashBuf)%32 != 0 { + err = fmt.Errorf("NewPayloadV3 SSZ: blob hashes not aligned") + return + } + blobHashes = make([]common.Hash, len(blobHashBuf)/32) + for i := range blobHashes { + copy(blobHashes[i][:], blobHashBuf[i*32:(i+1)*32]) + } + return + } + + // V4+ + if len(buf) < 44 { + err = fmt.Errorf("NewPayloadV4 SSZ: buffer too short (%d < 44)", len(buf)) + return + } + epOffset := binary.LittleEndian.Uint32(buf[0:4]) + blobHashOffset := binary.LittleEndian.Uint32(buf[4:8]) + root := common.BytesToHash(buf[8:40]) + parentBeaconBlockRoot = &root + reqOffset := binary.LittleEndian.Uint32(buf[40:44]) + + if epOffset > uint32(len(buf)) || blobHashOffset > uint32(len(buf)) || reqOffset > uint32(len(buf)) { + err = fmt.Errorf("NewPayloadV4 SSZ: offsets out of bounds") + return + } + ep, err = DecodeExecutionPayloadSSZ(buf[epOffset:blobHashOffset], payloadVersion) + if err != nil { + return + } + blobHashBuf := buf[blobHashOffset:reqOffset] + if len(blobHashBuf)%32 != 0 { + err = fmt.Errorf("NewPayloadV4 SSZ: blob hashes not aligned") + return + } + blobHashes = make([]common.Hash, len(blobHashBuf)/32) + for i := range blobHashes { + copy(blobHashes[i][:], blobHashBuf[i*32:(i+1)*32]) + } + + executionRequests, err = decodeStructuredExecutionRequestsSSZ(buf[reqOffset:]) + return +} + +// encodeExecutionRequestsSSZ encodes execution requests as SSZ list of variable items. +func encodeExecutionRequestsSSZ(reqs []hexutil.Bytes) []byte { + if len(reqs) == 0 { + return nil + } + offsetsSize := len(reqs) * 4 + dataSize := 0 + for _, r := range reqs { + dataSize += len(r) + } + buf := make([]byte, offsetsSize+dataSize) + dataStart := offsetsSize + for i, r := range reqs { + binary.LittleEndian.PutUint32(buf[i*4:(i+1)*4], uint32(dataStart)) + dataStart += len(r) + } + pos := offsetsSize + for _, r := range reqs { + copy(buf[pos:], r) + pos += len(r) + } + return buf +} + +func decodeExecutionRequestsSSZ(buf []byte) ([]hexutil.Bytes, error) { + if len(buf) == 0 { + return nil, nil + } + if len(buf) < 4 { + return nil, fmt.Errorf("execution requests SSZ: buffer too short") + } + firstOffset := binary.LittleEndian.Uint32(buf[0:4]) + if firstOffset%4 != 0 || firstOffset > uint32(len(buf)) { + return nil, fmt.Errorf("execution requests SSZ: invalid first offset") + } + count := firstOffset / 4 + offsets := make([]uint32, count) + for i := uint32(0); i < count; i++ { + offsets[i] = binary.LittleEndian.Uint32(buf[i*4 : (i+1)*4]) + } + reqs := make([]hexutil.Bytes, count) + for i := uint32(0); i < count; i++ { + start := offsets[i] + var end uint32 + if i+1 < count { + end = offsets[i+1] + } else { + end = uint32(len(buf)) + } + if start > uint32(len(buf)) || end > uint32(len(buf)) || start > end { + return nil, fmt.Errorf("execution requests SSZ: invalid offset at index %d", i) + } + r := make(hexutil.Bytes, end-start) + copy(r, buf[start:end]) + reqs[i] = r + } + return reqs, nil +} + +// encodeStructuredExecutionRequestsSSZ encodes execution requests as a structured SSZ Container +// that Prysm can UnmarshalSSZ. The container has 3 offsets (deposits, withdrawals, consolidations) +// followed by the raw SSZ data for each list. +// +// Container layout: +// +// Fixed: deposits_offset(4) + withdrawals_offset(4) + consolidations_offset(4) = 12 bytes +// Variable: deposits_ssz + withdrawals_ssz + consolidations_ssz +// +// The input flat format is []hexutil.Bytes where each item is: type_byte + ssz_data +func encodeStructuredExecutionRequestsSSZ(reqs []hexutil.Bytes) []byte { + var depositsData, withdrawalsData, consolidationsData []byte + + for _, r := range reqs { + if len(r) < 1 { + continue + } + switch r[0] { + case 0x00: // deposits + depositsData = append(depositsData, r[1:]...) + case 0x01: // withdrawals + withdrawalsData = append(withdrawalsData, r[1:]...) + case 0x02: // consolidations + consolidationsData = append(consolidationsData, r[1:]...) + } + } + + fixedSize := 12 // 3 offsets * 4 bytes + totalVar := len(depositsData) + len(withdrawalsData) + len(consolidationsData) + buf := make([]byte, fixedSize+totalVar) + + depositsOffset := fixedSize + withdrawalsOffset := depositsOffset + len(depositsData) + consolidationsOffset := withdrawalsOffset + len(withdrawalsData) + + binary.LittleEndian.PutUint32(buf[0:4], uint32(depositsOffset)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(withdrawalsOffset)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(consolidationsOffset)) + + copy(buf[depositsOffset:], depositsData) + copy(buf[withdrawalsOffset:], withdrawalsData) + copy(buf[consolidationsOffset:], consolidationsData) + + return buf +} + +// decodeStructuredExecutionRequestsSSZ decodes a structured SSZ Container of execution requests +// into the flat format used by Erigon ([]hexutil.Bytes where each item is type_byte + ssz_data). +func decodeStructuredExecutionRequestsSSZ(buf []byte) ([]hexutil.Bytes, error) { + if len(buf) == 0 { + return []hexutil.Bytes{}, nil + } + if len(buf) < 12 { + return nil, fmt.Errorf("structured execution requests SSZ: buffer too short (%d < 12)", len(buf)) + } + + depositsOffset := binary.LittleEndian.Uint32(buf[0:4]) + withdrawalsOffset := binary.LittleEndian.Uint32(buf[4:8]) + consolidationsOffset := binary.LittleEndian.Uint32(buf[8:12]) + + if depositsOffset > uint32(len(buf)) || withdrawalsOffset > uint32(len(buf)) || consolidationsOffset > uint32(len(buf)) { + return nil, fmt.Errorf("structured execution requests SSZ: offsets out of bounds") + } + if depositsOffset > withdrawalsOffset || withdrawalsOffset > consolidationsOffset { + return nil, fmt.Errorf("structured execution requests SSZ: offsets not in order") + } + + // Always return non-nil slice (engine requires non-nil for V4+ even if empty). + reqs := make([]hexutil.Bytes, 0, 3) + + // Deposits (type 0x00) + depositsData := buf[depositsOffset:withdrawalsOffset] + if len(depositsData) > 0 { + r := make(hexutil.Bytes, 1+len(depositsData)) + r[0] = 0x00 + copy(r[1:], depositsData) + reqs = append(reqs, r) + } + + // Withdrawals (type 0x01) + withdrawalsData := buf[withdrawalsOffset:consolidationsOffset] + if len(withdrawalsData) > 0 { + r := make(hexutil.Bytes, 1+len(withdrawalsData)) + r[0] = 0x01 + copy(r[1:], withdrawalsData) + reqs = append(reqs, r) + } + + // Consolidations (type 0x02) + consolidationsData := buf[consolidationsOffset:] + if len(consolidationsData) > 0 { + r := make(hexutil.Bytes, 1+len(consolidationsData)) + r[0] = 0x02 + copy(r[1:], consolidationsData) + reqs = append(reqs, r) + } + + return reqs, nil +} + +// --- GetPayload response SSZ encoding --- +// +// V1: The response body is just the SSZ-encoded ExecutionPayload. +// +// V2+ GetPayloadResponse SSZ container: +// Fixed part: ep_offset(4) + block_value(32) + blobs_bundle_offset(4) + +// should_override_builder(1) + requests_offset(4) = 45 bytes +// Variable: ExecutionPayload, BlobsBundle, ExecutionRequests + +const getPayloadResponseFixedSize = 45 + +// EncodeGetPayloadResponseSSZ encodes a GetPayloadResponse to SSZ. +func EncodeGetPayloadResponseSSZ(resp *GetPayloadResponse, version int) []byte { + if version == 1 { + return EncodeExecutionPayloadSSZ(resp.ExecutionPayload, 1) + } + + payloadVersion := engineVersionToPayloadVersion(version) + epBytes := EncodeExecutionPayloadSSZ(resp.ExecutionPayload, payloadVersion) + blobsBytes := encodeBlobsBundleSSZ(resp.BlobsBundle) + reqBytes := encodeStructuredExecutionRequestsSSZ(resp.ExecutionRequests) + + buf := make([]byte, getPayloadResponseFixedSize+len(epBytes)+len(blobsBytes)+len(reqBytes)) + + // ep offset + binary.LittleEndian.PutUint32(buf[0:4], uint32(getPayloadResponseFixedSize)) + + // block_value (uint256 LE) + if resp.BlockValue != nil { + copy(buf[4:36], uint256ToSSZBytes(resp.BlockValue.ToInt())) + } + + // blobs_bundle offset + blobsOffset := getPayloadResponseFixedSize + len(epBytes) + binary.LittleEndian.PutUint32(buf[36:40], uint32(blobsOffset)) + + // should_override_builder + if resp.ShouldOverrideBuilder { + buf[40] = 1 + } + + // execution_requests offset + reqOffset := blobsOffset + len(blobsBytes) + binary.LittleEndian.PutUint32(buf[41:45], uint32(reqOffset)) + + // Variable data + copy(buf[getPayloadResponseFixedSize:], epBytes) + copy(buf[blobsOffset:], blobsBytes) + copy(buf[reqOffset:], reqBytes) + + return buf +} + +// DecodeGetPayloadResponseSSZ decodes SSZ bytes into a GetPayloadResponse. +func DecodeGetPayloadResponseSSZ(buf []byte, version int) (*GetPayloadResponse, error) { + if version == 1 { + ep, err := DecodeExecutionPayloadSSZ(buf, 1) + if err != nil { + return nil, err + } + return &GetPayloadResponse{ExecutionPayload: ep}, nil + } + + if len(buf) < getPayloadResponseFixedSize { + return nil, fmt.Errorf("GetPayloadResponse SSZ: buffer too short (%d < %d)", len(buf), getPayloadResponseFixedSize) + } + + resp := &GetPayloadResponse{} + + epOffset := binary.LittleEndian.Uint32(buf[0:4]) + blockValue := sszBytesToUint256(buf[4:36]) + resp.BlockValue = (*hexutil.Big)(blockValue) + blobsOffset := binary.LittleEndian.Uint32(buf[36:40]) + resp.ShouldOverrideBuilder = buf[40] == 1 + reqOffset := binary.LittleEndian.Uint32(buf[41:45]) + + // Decode ExecutionPayload + if epOffset > uint32(len(buf)) || blobsOffset > uint32(len(buf)) { + return nil, fmt.Errorf("GetPayloadResponse SSZ: offsets out of bounds") + } + payloadVersion := engineVersionToPayloadVersion(version) + ep, err := DecodeExecutionPayloadSSZ(buf[epOffset:blobsOffset], payloadVersion) + if err != nil { + return nil, err + } + resp.ExecutionPayload = ep + + // Decode BlobsBundle + if blobsOffset > reqOffset || reqOffset > uint32(len(buf)) { + return nil, fmt.Errorf("GetPayloadResponse SSZ: invalid blobs/requests offsets") + } + bundle, err := decodeBlobsBundleSSZ(buf[blobsOffset:reqOffset]) + if err != nil { + return nil, err + } + resp.BlobsBundle = bundle + + // Decode ExecutionRequests + if reqOffset < uint32(len(buf)) { + reqs, err := decodeStructuredExecutionRequestsSSZ(buf[reqOffset:]) + if err != nil { + return nil, err + } + resp.ExecutionRequests = reqs + } + + return resp, nil +} + +// --- BlobsBundle SSZ encoding --- +// +// SSZ container: +// Fixed part: commitments_offset(4) + proofs_offset(4) + blobs_offset(4) = 12 bytes +// Variable: commitments (N*48), proofs (N*48), blobs (N*131072) + +const blobsBundleFixedSize = 12 + +func encodeBlobsBundleSSZ(bundle *BlobsBundle) []byte { + if bundle == nil { + return nil + } + + commitmentsData := encodeFixedSizeList(bundle.Commitments) + proofsData := encodeFixedSizeList(bundle.Proofs) + blobsData := encodeFixedSizeList(bundle.Blobs) + + totalVar := len(commitmentsData) + len(proofsData) + len(blobsData) + buf := make([]byte, blobsBundleFixedSize+totalVar) + + commitmentsOffset := blobsBundleFixedSize + proofsOffset := commitmentsOffset + len(commitmentsData) + blobsOffset := proofsOffset + len(proofsData) + + binary.LittleEndian.PutUint32(buf[0:4], uint32(commitmentsOffset)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(proofsOffset)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(blobsOffset)) + + copy(buf[commitmentsOffset:], commitmentsData) + copy(buf[proofsOffset:], proofsData) + copy(buf[blobsOffset:], blobsData) + + return buf +} + +func decodeBlobsBundleSSZ(buf []byte) (*BlobsBundle, error) { + if len(buf) == 0 { + return nil, nil + } + if len(buf) < blobsBundleFixedSize { + return nil, fmt.Errorf("BlobsBundle SSZ: buffer too short") + } + + commitmentsOffset := binary.LittleEndian.Uint32(buf[0:4]) + proofsOffset := binary.LittleEndian.Uint32(buf[4:8]) + blobsOffset := binary.LittleEndian.Uint32(buf[8:12]) + + if commitmentsOffset > uint32(len(buf)) || proofsOffset > uint32(len(buf)) || blobsOffset > uint32(len(buf)) { + return nil, fmt.Errorf("BlobsBundle SSZ: offsets out of bounds") + } + + bundle := &BlobsBundle{} + + // Commitments (each 48 bytes) + commBuf := buf[commitmentsOffset:proofsOffset] + if len(commBuf) > 0 { + if len(commBuf)%48 != 0 { + return nil, fmt.Errorf("BlobsBundle SSZ: commitments not aligned to 48 bytes") + } + bundle.Commitments = make([]hexutil.Bytes, len(commBuf)/48) + for i := range bundle.Commitments { + c := make(hexutil.Bytes, 48) + copy(c, commBuf[i*48:(i+1)*48]) + bundle.Commitments[i] = c + } + } + + // Proofs (each 48 bytes) + proofBuf := buf[proofsOffset:blobsOffset] + if len(proofBuf) > 0 { + if len(proofBuf)%48 != 0 { + return nil, fmt.Errorf("BlobsBundle SSZ: proofs not aligned to 48 bytes") + } + bundle.Proofs = make([]hexutil.Bytes, len(proofBuf)/48) + for i := range bundle.Proofs { + p := make(hexutil.Bytes, 48) + copy(p, proofBuf[i*48:(i+1)*48]) + bundle.Proofs[i] = p + } + } + + // Blobs (each 131072 bytes) + blobBuf := buf[blobsOffset:] + if len(blobBuf) > 0 { + if len(blobBuf)%131072 != 0 { + return nil, fmt.Errorf("BlobsBundle SSZ: blobs not aligned to 131072 bytes") + } + bundle.Blobs = make([]hexutil.Bytes, len(blobBuf)/131072) + for i := range bundle.Blobs { + b := make(hexutil.Bytes, 131072) + copy(b, blobBuf[i*131072:(i+1)*131072]) + bundle.Blobs[i] = b + } + } + + return bundle, nil +} + +// encodeFixedSizeList concatenates a list of byte slices. +func encodeFixedSizeList(items []hexutil.Bytes) []byte { + totalLen := 0 + for _, item := range items { + totalLen += len(item) + } + buf := make([]byte, totalLen) + pos := 0 + for _, item := range items { + copy(buf[pos:], item) + pos += len(item) + } + return buf +} + +// EncodeGetBlobsRequest encodes a list of versioned hashes for the get_blobs SSZ request. +func EncodeGetBlobsRequest(hashes []common.Hash) []byte { + buf := make([]byte, 4+len(hashes)*32) + binary.LittleEndian.PutUint32(buf[0:4], uint32(len(hashes))) + for i, h := range hashes { + copy(buf[4+i*32:4+(i+1)*32], h[:]) + } + return buf +} + +// DecodeGetBlobsRequest decodes a list of versioned hashes from SSZ bytes. +func DecodeGetBlobsRequest(buf []byte) ([]common.Hash, error) { + if len(buf) < 4 { + return nil, fmt.Errorf("GetBlobsRequest: buffer too short") + } + count := binary.LittleEndian.Uint32(buf[0:4]) + if 4+count*32 > uint32(len(buf)) { + return nil, fmt.Errorf("GetBlobsRequest: buffer too short for %d hashes", count) + } + hashes := make([]common.Hash, count) + for i := uint32(0); i < count; i++ { + copy(hashes[i][:], buf[4+i*32:4+(i+1)*32]) + } + return hashes, nil +} diff --git a/execution/engineapi/engine_types/ssz_test.go b/execution/engineapi/engine_types/ssz_test.go new file mode 100644 index 00000000000..5c49a849987 --- /dev/null +++ b/execution/engineapi/engine_types/ssz_test.go @@ -0,0 +1,625 @@ +// Copyright 2025 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package engine_types + +import ( + "math/big" + "testing" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/types" + "github.com/stretchr/testify/require" +) + +func TestPayloadStatusSSZRoundTrip(t *testing.T) { + req := require.New(t) + + // Test with all fields set + hash := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + ps := &PayloadStatusSSZ{ + Status: SSZStatusValid, + LatestValidHash: &hash, + ValidationError: "test error", + } + + encoded := ps.EncodeSSZ() + decoded, err := DecodePayloadStatusSSZ(encoded) + req.NoError(err) + req.Equal(ps.Status, decoded.Status) + req.NotNil(decoded.LatestValidHash) + req.Equal(*ps.LatestValidHash, *decoded.LatestValidHash) + req.Equal(ps.ValidationError, decoded.ValidationError) + + // Test with nil LatestValidHash + ps2 := &PayloadStatusSSZ{ + Status: SSZStatusSyncing, + LatestValidHash: nil, + ValidationError: "", + } + + encoded2 := ps2.EncodeSSZ() + decoded2, err := DecodePayloadStatusSSZ(encoded2) + req.NoError(err) + req.Equal(SSZStatusSyncing, decoded2.Status) + req.Nil(decoded2.LatestValidHash) + req.Empty(decoded2.ValidationError) +} + +func TestPayloadStatusConversion(t *testing.T) { + req := require.New(t) + + hash := common.HexToHash("0xabcdef") + ps := &PayloadStatus{ + Status: ValidStatus, + LatestValidHash: &hash, + ValidationError: NewStringifiedErrorFromString("block invalid"), + } + + ssz := PayloadStatusToSSZ(ps) + req.Equal(SSZStatusValid, ssz.Status) + req.Equal(hash, *ssz.LatestValidHash) + req.Equal("block invalid", ssz.ValidationError) + + back := ssz.ToPayloadStatus() + req.Equal(ValidStatus, back.Status) + req.Equal(hash, *back.LatestValidHash) + req.NotNil(back.ValidationError) + req.Equal("block invalid", back.ValidationError.Error().Error()) +} + +func TestEngineStatusSSZConversion(t *testing.T) { + req := require.New(t) + + tests := []struct { + status EngineStatus + sszValue uint8 + }{ + {ValidStatus, SSZStatusValid}, + {InvalidStatus, SSZStatusInvalid}, + {SyncingStatus, SSZStatusSyncing}, + {AcceptedStatus, SSZStatusAccepted}, + {InvalidBlockHashStatus, SSZStatusInvalidBlockHash}, + } + + for _, tt := range tests { + req.Equal(tt.sszValue, EngineStatusToSSZ(tt.status), "EngineStatusToSSZ(%s)", tt.status) + req.Equal(tt.status, SSZToEngineStatus(tt.sszValue), "SSZToEngineStatus(%d)", tt.sszValue) + } +} + +func TestForkchoiceStateRoundTrip(t *testing.T) { + req := require.New(t) + + fcs := &ForkChoiceState{ + HeadHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + SafeBlockHash: common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222"), + FinalizedBlockHash: common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"), + } + + encoded := EncodeForkchoiceState(fcs) + req.Len(encoded, 96) + + decoded, err := DecodeForkchoiceState(encoded) + req.NoError(err) + req.Equal(fcs.HeadHash, decoded.HeadHash) + req.Equal(fcs.SafeBlockHash, decoded.SafeBlockHash) + req.Equal(fcs.FinalizedBlockHash, decoded.FinalizedBlockHash) +} + +func TestForkchoiceStateDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeForkchoiceState(make([]byte, 50)) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +func TestCommunicationChannelsRoundTrip(t *testing.T) { + req := require.New(t) + + channels := []CommunicationChannel{ + {Protocol: "json_rpc", URL: "localhost:8551"}, + {Protocol: "ssz_rest", URL: "localhost:8552"}, + } + + encoded := EncodeCommunicationChannels(channels) + decoded, err := DecodeCommunicationChannels(encoded) + req.NoError(err) + req.Len(decoded, 2) + req.Equal("json_rpc", decoded[0].Protocol) + req.Equal("localhost:8551", decoded[0].URL) + req.Equal("ssz_rest", decoded[1].Protocol) + req.Equal("localhost:8552", decoded[1].URL) +} + +func TestCommunicationChannelsEmpty(t *testing.T) { + req := require.New(t) + + encoded := EncodeCommunicationChannels(nil) + req.Empty(encoded) +} + +func TestCapabilitiesRoundTrip(t *testing.T) { + req := require.New(t) + + caps := []string{ + "engine_newPayloadV4", + "engine_forkchoiceUpdatedV3", + "engine_getPayloadV4", + } + + encoded := EncodeCapabilities(caps) + decoded, err := DecodeCapabilities(encoded) + req.NoError(err) + req.Equal(caps, decoded) +} + +func TestClientVersionRoundTrip(t *testing.T) { + req := require.New(t) + + cv := &ClientVersionV1{ + Code: "EG", + Name: "Erigon", + Version: "3.0.0", + Commit: "0xdeadbeef", + } + + encoded := EncodeClientVersion(cv) + decoded, err := DecodeClientVersion(encoded) + req.NoError(err) + req.Equal(cv.Code, decoded.Code) + req.Equal(cv.Name, decoded.Name) + req.Equal(cv.Version, decoded.Version) + req.Equal(cv.Commit, decoded.Commit) +} + +func TestClientVersionsRoundTrip(t *testing.T) { + req := require.New(t) + + versions := []ClientVersionV1{ + {Code: "EG", Name: "Erigon", Version: "3.0.0", Commit: "0xdeadbeef"}, + {Code: "GE", Name: "Geth", Version: "1.14.0", Commit: "0xabcdef01"}, + } + + encoded := EncodeClientVersions(versions) + decoded, err := DecodeClientVersions(encoded) + req.NoError(err) + req.Len(decoded, 2) + req.Equal(versions[0].Code, decoded[0].Code) + req.Equal(versions[0].Name, decoded[0].Name) + req.Equal(versions[1].Code, decoded[1].Code) + req.Equal(versions[1].Version, decoded[1].Version) +} + +func TestGetBlobsRequestRoundTrip(t *testing.T) { + req := require.New(t) + + hashes := []common.Hash{ + common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222"), + common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"), + } + + encoded := EncodeGetBlobsRequest(hashes) + decoded, err := DecodeGetBlobsRequest(encoded) + req.NoError(err) + req.Len(decoded, 3) + for i := range hashes { + req.Equal(hashes[i], decoded[i]) + } +} + +func TestGetBlobsRequestEmpty(t *testing.T) { + req := require.New(t) + + encoded := EncodeGetBlobsRequest(nil) + decoded, err := DecodeGetBlobsRequest(encoded) + req.NoError(err) + req.Empty(decoded) +} + +func TestPayloadStatusSSZDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodePayloadStatusSSZ(make([]byte, 5)) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +func TestCapabilitiesDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeCapabilities(make([]byte, 2)) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +func TestClientVersionDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeClientVersion(make([]byte, 4)) + req.Error(err) +} + +func TestGetBlobsRequestDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeGetBlobsRequest(make([]byte, 2)) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +// --- ForkchoiceUpdatedResponse round-trip tests (verifies offset bug fix) --- + +func TestForkchoiceUpdatedResponseRoundTrip(t *testing.T) { + req := require.New(t) + + // Test with no validation error and no payload ID + hash := common.HexToHash("0xabcdef") + ps := &PayloadStatus{ + Status: ValidStatus, + LatestValidHash: &hash, + } + resp := &ForkChoiceUpdatedResponse{ + PayloadStatus: ps, + PayloadId: nil, + } + + encoded := EncodeForkchoiceUpdatedResponse(resp) + decoded, err := DecodeForkchoiceUpdatedResponse(encoded) + req.NoError(err) + req.Equal(SSZStatusValid, decoded.PayloadStatus.Status) + req.Equal(hash, *decoded.PayloadStatus.LatestValidHash) + req.Empty(decoded.PayloadStatus.ValidationError) + req.Nil(decoded.PayloadId) +} + +func TestForkchoiceUpdatedResponseWithPayloadId(t *testing.T) { + req := require.New(t) + + hash := common.HexToHash("0x1234") + pidBytes := make(hexutil.Bytes, 8) + pidBytes[0] = 0x00 + pidBytes[1] = 0x00 + pidBytes[2] = 0x00 + pidBytes[3] = 0x00 + pidBytes[4] = 0x00 + pidBytes[5] = 0x00 + pidBytes[6] = 0x00 + pidBytes[7] = 0x42 + ps := &PayloadStatus{ + Status: SyncingStatus, + LatestValidHash: &hash, + } + resp := &ForkChoiceUpdatedResponse{ + PayloadStatus: ps, + PayloadId: &pidBytes, + } + + encoded := EncodeForkchoiceUpdatedResponse(resp) + decoded, err := DecodeForkchoiceUpdatedResponse(encoded) + req.NoError(err) + req.Equal(SSZStatusSyncing, decoded.PayloadStatus.Status) + req.NotNil(decoded.PayloadId) + req.Equal(uint64(0x42), *decoded.PayloadId) +} + +func TestForkchoiceUpdatedResponseWithValidationError(t *testing.T) { + req := require.New(t) + + // This is the key test for the byte offset bug fix: + // When PayloadStatus has a validation error (variable-length), + // the payload_id must still be decoded correctly. + hash := common.HexToHash("0xdeadbeef") + pidBytes := make(hexutil.Bytes, 8) + pidBytes[7] = 0xFF + ps := &PayloadStatus{ + Status: InvalidStatus, + LatestValidHash: &hash, + ValidationError: NewStringifiedErrorFromString("block gas limit exceeded by a very long error message that makes the buffer larger"), + } + resp := &ForkChoiceUpdatedResponse{ + PayloadStatus: ps, + PayloadId: &pidBytes, + } + + encoded := EncodeForkchoiceUpdatedResponse(resp) + decoded, err := DecodeForkchoiceUpdatedResponse(encoded) + req.NoError(err) + req.Equal(SSZStatusInvalid, decoded.PayloadStatus.Status) + req.Equal(hash, *decoded.PayloadStatus.LatestValidHash) + req.Equal("block gas limit exceeded by a very long error message that makes the buffer larger", decoded.PayloadStatus.ValidationError) + req.NotNil(decoded.PayloadId) + req.Equal(uint64(0xFF), *decoded.PayloadId) +} + +func TestForkchoiceUpdatedResponseShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeForkchoiceUpdatedResponse(make([]byte, 4)) + req.Error(err) +} + +// --- ExecutionPayload SSZ round-trip tests --- + +func makeTestExecutionPayloadV1() *ExecutionPayload { + baseFee := big.NewInt(1000000000) // 1 gwei + return &ExecutionPayload{ + ParentHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + FeeRecipient: common.HexToAddress("0x2222222222222222222222222222222222222222"), + StateRoot: common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333"), + ReceiptsRoot: common.HexToHash("0x4444444444444444444444444444444444444444444444444444444444444444"), + LogsBloom: make(hexutil.Bytes, 256), + PrevRandao: common.HexToHash("0x5555555555555555555555555555555555555555555555555555555555555555"), + BlockNumber: hexutil.Uint64(100), + GasLimit: hexutil.Uint64(30000000), + GasUsed: hexutil.Uint64(21000), + Timestamp: hexutil.Uint64(1700000000), + ExtraData: hexutil.Bytes{0x01, 0x02, 0x03}, + BaseFeePerGas: (*hexutil.Big)(baseFee), + BlockHash: common.HexToHash("0x6666666666666666666666666666666666666666666666666666666666666666"), + Transactions: []hexutil.Bytes{ + {0xf8, 0x50, 0x80, 0x01, 0x82, 0x52, 0x08}, + {0xf8, 0x60, 0x80, 0x02, 0x83, 0x01, 0x00, 0x00}, + }, + } +} + +func TestExecutionPayloadV1RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + + encoded := EncodeExecutionPayloadSSZ(ep, 1) + decoded, err := DecodeExecutionPayloadSSZ(encoded, 1) + req.NoError(err) + + req.Equal(ep.ParentHash, decoded.ParentHash) + req.Equal(ep.FeeRecipient, decoded.FeeRecipient) + req.Equal(ep.StateRoot, decoded.StateRoot) + req.Equal(ep.ReceiptsRoot, decoded.ReceiptsRoot) + req.Equal(ep.PrevRandao, decoded.PrevRandao) + req.Equal(ep.BlockNumber, decoded.BlockNumber) + req.Equal(ep.GasLimit, decoded.GasLimit) + req.Equal(ep.GasUsed, decoded.GasUsed) + req.Equal(ep.Timestamp, decoded.Timestamp) + req.Equal([]byte(ep.ExtraData), []byte(decoded.ExtraData)) + req.Equal(ep.BaseFeePerGas.ToInt().String(), decoded.BaseFeePerGas.ToInt().String()) + req.Equal(ep.BlockHash, decoded.BlockHash) + req.Len(decoded.Transactions, 2) + req.Equal([]byte(ep.Transactions[0]), []byte(decoded.Transactions[0])) + req.Equal([]byte(ep.Transactions[1]), []byte(decoded.Transactions[1])) +} + +func TestExecutionPayloadV2RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + ep.Withdrawals = []*types.Withdrawal{ + {Index: 1, Validator: 100, Address: common.HexToAddress("0xaaaa"), Amount: 32000000000}, + {Index: 2, Validator: 200, Address: common.HexToAddress("0xbbbb"), Amount: 64000000000}, + } + + encoded := EncodeExecutionPayloadSSZ(ep, 2) + decoded, err := DecodeExecutionPayloadSSZ(encoded, 2) + req.NoError(err) + + req.Equal(ep.ParentHash, decoded.ParentHash) + req.Equal(ep.BlockHash, decoded.BlockHash) + req.Len(decoded.Transactions, 2) + req.Len(decoded.Withdrawals, 2) + req.Equal(ep.Withdrawals[0].Index, decoded.Withdrawals[0].Index) + req.Equal(ep.Withdrawals[0].Validator, decoded.Withdrawals[0].Validator) + req.Equal(ep.Withdrawals[0].Address, decoded.Withdrawals[0].Address) + req.Equal(ep.Withdrawals[0].Amount, decoded.Withdrawals[0].Amount) + req.Equal(ep.Withdrawals[1].Index, decoded.Withdrawals[1].Index) +} + +func TestExecutionPayloadV3RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + ep.Withdrawals = []*types.Withdrawal{} + blobGasUsed := hexutil.Uint64(131072) + excessBlobGas := hexutil.Uint64(262144) + ep.BlobGasUsed = &blobGasUsed + ep.ExcessBlobGas = &excessBlobGas + + encoded := EncodeExecutionPayloadSSZ(ep, 3) + decoded, err := DecodeExecutionPayloadSSZ(encoded, 3) + req.NoError(err) + + req.Equal(ep.ParentHash, decoded.ParentHash) + req.NotNil(decoded.BlobGasUsed) + req.Equal(uint64(131072), uint64(*decoded.BlobGasUsed)) + req.NotNil(decoded.ExcessBlobGas) + req.Equal(uint64(262144), uint64(*decoded.ExcessBlobGas)) +} + +func TestExecutionPayloadV3EmptyTransactions(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + ep.Transactions = []hexutil.Bytes{} + ep.Withdrawals = []*types.Withdrawal{} + blobGasUsed := hexutil.Uint64(0) + excessBlobGas := hexutil.Uint64(0) + ep.BlobGasUsed = &blobGasUsed + ep.ExcessBlobGas = &excessBlobGas + + encoded := EncodeExecutionPayloadSSZ(ep, 3) + decoded, err := DecodeExecutionPayloadSSZ(encoded, 3) + req.NoError(err) + req.Empty(decoded.Transactions) +} + +func TestExecutionPayloadSSZDecodeShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeExecutionPayloadSSZ(make([]byte, 100), 1) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +// --- NewPayload request SSZ round-trip tests --- + +func TestNewPayloadRequestV1RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + encoded := EncodeNewPayloadRequestSSZ(ep, nil, nil, nil, 1) + decodedEp, blobHashes, parentRoot, execReqs, err := DecodeNewPayloadRequestSSZ(encoded, 1) + req.NoError(err) + req.Nil(blobHashes) + req.Nil(parentRoot) + req.Nil(execReqs) + req.Equal(ep.BlockHash, decodedEp.BlockHash) + req.Len(decodedEp.Transactions, 2) +} + +func TestNewPayloadRequestV3RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + ep.Withdrawals = []*types.Withdrawal{} + blobGasUsed := hexutil.Uint64(0) + excessBlobGas := hexutil.Uint64(0) + ep.BlobGasUsed = &blobGasUsed + ep.ExcessBlobGas = &excessBlobGas + + hashes := []common.Hash{ + common.HexToHash("0xaaaa"), + common.HexToHash("0xbbbb"), + } + root := common.HexToHash("0xcccc") + + encoded := EncodeNewPayloadRequestSSZ(ep, hashes, &root, nil, 3) + decodedEp, decodedHashes, decodedRoot, _, err := DecodeNewPayloadRequestSSZ(encoded, 3) + req.NoError(err) + req.Equal(ep.BlockHash, decodedEp.BlockHash) + req.Len(decodedHashes, 2) + req.Equal(hashes[0], decodedHashes[0]) + req.Equal(hashes[1], decodedHashes[1]) + req.Equal(root, *decodedRoot) +} + +func TestNewPayloadRequestV4RoundTrip(t *testing.T) { + req := require.New(t) + + // V4 = Electra, which uses Deneb payload layout (version 3) + // No SlotNumber or BlockAccessList + ep := makeTestExecutionPayloadV1() + ep.Withdrawals = []*types.Withdrawal{} + blobGasUsed := hexutil.Uint64(0) + excessBlobGas := hexutil.Uint64(0) + ep.BlobGasUsed = &blobGasUsed + ep.ExcessBlobGas = &excessBlobGas + + hashes := []common.Hash{common.HexToHash("0xdddd")} + root := common.HexToHash("0xeeee") + execReqs := []hexutil.Bytes{ + {0x00, 0x01, 0x02, 0x03}, + {0x01, 0x04, 0x05}, + } + + encoded := EncodeNewPayloadRequestSSZ(ep, hashes, &root, execReqs, 4) + decodedEp, decodedHashes, decodedRoot, decodedReqs, err := DecodeNewPayloadRequestSSZ(encoded, 4) + req.NoError(err) + req.Equal(ep.BlockHash, decodedEp.BlockHash) + req.Len(decodedHashes, 1) + req.Equal(hashes[0], decodedHashes[0]) + req.Equal(root, *decodedRoot) + req.Len(decodedReqs, 2) + req.Equal([]byte(execReqs[0]), []byte(decodedReqs[0])) + req.Equal([]byte(execReqs[1]), []byte(decodedReqs[1])) +} + +// --- GetPayload response SSZ round-trip tests --- + +func TestGetPayloadResponseV1RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + resp := &GetPayloadResponse{ExecutionPayload: ep} + + encoded := EncodeGetPayloadResponseSSZ(resp, 1) + decoded, err := DecodeGetPayloadResponseSSZ(encoded, 1) + req.NoError(err) + req.Equal(ep.BlockHash, decoded.ExecutionPayload.BlockHash) + req.Len(decoded.ExecutionPayload.Transactions, 2) +} + +func TestGetPayloadResponseV3RoundTrip(t *testing.T) { + req := require.New(t) + + ep := makeTestExecutionPayloadV1() + ep.Withdrawals = []*types.Withdrawal{} + blobGasUsed := hexutil.Uint64(131072) + excessBlobGas := hexutil.Uint64(0) + ep.BlobGasUsed = &blobGasUsed + ep.ExcessBlobGas = &excessBlobGas + + blockValue := big.NewInt(1234567890) + resp := &GetPayloadResponse{ + ExecutionPayload: ep, + BlockValue: (*hexutil.Big)(blockValue), + BlobsBundle: &BlobsBundle{}, + ShouldOverrideBuilder: true, + } + + encoded := EncodeGetPayloadResponseSSZ(resp, 3) + decoded, err := DecodeGetPayloadResponseSSZ(encoded, 3) + req.NoError(err) + req.Equal(ep.BlockHash, decoded.ExecutionPayload.BlockHash) + req.Equal(blockValue.String(), decoded.BlockValue.ToInt().String()) + req.True(decoded.ShouldOverrideBuilder) +} + +func TestGetPayloadResponseShortBuffer(t *testing.T) { + req := require.New(t) + + _, err := DecodeGetPayloadResponseSSZ(make([]byte, 10), 2) + req.Error(err) + req.Contains(err.Error(), "buffer too short") +} + +// --- uint256 SSZ conversion tests --- + +func TestUint256SSZRoundTrip(t *testing.T) { + req := require.New(t) + + tests := []*big.Int{ + big.NewInt(0), + big.NewInt(1), + big.NewInt(1000000000), + new(big.Int).SetBytes(common.Hex2Bytes("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")), + } + + for _, val := range tests { + encoded := uint256ToSSZBytes(val) + req.Len(encoded, 32) + decoded := sszBytesToUint256(encoded) + req.Equal(val.String(), decoded.String(), "round-trip failed for %s", val.String()) + } + + // Test nil + encoded := uint256ToSSZBytes(nil) + req.Len(encoded, 32) + decoded := sszBytesToUint256(encoded) + req.Equal("0", decoded.String()) +} diff --git a/execution/engineapi/interface.go b/execution/engineapi/interface.go index 28bad397ec3..a14cd982d4d 100644 --- a/execution/engineapi/interface.go +++ b/execution/engineapi/interface.go @@ -46,4 +46,5 @@ type EngineAPI interface { GetBlobsV1(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV1, error) GetBlobsV2(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV2, error) GetBlobsV3(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV2, error) + GetClientCommunicationChannelsV1(ctx context.Context) ([]engine_types.CommunicationChannel, error) } diff --git a/node/cli/default_flags.go b/node/cli/default_flags.go index a72e8504973..ba63ad7e223 100644 --- a/node/cli/default_flags.go +++ b/node/cli/default_flags.go @@ -69,6 +69,8 @@ var DefaultFlags = []cli.Flag{ &utils.AuthRpcAddr, &utils.AuthRpcPort, &utils.JWTSecretPath, + &utils.SszRestEnabledFlag, + &utils.SszRestPortFlag, &utils.HttpCompressionFlag, &utils.HTTPCORSDomainFlag, &utils.HTTPVirtualHostsFlag, diff --git a/node/cli/flags.go b/node/cli/flags.go index 962734196a8..2ba713b8641 100644 --- a/node/cli/flags.go +++ b/node/cli/flags.go @@ -422,6 +422,8 @@ func setEmbeddedRpcDaemon(ctx *cli.Context, cfg *nodecfg.Config, logger log.Logg AuthRpcHTTPListenAddress: ctx.String(utils.AuthRpcAddr.Name), AuthRpcPort: ctx.Int(utils.AuthRpcPort.Name), JWTSecretPath: jwtSecretPath, + SszRestEnabled: ctx.Bool(utils.SszRestEnabledFlag.Name), + SszRestPort: ctx.Int(utils.SszRestPortFlag.Name), TraceRequests: ctx.Bool(utils.HTTPTraceFlag.Name), DebugSingleRequest: ctx.Bool(utils.HTTPDebugSingleFlag.Name), HttpCORSDomain: common.CliString2Array(ctx.String(utils.HTTPCORSDomainFlag.Name)), From f1bba864e9bbd0660f2d9cfb296950b2d08d9162 Mon Sep 17 00:00:00 2001 From: Giulio Date: Mon, 2 Mar 2026 00:30:25 +0100 Subject: [PATCH 2/2] eip-8160: add engine_exchangeCapabilitiesV2 with supportedProtocols Implements the updated EIP-8160 spec where communication channels are returned as part of engine_exchangeCapabilitiesV2 instead of a separate engine_getClientCommunicationChannelsV1 method. The V2 response includes both capabilities and supportedProtocols fields. The old GetClientCommunicationChannelsV1 method is kept for backward compatibility but now delegates to the shared getSupportedProtocols(). Co-Authored-By: Claude Opus 4.6 --- .../engineapi/engine_api_jsonrpc_client.go | 5 ++ execution/engineapi/engine_api_methods.go | 72 +++++++++++-------- execution/engineapi/engine_ssz_rest_server.go | 39 +++++++++- execution/engineapi/engine_types/jsonrpc.go | 8 ++- execution/engineapi/interface.go | 1 + 5 files changed, 94 insertions(+), 31 deletions(-) diff --git a/execution/engineapi/engine_api_jsonrpc_client.go b/execution/engineapi/engine_api_jsonrpc_client.go index dcf4b3373df..759eec04cbe 100644 --- a/execution/engineapi/engine_api_jsonrpc_client.go +++ b/execution/engineapi/engine_api_jsonrpc_client.go @@ -407,6 +407,11 @@ func (c *JsonRpcClient) GetClientCommunicationChannelsV1(ctx context.Context) ([ }, c.backOff(ctx)) } +func (c *JsonRpcClient) ExchangeCapabilitiesV2(fromCl []string) *enginetypes.ExchangeCapabilitiesV2Response { + // JSON-RPC client doesn't implement V2 directly; the server-side handles this. + return nil +} + func (c *JsonRpcClient) backOff(ctx context.Context) backoff.BackOff { var backOff backoff.BackOff backOff = backoff.NewConstantBackOff(c.retryBackOff) diff --git a/execution/engineapi/engine_api_methods.go b/execution/engineapi/engine_api_methods.go index 7aa0065e289..bfd580ec3ec 100644 --- a/execution/engineapi/engine_api_methods.go +++ b/execution/engineapi/engine_api_methods.go @@ -55,6 +55,7 @@ var ourCapabilities = []string{ "engine_getBlobsV2", "engine_getBlobsV3", "engine_getClientCommunicationChannelsV1", + "engine_exchangeCapabilitiesV2", } // Returns the most recent version of the payload(for the payloadID) at the time of receiving the call @@ -248,6 +249,46 @@ func (e *EngineServer) ExchangeCapabilities(fromCl []string) []string { return ourCapabilities } +// ExchangeCapabilitiesV2 extends ExchangeCapabilities with supportedProtocols (EIP-8160). +func (e *EngineServer) ExchangeCapabilitiesV2(fromCl []string) *engine_types.ExchangeCapabilitiesV2Response { + capabilities := e.ExchangeCapabilities(fromCl) + return &engine_types.ExchangeCapabilitiesV2Response{ + Capabilities: capabilities, + SupportedProtocols: e.getSupportedProtocols(), + } +} + +// getSupportedProtocols returns the list of communication protocols supported by the EL. +func (e *EngineServer) getSupportedProtocols() []engine_types.CommunicationChannel { + addr := "localhost" + port := 8551 + if e.httpConfig != nil { + if e.httpConfig.AuthRpcHTTPListenAddress != "" { + addr = e.httpConfig.AuthRpcHTTPListenAddress + } + if e.httpConfig.AuthRpcPort != 0 { + port = e.httpConfig.AuthRpcPort + } + } + + channels := []engine_types.CommunicationChannel{ + { + Protocol: "json_rpc", + URL: fmt.Sprintf("%s:%d", addr, port), + }, + } + + // EIP-8161: Advertise the SSZ-REST channel if the server is running + if e.httpConfig != nil && e.httpConfig.SszRestEnabled && e.sszRestPort > 0 { + channels = append(channels, engine_types.CommunicationChannel{ + Protocol: "ssz_rest", + URL: fmt.Sprintf("http://%s:%d", addr, e.sszRestPort), + }) + } + + return channels +} + func (e *EngineServer) GetBlobsV1(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV1, error) { e.logger.Debug("[GetBlobsV1] Received Request", "hashes", len(blobHashes)) resp, err := e.getBlobs(ctx, blobHashes, clparams.DenebVersion) @@ -287,35 +328,8 @@ func (e *EngineServer) GetBlobsV3(ctx context.Context, blobHashes []common.Hash) } // GetClientCommunicationChannelsV1 returns the communication protocols and endpoints supported by the EL. -// See EIP-8160 and EIP-8161 +// Deprecated: Use ExchangeCapabilitiesV2 instead. Kept for backward compatibility. func (e *EngineServer) GetClientCommunicationChannelsV1(ctx context.Context) ([]engine_types.CommunicationChannel, error) { e.engineLogSpamer.RecordRequest() - - addr := "localhost" - port := 8551 - if e.httpConfig != nil { - if e.httpConfig.AuthRpcHTTPListenAddress != "" { - addr = e.httpConfig.AuthRpcHTTPListenAddress - } - if e.httpConfig.AuthRpcPort != 0 { - port = e.httpConfig.AuthRpcPort - } - } - - channels := []engine_types.CommunicationChannel{ - { - Protocol: "json_rpc", - URL: fmt.Sprintf("%s:%d", addr, port), - }, - } - - // EIP-8161: Advertise the SSZ-REST channel if the server is running - if e.httpConfig != nil && e.httpConfig.SszRestEnabled && e.sszRestPort > 0 { - channels = append(channels, engine_types.CommunicationChannel{ - Protocol: "ssz_rest", - URL: fmt.Sprintf("http://%s:%d", addr, e.sszRestPort), - }) - } - - return channels, nil + return e.getSupportedProtocols(), nil } diff --git a/execution/engineapi/engine_ssz_rest_server.go b/execution/engineapi/engine_ssz_rest_server.go index c7c2a6871c3..8a5c38b2a15 100644 --- a/execution/engineapi/engine_ssz_rest_server.go +++ b/execution/engineapi/engine_ssz_rest_server.go @@ -163,8 +163,11 @@ func (s *SszRestServer) registerRoutes(mux *http.ServeMux) { // getClientVersion mux.HandleFunc("POST /engine/v1/get_client_version", s.handleGetClientVersion) - // getClientCommunicationChannels + // getClientCommunicationChannels (deprecated, kept for backward compat) mux.HandleFunc("POST /engine/v1/get_client_communication_channels", s.handleGetClientCommunicationChannels) + + // exchangeCapabilitiesV2 (EIP-8160) + mux.HandleFunc("POST /engine/v2/exchange_capabilities", s.handleExchangeCapabilitiesV2) } // readBody reads the request body with a size limit. @@ -610,6 +613,40 @@ func (s *SszRestServer) handleGetClientVersion(w http.ResponseWriter, r *http.Re sszResponse(w, engine_types.EncodeClientVersions(result)) } +// --- exchangeCapabilitiesV2 handler (EIP-8160) --- + +func (s *SszRestServer) handleExchangeCapabilitiesV2(w http.ResponseWriter, r *http.Request) { + s.logger.Info("[SSZ-REST] Received ExchangeCapabilitiesV2") + + 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.ExchangeCapabilitiesV2(capabilities) + // Encode as: capabilities SSZ + communication channels SSZ appended + // For simplicity, use the same capabilities encoding followed by channels encoding. + capBuf := engine_types.EncodeCapabilities(result.Capabilities) + chanBuf := engine_types.EncodeCommunicationChannels(result.SupportedProtocols) + + // SSZ Container: capabilities_offset(4) + channels_offset(4) + capabilities_data + channels_data + fixedSize := uint32(8) + buf := make([]byte, 8+len(capBuf)+len(chanBuf)) + binary.LittleEndian.PutUint32(buf[0:4], fixedSize) + binary.LittleEndian.PutUint32(buf[4:8], fixedSize+uint32(len(capBuf))) + copy(buf[8:], capBuf) + copy(buf[8+len(capBuf):], chanBuf) + + sszResponse(w, buf) +} + // --- getClientCommunicationChannels handler --- func (s *SszRestServer) handleGetClientCommunicationChannels(w http.ResponseWriter, r *http.Request) { diff --git a/execution/engineapi/engine_types/jsonrpc.go b/execution/engineapi/engine_types/jsonrpc.go index fe6d2536037..0fac64988c5 100644 --- a/execution/engineapi/engine_types/jsonrpc.go +++ b/execution/engineapi/engine_types/jsonrpc.go @@ -137,12 +137,18 @@ type ClientVersionV1 struct { } // CommunicationChannel describes a protocol and endpoint supported by the EL. -// See EIP-8160: engine_getClientCommunicationChannelsV1 +// See EIP-8160: engine_exchangeCapabilitiesV2 type CommunicationChannel struct { Protocol string `json:"protocol" gencodec:"required"` URL string `json:"url" gencodec:"required"` } +// ExchangeCapabilitiesV2Response is the response for engine_exchangeCapabilitiesV2 (EIP-8160). +type ExchangeCapabilitiesV2Response struct { + Capabilities []string `json:"capabilities"` + SupportedProtocols []CommunicationChannel `json:"supportedProtocols"` +} + func (c ClientVersionV1) String() string { return fmt.Sprintf("ClientCode: %s, %s-%s-%s", c.Code, c.Name, c.Version, c.Commit) } diff --git a/execution/engineapi/interface.go b/execution/engineapi/interface.go index a14cd982d4d..c703d2b0c98 100644 --- a/execution/engineapi/interface.go +++ b/execution/engineapi/interface.go @@ -47,4 +47,5 @@ type EngineAPI interface { GetBlobsV2(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV2, error) GetBlobsV3(ctx context.Context, blobHashes []common.Hash) ([]*engine_types.BlobAndProofV2, error) GetClientCommunicationChannelsV1(ctx context.Context) ([]engine_types.CommunicationChannel, error) + ExchangeCapabilitiesV2(fromCl []string) *engine_types.ExchangeCapabilitiesV2Response }