diff --git a/beacon/engine/ssz.go b/beacon/engine/ssz.go
new file mode 100644
index 0000000000..a3b4cfbdec
--- /dev/null
+++ b/beacon/engine/ssz.go
@@ -0,0 +1,1284 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package engine
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math/big"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ "github.com/ethereum/go-ethereum/core/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 engine status to the SSZ uint8 representation.
+func EngineStatusToSSZ(status string) uint8 {
+ switch status {
+ case VALID:
+ return SSZStatusValid
+ case INVALID:
+ return SSZStatusInvalid
+ case SYNCING:
+ return SSZStatusSyncing
+ case ACCEPTED:
+ return SSZStatusAccepted
+ case "INVALID_BLOCK_HASH":
+ return SSZStatusInvalidBlockHash
+ default:
+ return SSZStatusInvalid
+ }
+}
+
+// SSZToEngineStatus converts an SSZ uint8 status to the string engine status.
+func SSZToEngineStatus(status uint8) string {
+ switch status {
+ case SSZStatusValid:
+ return VALID
+ case SSZStatusInvalid:
+ return INVALID
+ case SSZStatusSyncing:
+ return SYNCING
+ case SSZStatusAccepted:
+ return ACCEPTED
+ case SSZStatusInvalidBlockHash:
+ return "INVALID_BLOCK_HASH"
+ default:
+ return INVALID
+ }
+}
+
+// --- PayloadStatus SSZ ---
+
+const payloadStatusFixedSize = 9 // status(1) + hash_offset(4) + err_offset(4)
+
+// EncodePayloadStatusSSZ encodes a PayloadStatusV1 to SSZ bytes per EIP-8161.
+// Uses List[Hash32, 1] encoding: 0 bytes if nil, 32 bytes if present.
+func EncodePayloadStatusSSZ(ps *PayloadStatusV1) []byte {
+ var hashData []byte
+ if ps.LatestValidHash != nil {
+ hashData = ps.LatestValidHash[:]
+ }
+
+ var errorBytes []byte
+ if ps.ValidationError != nil {
+ errorBytes = []byte(*ps.ValidationError)
+ }
+
+ buf := make([]byte, payloadStatusFixedSize+len(hashData)+len(errorBytes))
+ buf[0] = EngineStatusToSSZ(ps.Status)
+ binary.LittleEndian.PutUint32(buf[1:5], uint32(payloadStatusFixedSize))
+ binary.LittleEndian.PutUint32(buf[5:9], uint32(payloadStatusFixedSize+len(hashData)))
+
+ copy(buf[payloadStatusFixedSize:], hashData)
+ copy(buf[payloadStatusFixedSize+len(hashData):], errorBytes)
+ return buf
+}
+
+// DecodePayloadStatusSSZ decodes SSZ bytes into a PayloadStatusV1.
+func DecodePayloadStatusSSZ(buf []byte) (*PayloadStatusV1, error) {
+ if len(buf) < payloadStatusFixedSize {
+ return nil, fmt.Errorf("PayloadStatusSSZ: buffer too short (%d < %d)", len(buf), payloadStatusFixedSize)
+ }
+
+ ps := &PayloadStatusV1{
+ Status: SSZToEngineStatus(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 List[Hash32, 1]: 0 bytes = nil, 32 bytes = hash present
+ hashData := buf[hashOffset:errOffset]
+ switch len(hashData) {
+ case 0:
+ ps.LatestValidHash = nil
+ case 32:
+ hash := common.BytesToHash(hashData)
+ ps.LatestValidHash = &hash
+ default:
+ return nil, fmt.Errorf("PayloadStatusSSZ: invalid hash list length %d (expected 0 or 32)", len(hashData))
+ }
+
+ // 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)
+ }
+ s := string(buf[errOffset:])
+ ps.ValidationError = &s
+ }
+
+ return ps, nil
+}
+
+// --- ForkchoiceState SSZ ---
+
+// EncodeForkchoiceStateSSZ encodes a ForkchoiceStateV1 to SSZ bytes (96 bytes fixed).
+func EncodeForkchoiceStateSSZ(fcs *ForkchoiceStateV1) []byte {
+ buf := make([]byte, 96)
+ copy(buf[0:32], fcs.HeadBlockHash[:])
+ copy(buf[32:64], fcs.SafeBlockHash[:])
+ copy(buf[64:96], fcs.FinalizedBlockHash[:])
+ return buf
+}
+
+// DecodeForkchoiceStateSSZ decodes SSZ bytes into a ForkchoiceStateV1.
+func DecodeForkchoiceStateSSZ(buf []byte) (*ForkchoiceStateV1, error) {
+ if len(buf) < 96 {
+ return nil, fmt.Errorf("ForkchoiceState: buffer too short (%d < 96)", len(buf))
+ }
+ fcs := &ForkchoiceStateV1{}
+ copy(fcs.HeadBlockHash[:], buf[0:32])
+ copy(fcs.SafeBlockHash[:], buf[32:64])
+ copy(fcs.FinalizedBlockHash[:], buf[64:96])
+ return fcs, nil
+}
+
+// --- ForkchoiceUpdated Response SSZ ---
+
+const forkchoiceUpdatedResponseFixedSize = 8
+
+// EncodeForkChoiceResponseSSZ encodes a ForkChoiceResponse to SSZ bytes.
+func EncodeForkChoiceResponseSSZ(resp *ForkChoiceResponse) []byte {
+ psBytes := EncodePayloadStatusSSZ(&resp.PayloadStatus)
+
+ // Build List[Bytes8, 1] for payload ID: 0 bytes if nil, 8 bytes if present
+ var pidData []byte
+ if resp.PayloadID != nil {
+ pidData = resp.PayloadID[:]
+ }
+
+ buf := make([]byte, forkchoiceUpdatedResponseFixedSize+len(psBytes)+len(pidData))
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(forkchoiceUpdatedResponseFixedSize))
+ binary.LittleEndian.PutUint32(buf[4:8], uint32(forkchoiceUpdatedResponseFixedSize+len(psBytes)))
+
+ copy(buf[forkchoiceUpdatedResponseFixedSize:], psBytes)
+ copy(buf[forkchoiceUpdatedResponseFixedSize+len(psBytes):], pidData)
+ return buf
+}
+
+// DecodeForkChoiceResponseSSZ decodes SSZ bytes into a ForkChoiceResponse.
+func DecodeForkChoiceResponseSSZ(buf []byte) (*ForkChoiceResponse, error) {
+ if len(buf) < forkchoiceUpdatedResponseFixedSize {
+ return nil, fmt.Errorf("ForkChoiceResponseSSZ: 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("ForkChoiceResponseSSZ: offsets out of bounds")
+ }
+
+ resp := &ForkChoiceResponse{}
+
+ ps, err := DecodePayloadStatusSSZ(buf[psOffset:pidOffset])
+ if err != nil {
+ return nil, err
+ }
+ resp.PayloadStatus = *ps
+
+ // Decode List[Bytes8, 1]: 0 bytes = nil, 8 bytes = payload ID present
+ pidData := buf[pidOffset:]
+ switch len(pidData) {
+ case 0:
+ resp.PayloadID = nil
+ case 8:
+ var pid PayloadID
+ copy(pid[:], pidData)
+ resp.PayloadID = &pid
+ default:
+ return nil, fmt.Errorf("ForkChoiceResponseSSZ: invalid payload_id list length %d (expected 0 or 8)", len(pidData))
+ }
+
+ return resp, nil
+}
+
+// --- Capabilities SSZ ---
+
+// EncodeCapabilitiesSSZ encodes a list of capability strings to SSZ.
+func EncodeCapabilitiesSSZ(capabilities []string) []byte {
+ 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
+}
+
+// DecodeCapabilitiesSSZ decodes a list of capability strings from SSZ bytes.
+func DecodeCapabilitiesSSZ(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
+}
+
+// --- ClientVersion SSZ ---
+
+// EncodeClientVersionSSZ encodes a ClientVersionV1 to SSZ.
+func EncodeClientVersionSSZ(cv *ClientVersionV1) []byte {
+ codeBytes := []byte(cv.Code)
+ nameBytes := []byte(cv.Name)
+ versionBytes := []byte(cv.Version)
+ commitBytes := []byte(cv.Commit)
+
+ totalLen := 4 + len(codeBytes) + 4 + len(nameBytes) + 4 + len(versionBytes) + 4 + len(commitBytes)
+ buf := make([]byte, totalLen)
+
+ offset := 0
+ for _, field := range [][]byte{codeBytes, nameBytes, versionBytes, commitBytes} {
+ binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(len(field)))
+ offset += 4
+ copy(buf[offset:], field)
+ offset += len(field)
+ }
+
+ return buf
+}
+
+// DecodeClientVersionSSZ decodes a ClientVersionV1 from SSZ bytes.
+func DecodeClientVersionSSZ(buf []byte) (*ClientVersionV1, error) {
+ if len(buf) < 16 {
+ 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
+}
+
+// EncodeClientVersionsSSZ encodes a list of ClientVersionV1 to SSZ.
+func EncodeClientVersionsSSZ(versions []ClientVersionV1) []byte {
+ var parts [][]byte
+ for i := range versions {
+ parts = append(parts, EncodeClientVersionSSZ(&versions[i]))
+ }
+
+ 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
+}
+
+// DecodeClientVersionsSSZ decodes a list of ClientVersionV1 from SSZ bytes.
+func DecodeClientVersionsSSZ(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 := DecodeClientVersionSSZ(buf[offset : offset+cvLen])
+ if err != nil {
+ return nil, err
+ }
+ versions = append(versions, *cv)
+ offset += cvLen
+ }
+
+ return versions, nil
+}
+
+// --- ExecutionPayload SSZ ---
+
+// engineVersionToPayloadVersion maps Engine API versions to ExecutionPayload SSZ versions.
+func engineVersionToPayloadVersion(engineVersion int) int {
+ if engineVersion == 4 {
+ return 3 // Electra uses Deneb payload layout
+ }
+ if engineVersion >= 5 {
+ return 4 // Amsterdam and beyond use extended layout
+ }
+ return engineVersion
+}
+
+// executionPayloadFixedSize returns the fixed part size for a given version.
+func executionPayloadFixedSize(version int) int {
+ size := 508 // V1 base
+ 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
+ for i, v := range b {
+ buf[len(b)-1-i] = v
+ }
+ return buf
+}
+
+// sszBytesToUint256 converts 32-byte little-endian SSZ bytes to a big.Int.
+func sszBytesToUint256(buf []byte) *big.Int {
+ be := make([]byte, 32)
+ for i := 0; i < 32; i++ {
+ be[31-i] = buf[i]
+ }
+ return new(big.Int).SetBytes(be)
+}
+
+// encodeTransactionsSSZ encodes a list of transactions as SSZ list of variable-length items.
+func encodeTransactionsSSZ(txs [][]byte) []byte {
+ if len(txs) == 0 {
+ return nil
+ }
+ offsetsSize := len(txs) * 4
+ dataSize := 0
+ for _, tx := range txs {
+ dataSize += len(tx)
+ }
+ buf := make([]byte, offsetsSize+dataSize)
+
+ dataStart := offsetsSize
+ for i, tx := range txs {
+ binary.LittleEndian.PutUint32(buf[i*4:(i+1)*4], uint32(dataStart))
+ dataStart += len(tx)
+ }
+ pos := offsetsSize
+ for _, tx := range txs {
+ copy(buf[pos:], tx)
+ pos += len(tx)
+ }
+ return buf
+}
+
+// decodeTransactionsSSZ decodes SSZ-encoded list of variable-length transactions.
+func decodeTransactionsSSZ(buf []byte) ([][]byte, error) {
+ if len(buf) == 0 {
+ return nil, nil
+ }
+ if len(buf) < 4 {
+ return nil, fmt.Errorf("transactions SSZ: buffer too short")
+ }
+ 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")
+ }
+
+ offsets := make([]uint32, count)
+ for i := uint32(0); i < count; i++ {
+ offsets[i] = binary.LittleEndian.Uint32(buf[i*4 : (i+1)*4])
+ }
+
+ txs := make([][]byte, 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([]byte, end-start)
+ copy(tx, buf[start:end])
+ txs[i] = tx
+ }
+ return txs, nil
+}
+
+// Withdrawal SSZ: 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
+}
+
+// EncodeExecutableDataSSZ encodes an ExecutableData to SSZ bytes.
+// version: 1=Bellatrix, 2=Capella, 3=Deneb, 4=Amsterdam
+func EncodeExecutableDataSSZ(ep *ExecutableData, version int) []byte {
+ fixedSize := executionPayloadFixedSize(version)
+
+ extraData := ep.ExtraData
+ txData := encodeTransactionsSSZ(ep.Transactions)
+ var withdrawalData []byte
+ if version >= 2 {
+ withdrawalData = encodeWithdrawalsSSZ(ep.Withdrawals)
+ }
+
+ totalVarSize := len(extraData) + len(txData)
+ if version >= 2 {
+ totalVarSize += len(withdrawalData)
+ }
+
+ 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
+ if len(ep.LogsBloom) >= 256 {
+ copy(buf[pos:pos+256], ep.LogsBloom[:256])
+ }
+ pos += 256
+ copy(buf[pos:pos+32], ep.Random[:])
+ pos += 32
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], ep.Number)
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], ep.GasLimit)
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], ep.GasUsed)
+ pos += 8
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], 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)
+ copy(buf[pos:pos+32], uint256ToSSZBytes(ep.BaseFeePerGas))
+ 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 {
+ 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 = *ep.BlobGasUsed
+ }
+ if ep.ExcessBlobGas != nil {
+ excessBlobGas = *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 = *ep.SlotNumber
+ }
+ binary.LittleEndian.PutUint64(buf[pos:pos+8], slotNumber)
+ pos += 8
+ // Note: For V4 we'd have block_access_list offset here, but Geth doesn't have that field.
+ // We write the end of data as the offset (no block_access_list data).
+ balOffset := extraDataOffset + len(extraData) + len(txData)
+ if version >= 2 {
+ balOffset += len(withdrawalData)
+ }
+ binary.LittleEndian.PutUint32(buf[pos:pos+4], uint32(balOffset))
+ }
+
+ // Variable part
+ copy(buf[extraDataOffset:], extraData)
+ copy(buf[txOffset:], txData)
+ if version >= 2 {
+ wdOffset := txOffset + len(txData)
+ copy(buf[wdOffset:], withdrawalData)
+ }
+
+ return buf
+}
+
+// DecodeExecutableDataSSZ decodes SSZ bytes into an ExecutableData.
+func DecodeExecutableDataSSZ(buf []byte, version int) (*ExecutableData, error) {
+ fixedSize := executionPayloadFixedSize(version)
+ if len(buf) < fixedSize {
+ return nil, fmt.Errorf("ExecutableData SSZ: buffer too short (%d < %d)", len(buf), fixedSize)
+ }
+
+ ep := &ExecutableData{}
+ 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([]byte, 256)
+ copy(ep.LogsBloom, buf[pos:pos+256])
+ pos += 256
+ copy(ep.Random[:], buf[pos:pos+32])
+ pos += 32
+ ep.Number = binary.LittleEndian.Uint64(buf[pos : pos+8])
+ pos += 8
+ ep.GasLimit = binary.LittleEndian.Uint64(buf[pos : pos+8])
+ pos += 8
+ ep.GasUsed = binary.LittleEndian.Uint64(buf[pos : pos+8])
+ pos += 8
+ ep.Timestamp = binary.LittleEndian.Uint64(buf[pos : pos+8])
+ pos += 8
+
+ extraDataOffset := binary.LittleEndian.Uint32(buf[pos : pos+4])
+ pos += 4
+
+ ep.BaseFeePerGas = sszBytesToUint256(buf[pos : pos+32])
+ 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 := binary.LittleEndian.Uint64(buf[pos : pos+8])
+ ep.BlobGasUsed = &blobGasUsed
+ pos += 8
+ excessBlobGas := binary.LittleEndian.Uint64(buf[pos : pos+8])
+ ep.ExcessBlobGas = &excessBlobGas
+ pos += 8
+ }
+
+ var balOffset uint32
+ if version >= 4 {
+ slotNumber := binary.LittleEndian.Uint64(buf[pos : pos+8])
+ ep.SlotNumber = &slotNumber
+ pos += 8
+ balOffset = binary.LittleEndian.Uint32(buf[pos : pos+4])
+ }
+
+ // Decode variable-length fields
+ if extraDataOffset > uint32(len(buf)) || txOffset > uint32(len(buf)) || extraDataOffset > txOffset {
+ return nil, fmt.Errorf("ExecutableData SSZ: invalid extra_data/transactions offsets")
+ }
+ ep.ExtraData = make([]byte, txOffset-extraDataOffset)
+ copy(ep.ExtraData, buf[extraDataOffset:txOffset])
+
+ var txEnd uint32
+ if version >= 2 {
+ txEnd = wdOffset
+ } else {
+ txEnd = uint32(len(buf))
+ }
+ if txOffset > txEnd {
+ return nil, fmt.Errorf("ExecutableData SSZ: transactions offset > end")
+ }
+
+ txBuf := buf[txOffset:txEnd]
+ txs, err := decodeTransactionsSSZ(txBuf)
+ if err != nil {
+ return nil, fmt.Errorf("ExecutableData SSZ: %w", err)
+ }
+ ep.Transactions = txs
+ if ep.Transactions == nil {
+ ep.Transactions = [][]byte{}
+ }
+
+ 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("ExecutableData SSZ: invalid withdrawals offset")
+ }
+ wds, err := decodeWithdrawalsSSZ(buf[wdOffset:wdEnd])
+ if err != nil {
+ return nil, fmt.Errorf("ExecutableData SSZ: %w", err)
+ }
+ ep.Withdrawals = wds
+ }
+
+ return ep, nil
+}
+
+// --- NewPayload request SSZ ---
+
+// EncodeNewPayloadRequestSSZ encodes a newPayload request to SSZ.
+func EncodeNewPayloadRequestSSZ(
+ ep *ExecutableData,
+ versionedHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests [][]byte,
+ version int,
+) []byte {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if version <= 2 {
+ return EncodeExecutableDataSSZ(ep, payloadVersion)
+ }
+
+ epBytes := EncodeExecutableDataSSZ(ep, payloadVersion)
+ blobHashBytes := make([]byte, len(versionedHashes)*32)
+ for i, h := range versionedHashes {
+ copy(blobHashBytes[i*32:(i+1)*32], h[:])
+ }
+
+ if version == 3 {
+ fixedSize := 40
+ buf := make([]byte, fixedSize+len(epBytes)+len(blobHashBytes))
+ 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[:])
+ }
+ copy(buf[fixedSize:], epBytes)
+ copy(buf[fixedSize+len(epBytes):], blobHashBytes)
+ return buf
+ }
+
+ // V4+
+ reqBytes := encodeStructuredExecutionRequestsSSZ(executionRequests)
+
+ fixedSize := 44
+ 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 *ExecutableData,
+ versionedHashes []common.Hash,
+ parentBeaconBlockRoot *common.Hash,
+ executionRequests [][]byte,
+ err error,
+) {
+ payloadVersion := engineVersionToPayloadVersion(version)
+ if version <= 2 {
+ ep, err = DecodeExecutableDataSSZ(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 = DecodeExecutableDataSSZ(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
+ }
+ versionedHashes = make([]common.Hash, len(blobHashBuf)/32)
+ for i := range versionedHashes {
+ copy(versionedHashes[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 = DecodeExecutableDataSSZ(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
+ }
+ versionedHashes = make([]common.Hash, len(blobHashBuf)/32)
+ for i := range versionedHashes {
+ copy(versionedHashes[i][:], blobHashBuf[i*32:(i+1)*32])
+ }
+
+ executionRequests, err = decodeStructuredExecutionRequestsSSZ(buf[reqOffset:])
+ return
+}
+
+// --- Execution Requests SSZ (structured container for Prysm compatibility) ---
+
+func encodeStructuredExecutionRequestsSSZ(reqs [][]byte) []byte {
+ var depositsData, withdrawalsData, consolidationsData []byte
+ for _, r := range reqs {
+ if len(r) < 1 {
+ continue
+ }
+ switch r[0] {
+ case 0x00:
+ depositsData = append(depositsData, r[1:]...)
+ case 0x01:
+ withdrawalsData = append(withdrawalsData, r[1:]...)
+ case 0x02:
+ consolidationsData = append(consolidationsData, r[1:]...)
+ }
+ }
+
+ fixedSize := 12
+ 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
+}
+
+func decodeStructuredExecutionRequestsSSZ(buf []byte) ([][]byte, error) {
+ if len(buf) == 0 {
+ return [][]byte{}, 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")
+ }
+
+ reqs := make([][]byte, 0, 3)
+
+ depositsData := buf[depositsOffset:withdrawalsOffset]
+ if len(depositsData) > 0 {
+ r := make([]byte, 1+len(depositsData))
+ r[0] = 0x00
+ copy(r[1:], depositsData)
+ reqs = append(reqs, r)
+ }
+
+ withdrawalsData := buf[withdrawalsOffset:consolidationsOffset]
+ if len(withdrawalsData) > 0 {
+ r := make([]byte, 1+len(withdrawalsData))
+ r[0] = 0x01
+ copy(r[1:], withdrawalsData)
+ reqs = append(reqs, r)
+ }
+
+ consolidationsData := buf[consolidationsOffset:]
+ if len(consolidationsData) > 0 {
+ r := make([]byte, 1+len(consolidationsData))
+ r[0] = 0x02
+ copy(r[1:], consolidationsData)
+ reqs = append(reqs, r)
+ }
+
+ return reqs, nil
+}
+
+// --- GetPayload response SSZ ---
+
+const getPayloadResponseFixedSize = 45
+
+// EncodeExecutionPayloadEnvelopeSSZ encodes a GetPayload response to SSZ.
+func EncodeExecutionPayloadEnvelopeSSZ(resp *ExecutionPayloadEnvelope, version int) []byte {
+ if version == 1 {
+ return EncodeExecutableDataSSZ(resp.ExecutionPayload, 1)
+ }
+
+ payloadVersion := engineVersionToPayloadVersion(version)
+ epBytes := EncodeExecutableDataSSZ(resp.ExecutionPayload, payloadVersion)
+ blobsBytes := encodeBlobsBundleSSZ(resp.BlobsBundle)
+ reqBytes := encodeStructuredExecutionRequestsSSZ(resp.Requests)
+
+ 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))
+ }
+
+ // blobs_bundle offset
+ blobsOffset := getPayloadResponseFixedSize + len(epBytes)
+ binary.LittleEndian.PutUint32(buf[36:40], uint32(blobsOffset))
+
+ // should_override_builder
+ if resp.Override {
+ 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
+}
+
+// DecodeExecutionPayloadEnvelopeSSZ decodes SSZ bytes into an ExecutionPayloadEnvelope.
+func DecodeExecutionPayloadEnvelopeSSZ(buf []byte, version int) (*ExecutionPayloadEnvelope, error) {
+ if version == 1 {
+ ep, err := DecodeExecutableDataSSZ(buf, 1)
+ if err != nil {
+ return nil, err
+ }
+ return &ExecutionPayloadEnvelope{ExecutionPayload: ep}, nil
+ }
+
+ if len(buf) < getPayloadResponseFixedSize {
+ return nil, fmt.Errorf("ExecutionPayloadEnvelope SSZ: buffer too short (%d < %d)", len(buf), getPayloadResponseFixedSize)
+ }
+
+ resp := &ExecutionPayloadEnvelope{}
+
+ epOffset := binary.LittleEndian.Uint32(buf[0:4])
+ resp.BlockValue = sszBytesToUint256(buf[4:36])
+ blobsOffset := binary.LittleEndian.Uint32(buf[36:40])
+ resp.Override = buf[40] == 1
+ reqOffset := binary.LittleEndian.Uint32(buf[41:45])
+
+ if epOffset > uint32(len(buf)) || blobsOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("ExecutionPayloadEnvelope SSZ: offsets out of bounds")
+ }
+ payloadVersion := engineVersionToPayloadVersion(version)
+ ep, err := DecodeExecutableDataSSZ(buf[epOffset:blobsOffset], payloadVersion)
+ if err != nil {
+ return nil, err
+ }
+ resp.ExecutionPayload = ep
+
+ if blobsOffset > reqOffset || reqOffset > uint32(len(buf)) {
+ return nil, fmt.Errorf("ExecutionPayloadEnvelope SSZ: invalid blobs/requests offsets")
+ }
+ bundle, err := decodeBlobsBundleSSZ(buf[blobsOffset:reqOffset])
+ if err != nil {
+ return nil, err
+ }
+ resp.BlobsBundle = bundle
+
+ if reqOffset < uint32(len(buf)) {
+ reqs, err := decodeStructuredExecutionRequestsSSZ(buf[reqOffset:])
+ if err != nil {
+ return nil, err
+ }
+ resp.Requests = reqs
+ }
+
+ return resp, nil
+}
+
+// --- BlobsBundle SSZ ---
+
+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{}
+
+ 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
+ }
+ }
+
+ 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
+ }
+ }
+
+ 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
+}
+
+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
+}
+
+// --- GetBlobs request SSZ ---
+
+// EncodeGetBlobsRequestSSZ encodes a list of versioned hashes for the get_blobs SSZ request.
+func EncodeGetBlobsRequestSSZ(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
+}
+
+// DecodeGetBlobsRequestSSZ decodes a list of versioned hashes from SSZ bytes.
+func DecodeGetBlobsRequestSSZ(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
+}
+
+// --- PayloadAttributes SSZ ---
+
+// DecodePayloadAttributesSSZ decodes PayloadAttributes from SSZ bytes.
+func DecodePayloadAttributesSSZ(buf []byte, version int) (*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 := &PayloadAttributes{
+ Timestamp: timestamp,
+ SuggestedFeeRecipient: common.BytesToAddress(buf[40:60]),
+ }
+ copy(pa.Random[:], buf[8:40])
+
+ if version == 1 {
+ return pa, nil
+ }
+
+ 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 {
+ if len(buf) < 96 {
+ return nil, fmt.Errorf("PayloadAttributes V3: buffer too short (%d < 96)", len(buf))
+ }
+ root := common.BytesToHash(buf[64:96])
+ pa.BeaconRoot = &root
+ }
+
+ if withdrawalsOffset <= uint32(len(buf)) {
+ wdBuf := buf[withdrawalsOffset:]
+ if len(wdBuf) > 0 {
+ 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
+}
diff --git a/beacon/engine/ssz_test.go b/beacon/engine/ssz_test.go
new file mode 100644
index 0000000000..db2e84419e
--- /dev/null
+++ b/beacon/engine/ssz_test.go
@@ -0,0 +1,425 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package engine
+
+import (
+ "bytes"
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+)
+
+func TestPayloadStatusSSZRoundTrip(t *testing.T) {
+ hash := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
+ errMsg := "something went wrong"
+
+ tests := []struct {
+ name string
+ ps PayloadStatusV1
+ }{
+ {
+ name: "valid with hash",
+ ps: PayloadStatusV1{
+ Status: VALID,
+ LatestValidHash: &hash,
+ },
+ },
+ {
+ name: "invalid with error",
+ ps: PayloadStatusV1{
+ Status: INVALID,
+ LatestValidHash: &hash,
+ ValidationError: &errMsg,
+ },
+ },
+ {
+ name: "syncing no hash",
+ ps: PayloadStatusV1{
+ Status: SYNCING,
+ },
+ },
+ {
+ name: "accepted",
+ ps: PayloadStatusV1{
+ Status: ACCEPTED,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ encoded := EncodePayloadStatusSSZ(&tt.ps)
+ decoded, err := DecodePayloadStatusSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if decoded.Status != tt.ps.Status {
+ t.Errorf("status mismatch: got %s, want %s", decoded.Status, tt.ps.Status)
+ }
+ if (decoded.LatestValidHash == nil) != (tt.ps.LatestValidHash == nil) {
+ t.Errorf("hash nil mismatch")
+ }
+ if decoded.LatestValidHash != nil && *decoded.LatestValidHash != *tt.ps.LatestValidHash {
+ t.Errorf("hash mismatch")
+ }
+ if tt.ps.ValidationError != nil {
+ if decoded.ValidationError == nil || *decoded.ValidationError != *tt.ps.ValidationError {
+ t.Errorf("validation error mismatch")
+ }
+ }
+ })
+ }
+}
+
+func TestForkchoiceStateSSZRoundTrip(t *testing.T) {
+ fcs := &ForkchoiceStateV1{
+ HeadBlockHash: common.HexToHash("0xaaaa"),
+ SafeBlockHash: common.HexToHash("0xbbbb"),
+ FinalizedBlockHash: common.HexToHash("0xcccc"),
+ }
+
+ encoded := EncodeForkchoiceStateSSZ(fcs)
+ if len(encoded) != 96 {
+ t.Fatalf("expected 96 bytes, got %d", len(encoded))
+ }
+
+ decoded, err := DecodeForkchoiceStateSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+
+ if decoded.HeadBlockHash != fcs.HeadBlockHash {
+ t.Errorf("head hash mismatch")
+ }
+ if decoded.SafeBlockHash != fcs.SafeBlockHash {
+ t.Errorf("safe hash mismatch")
+ }
+ if decoded.FinalizedBlockHash != fcs.FinalizedBlockHash {
+ t.Errorf("finalized hash mismatch")
+ }
+}
+
+func TestForkChoiceResponseSSZRoundTrip(t *testing.T) {
+ hash := common.HexToHash("0x1234")
+ pid := PayloadID{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
+
+ tests := []struct {
+ name string
+ resp ForkChoiceResponse
+ }{
+ {
+ name: "with payload id",
+ resp: ForkChoiceResponse{
+ PayloadStatus: PayloadStatusV1{
+ Status: VALID,
+ LatestValidHash: &hash,
+ },
+ PayloadID: &pid,
+ },
+ },
+ {
+ name: "without payload id",
+ resp: ForkChoiceResponse{
+ PayloadStatus: PayloadStatusV1{
+ Status: SYNCING,
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ encoded := EncodeForkChoiceResponseSSZ(&tt.resp)
+ decoded, err := DecodeForkChoiceResponseSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if decoded.PayloadStatus.Status != tt.resp.PayloadStatus.Status {
+ t.Errorf("status mismatch: got %s, want %s", decoded.PayloadStatus.Status, tt.resp.PayloadStatus.Status)
+ }
+ if (decoded.PayloadID == nil) != (tt.resp.PayloadID == nil) {
+ t.Errorf("payloadID nil mismatch: got %v, want %v", decoded.PayloadID, tt.resp.PayloadID)
+ }
+ if decoded.PayloadID != nil && *decoded.PayloadID != *tt.resp.PayloadID {
+ t.Errorf("payloadID mismatch: got %x, want %x", decoded.PayloadID, tt.resp.PayloadID)
+ }
+ })
+ }
+}
+
+func TestCapabilitiesSSZRoundTrip(t *testing.T) {
+ caps := []string{"engine_newPayloadV1", "engine_forkchoiceUpdatedV1", "engine_getPayloadV1"}
+ encoded := EncodeCapabilitiesSSZ(caps)
+ decoded, err := DecodeCapabilitiesSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(decoded) != len(caps) {
+ t.Fatalf("length mismatch: got %d, want %d", len(decoded), len(caps))
+ }
+ for i, c := range caps {
+ if decoded[i] != c {
+ t.Errorf("capability[%d] mismatch: got %s, want %s", i, decoded[i], c)
+ }
+ }
+}
+
+func TestClientVersionSSZRoundTrip(t *testing.T) {
+ cv := &ClientVersionV1{
+ Code: "GE",
+ Name: "go-ethereum",
+ Version: "1.14.0",
+ Commit: "0xdeadbeef",
+ }
+ encoded := EncodeClientVersionSSZ(cv)
+ decoded, err := DecodeClientVersionSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if decoded.Code != cv.Code || decoded.Name != cv.Name || decoded.Version != cv.Version || decoded.Commit != cv.Commit {
+ t.Errorf("mismatch: got %+v, want %+v", decoded, cv)
+ }
+}
+
+func TestClientVersionsSSZRoundTrip(t *testing.T) {
+ versions := []ClientVersionV1{
+ {Code: "GE", Name: "go-ethereum", Version: "1.14.0", Commit: "0xabcd"},
+ {Code: "ER", Name: "erigon", Version: "2.59.0", Commit: "0x1234"},
+ }
+ encoded := EncodeClientVersionsSSZ(versions)
+ decoded, err := DecodeClientVersionsSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(decoded) != len(versions) {
+ t.Fatalf("length mismatch")
+ }
+ for i := range versions {
+ if decoded[i].Code != versions[i].Code || decoded[i].Name != versions[i].Name {
+ t.Errorf("version[%d] mismatch", i)
+ }
+ }
+}
+
+func TestExecutableDataSSZRoundTripV1(t *testing.T) {
+ baseFee := big.NewInt(7)
+ ed := &ExecutableData{
+ ParentHash: common.HexToHash("0x1111"),
+ FeeRecipient: common.HexToAddress("0x2222"),
+ StateRoot: common.HexToHash("0x3333"),
+ ReceiptsRoot: common.HexToHash("0x4444"),
+ LogsBloom: make([]byte, 256),
+ Random: common.HexToHash("0x5555"),
+ Number: 100,
+ GasLimit: 30000000,
+ GasUsed: 21000,
+ Timestamp: 1700000000,
+ ExtraData: []byte("hello"),
+ BaseFeePerGas: baseFee,
+ BlockHash: common.HexToHash("0x6666"),
+ Transactions: [][]byte{{0x01, 0x02}, {0x03, 0x04, 0x05}},
+ }
+
+ encoded := EncodeExecutableDataSSZ(ed, 1)
+ decoded, err := DecodeExecutableDataSSZ(encoded, 1)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+
+ if decoded.ParentHash != ed.ParentHash {
+ t.Errorf("ParentHash mismatch")
+ }
+ if decoded.Number != ed.Number {
+ t.Errorf("Number mismatch: got %d, want %d", decoded.Number, ed.Number)
+ }
+ if decoded.BaseFeePerGas.Cmp(ed.BaseFeePerGas) != 0 {
+ t.Errorf("BaseFeePerGas mismatch: got %v, want %v", decoded.BaseFeePerGas, ed.BaseFeePerGas)
+ }
+ if len(decoded.Transactions) != len(ed.Transactions) {
+ t.Fatalf("Transactions length mismatch: got %d, want %d", len(decoded.Transactions), len(ed.Transactions))
+ }
+ for i := range ed.Transactions {
+ if !bytes.Equal(decoded.Transactions[i], ed.Transactions[i]) {
+ t.Errorf("Transaction[%d] mismatch", i)
+ }
+ }
+ if !bytes.Equal(decoded.ExtraData, ed.ExtraData) {
+ t.Errorf("ExtraData mismatch")
+ }
+}
+
+func TestExecutableDataSSZRoundTripV3(t *testing.T) {
+ baseFee := big.NewInt(1000000000)
+ blobGasUsed := uint64(131072)
+ excessBlobGas := uint64(262144)
+
+ addr := common.HexToAddress("0xdead")
+ ed := &ExecutableData{
+ ParentHash: common.HexToHash("0xaa"),
+ FeeRecipient: addr,
+ StateRoot: common.HexToHash("0xbb"),
+ ReceiptsRoot: common.HexToHash("0xcc"),
+ LogsBloom: make([]byte, 256),
+ Random: common.HexToHash("0xdd"),
+ Number: 200,
+ GasLimit: 30000000,
+ GasUsed: 42000,
+ Timestamp: 1700000001,
+ ExtraData: []byte{},
+ BaseFeePerGas: baseFee,
+ BlockHash: common.HexToHash("0xee"),
+ Transactions: [][]byte{},
+ Withdrawals: []*types.Withdrawal{
+ {Index: 0, Validator: 1, Address: addr, Amount: 1000},
+ {Index: 1, Validator: 2, Address: addr, Amount: 2000},
+ },
+ BlobGasUsed: &blobGasUsed,
+ ExcessBlobGas: &excessBlobGas,
+ }
+
+ encoded := EncodeExecutableDataSSZ(ed, 3)
+ decoded, err := DecodeExecutableDataSSZ(encoded, 3)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+
+ if decoded.Number != ed.Number {
+ t.Errorf("Number mismatch")
+ }
+ if *decoded.BlobGasUsed != *ed.BlobGasUsed {
+ t.Errorf("BlobGasUsed mismatch")
+ }
+ if *decoded.ExcessBlobGas != *ed.ExcessBlobGas {
+ t.Errorf("ExcessBlobGas mismatch")
+ }
+ if len(decoded.Withdrawals) != len(ed.Withdrawals) {
+ t.Fatalf("Withdrawals length mismatch")
+ }
+ for i := range ed.Withdrawals {
+ if decoded.Withdrawals[i].Index != ed.Withdrawals[i].Index {
+ t.Errorf("Withdrawal[%d].Index mismatch", i)
+ }
+ if decoded.Withdrawals[i].Amount != ed.Withdrawals[i].Amount {
+ t.Errorf("Withdrawal[%d].Amount mismatch", i)
+ }
+ }
+}
+
+func TestNewPayloadRequestSSZRoundTripV4(t *testing.T) {
+ baseFee := big.NewInt(1000000000)
+ blobGasUsed := uint64(131072)
+ excessBlobGas := uint64(0)
+
+ addr := common.HexToAddress("0xbeef")
+ ep := &ExecutableData{
+ ParentHash: common.HexToHash("0x01"),
+ FeeRecipient: addr,
+ StateRoot: common.HexToHash("0x02"),
+ ReceiptsRoot: common.HexToHash("0x03"),
+ LogsBloom: make([]byte, 256),
+ Random: common.HexToHash("0x04"),
+ Number: 300,
+ GasLimit: 30000000,
+ GasUsed: 63000,
+ Timestamp: 1700000002,
+ ExtraData: []byte("test"),
+ BaseFeePerGas: baseFee,
+ BlockHash: common.HexToHash("0x05"),
+ Transactions: [][]byte{{0xf8}},
+ Withdrawals: []*types.Withdrawal{},
+ BlobGasUsed: &blobGasUsed,
+ ExcessBlobGas: &excessBlobGas,
+ }
+
+ blobHashes := []common.Hash{common.HexToHash("0xb10b")}
+ beaconRoot := common.HexToHash("0xbeac")
+ execRequests := [][]byte{
+ {0x00, 0x01, 0x02}, // deposits
+ {0x01, 0x03, 0x04}, // withdrawals
+ }
+
+ encoded := EncodeNewPayloadRequestSSZ(ep, blobHashes, &beaconRoot, execRequests, 4)
+ decEp, decHashes, decRoot, decReqs, err := DecodeNewPayloadRequestSSZ(encoded, 4)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+
+ if decEp.Number != ep.Number {
+ t.Errorf("Number mismatch")
+ }
+ if len(decHashes) != 1 || decHashes[0] != blobHashes[0] {
+ t.Errorf("blob hashes mismatch")
+ }
+ if *decRoot != beaconRoot {
+ t.Errorf("beacon root mismatch")
+ }
+ // Structured requests: deposits and withdrawals should be present
+ if len(decReqs) < 2 {
+ t.Fatalf("expected at least 2 execution requests, got %d", len(decReqs))
+ }
+}
+
+func TestGetBlobsRequestSSZRoundTrip(t *testing.T) {
+ hashes := []common.Hash{
+ common.HexToHash("0x1111"),
+ common.HexToHash("0x2222"),
+ common.HexToHash("0x3333"),
+ }
+ encoded := EncodeGetBlobsRequestSSZ(hashes)
+ decoded, err := DecodeGetBlobsRequestSSZ(encoded)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(decoded) != len(hashes) {
+ t.Fatalf("length mismatch")
+ }
+ for i := range hashes {
+ if decoded[i] != hashes[i] {
+ t.Errorf("hash[%d] mismatch", i)
+ }
+ }
+}
+
+func TestUint256SSZRoundTrip(t *testing.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)
+ decoded := sszBytesToUint256(encoded)
+ if decoded.Cmp(val) != 0 {
+ t.Errorf("uint256 roundtrip failed for %v: got %v", val, decoded)
+ }
+ }
+}
+
+func TestEngineStatusSSZConversion(t *testing.T) {
+ statuses := []string{VALID, INVALID, SYNCING, ACCEPTED, "INVALID_BLOCK_HASH"}
+ for _, s := range statuses {
+ ssz := EngineStatusToSSZ(s)
+ back := SSZToEngineStatus(ssz)
+ if back != s {
+ t.Errorf("status roundtrip failed: %s -> %d -> %s", s, ssz, back)
+ }
+ }
+}
diff --git a/cmd/geth/main.go b/cmd/geth/main.go
index 850e26d161..79d34e5caf 100644
--- a/cmd/geth/main.go
+++ b/cmd/geth/main.go
@@ -168,6 +168,8 @@ var (
utils.AuthPortFlag,
utils.AuthVirtualHostsFlag,
utils.JWTSecretFlag,
+ utils.AuthRpcSszRestFlag,
+ utils.AuthRpcSszRestPortFlag,
utils.HTTPVirtualHostsFlag,
utils.GraphQLEnabledFlag,
utils.GraphQLCORSDomainFlag,
diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go
index c41cf4ee40..ab22252453 100644
--- a/cmd/utils/flags.go
+++ b/cmd/utils/flags.go
@@ -692,6 +692,18 @@ var (
Usage: "Path to a JWT secret to use for authenticated RPC endpoints",
Category: flags.APICategory,
}
+ AuthRpcSszRestFlag = &cli.BoolFlag{
+ Name: "authrpc.ssz-rest",
+ Aliases: []string{"rest"},
+ Usage: "Enable the SSZ-REST Engine API transport (EIP-8161)",
+ Category: flags.APICategory,
+ }
+ AuthRpcSszRestPortFlag = &cli.IntFlag{
+ Name: "authrpc.ssz-rest.port",
+ Usage: "SSZ-REST Engine API listening port",
+ Value: node.DefaultSszRestPort,
+ Category: flags.APICategory,
+ }
// Logging and debug settings
EthStatsURLFlag = &cli.StringFlag{
@@ -1350,6 +1362,17 @@ func setHTTP(ctx *cli.Context, cfg *node.Config) {
if ctx.IsSet(BatchResponseMaxSize.Name) {
cfg.BatchResponseMaxSize = ctx.Int(BatchResponseMaxSize.Name)
}
+
+ // SSZ-REST Engine API (EIP-8161)
+ if ctx.Bool(AuthRpcSszRestFlag.Name) {
+ cfg.SszRestEnabled = true
+ }
+ if ctx.IsSet(AuthRpcSszRestPortFlag.Name) {
+ cfg.SszRestPort = ctx.Int(AuthRpcSszRestPortFlag.Name)
+ }
+ if cfg.SszRestEnabled && cfg.SszRestPort == 0 {
+ cfg.SszRestPort = node.DefaultSszRestPort
+ }
}
// setGraphQL creates the GraphQL listener interface string from the set
diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go
index 4109971dc8..2a679e104a 100644
--- a/eth/catalyst/api.go
+++ b/eth/catalyst/api.go
@@ -48,15 +48,32 @@ import (
)
// Register adds the engine API and related APIs to the full node.
+// If SSZ-REST is enabled in the node config, it also starts the SSZ-REST server.
func Register(stack *node.Node, backend *eth.Ethereum) error {
+ api := NewConsensusAPI(backend)
+
+ cfg := stack.Config()
+
stack.RegisterAPIs([]rpc.API{
newTestingAPI(backend),
{
Namespace: "engine",
- Service: NewConsensusAPI(backend),
+ Service: api,
Authenticated: true,
},
})
+
+ // Start SSZ-REST server if enabled
+ if cfg.SszRestEnabled {
+ jwtSecret, err := node.ObtainJWTSecret(cfg.JWTSecret)
+ if err != nil {
+ return fmt.Errorf("SSZ-REST: failed to obtain JWT secret: %w", err)
+ }
+ sszServer := NewSszRestServer(api, jwtSecret, cfg.AuthAddr, cfg.SszRestPort)
+ stack.RegisterLifecycle(sszServer)
+ log.Info("[SSZ-REST] Server registered", "addr", cfg.AuthAddr, "port", cfg.SszRestPort)
+ }
+
return nil
}
@@ -125,6 +142,7 @@ type ConsensusAPI struct {
forkchoiceLock sync.Mutex // Lock for the forkChoiceUpdated method
newPayloadLock sync.Mutex // Lock for the NewPayload method
+
}
// NewConsensusAPI creates a new consensus api for the given backend.
@@ -1127,14 +1145,19 @@ func (api *ConsensusAPI) checkFork(timestamp uint64, forks ...forks.Fork) bool {
// ExchangeCapabilities returns the current methods provided by this node.
func (api *ConsensusAPI) ExchangeCapabilities([]string) []string {
+ // Methods that should not be advertised via V1 capabilities
+ skip := map[string]bool{
+ "ExchangeCapabilities": true,
+ }
valueT := reflect.TypeOf(api)
caps := make([]string, 0, valueT.NumMethod())
for i := 0; i < valueT.NumMethod(); i++ {
- name := []rune(valueT.Method(i).Name)
- if string(name) == "ExchangeCapabilities" {
+ name := valueT.Method(i).Name
+ if skip[name] {
continue
}
- caps = append(caps, "engine_"+string(unicode.ToLower(name[0]))+string(name[1:]))
+ runes := []rune(name)
+ caps = append(caps, "engine_"+string(unicode.ToLower(runes[0]))+string(runes[1:]))
}
return caps
}
diff --git a/eth/catalyst/ssz_rest.go b/eth/catalyst/ssz_rest.go
new file mode 100644
index 0000000000..e3fa3a9884
--- /dev/null
+++ b/eth/catalyst/ssz_rest.go
@@ -0,0 +1,519 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package catalyst
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+
+ "github.com/ethereum/go-ethereum/beacon/engine"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/node"
+)
+
+// SszRestServer implements the EIP-8161 SSZ-REST Engine API transport.
+// It runs alongside the JSON-RPC Engine API and shares the same ConsensusAPI.
+type SszRestServer struct {
+ api *ConsensusAPI
+ jwtSecret []byte
+ addr string
+ port int
+ server *http.Server
+}
+
+// NewSszRestServer creates a new SSZ-REST server.
+func NewSszRestServer(api *ConsensusAPI, jwtSecret []byte, addr string, port int) *SszRestServer {
+ return &SszRestServer{
+ api: api,
+ jwtSecret: jwtSecret,
+ addr: addr,
+ port: port,
+ }
+}
+
+// Start implements node.Lifecycle. It starts the SSZ-REST HTTP server.
+func (s *SszRestServer) Start() error {
+ mux := http.NewServeMux()
+ s.registerRoutes(mux)
+
+ handler := node.NewJWTHandler(s.jwtSecret, s.panicRecovery(mux))
+
+ listenAddr := fmt.Sprintf("%s:%d", s.addr, s.port)
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return fmt.Errorf("[SSZ-REST] failed to listen on %s: %w", listenAddr, err)
+ }
+
+ s.server = &http.Server{Handler: handler}
+ log.Info("[SSZ-REST] Engine API server started", "addr", listenAddr)
+
+ go func() {
+ if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Error("[SSZ-REST] Server error", "err", err)
+ }
+ }()
+
+ return nil
+}
+
+// Stop implements node.Lifecycle. It stops the SSZ-REST HTTP server.
+func (s *SszRestServer) Stop() error {
+ if s.server != nil {
+ log.Info("[SSZ-REST] Stopping server")
+ return s.server.Close()
+ }
+ return nil
+}
+
+// panicRecovery wraps a handler with panic recovery.
+func (s *SszRestServer) panicRecovery(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ log.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)
+ })
+}
+
+// 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
+}
+
+// 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))
+}
+
+// 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)
+}
+
+// --- 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) {
+ log.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
+ }
+
+ ep, versionedHashes, parentBeaconBlockRoot, executionRequests, err := engine.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.PayloadStatusV1
+
+ switch version {
+ case 1:
+ result, err = s.api.NewPayloadV1(ctx, *ep)
+ case 2:
+ result, err = s.api.NewPayloadV2(ctx, *ep)
+ case 3:
+ result, err = s.api.NewPayloadV3(ctx, *ep, versionedHashes, parentBeaconBlockRoot)
+ case 4:
+ hexReqs := make([]hexutil.Bytes, len(executionRequests))
+ for i, r := range executionRequests {
+ hexReqs[i] = hexutil.Bytes(r)
+ }
+ result, err = s.api.NewPayloadV4(ctx, *ep, versionedHashes, parentBeaconBlockRoot, hexReqs)
+ case 5:
+ hexReqs := make([]hexutil.Bytes, len(executionRequests))
+ for i, r := range executionRequests {
+ hexReqs[i] = hexutil.Bytes(r)
+ }
+ result, err = s.api.NewPayloadV5(ctx, *ep, versionedHashes, parentBeaconBlockRoot, hexReqs)
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported newPayload version: %d", version))
+ return
+ }
+
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ sszResponse(w, engine.EncodePayloadStatusSSZ(&result))
+}
+
+// --- 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) {
+ log.Info("[SSZ-REST] Received ForkchoiceUpdated", "version", version)
+
+ body, err := readBody(r, 1024*1024) // 1 MB max
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ const fixedSize = 100 // forkchoice_state(96) + attributes_offset(4)
+
+ if len(body) < 96 {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "request body too short for ForkchoiceState")
+ return
+ }
+
+ fcs, err := engine.DecodeForkchoiceStateSSZ(body[:96])
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ var payloadAttributes *engine.PayloadAttributes
+
+ if len(body) >= fixedSize {
+ attrOffset := binary.LittleEndian.Uint32(body[96:100])
+ if attrOffset <= uint32(len(body)) {
+ // List[PayloadAttributesV3SSZ, 1]: for variable-size containers,
+ // the list data starts with a 4-byte offset to the first element.
+ listData := body[attrOffset:]
+ if len(listData) > 4 {
+ elemOffset := binary.LittleEndian.Uint32(listData[0:4])
+ if elemOffset <= uint32(len(listData)) {
+ pa, err := engine.DecodePayloadAttributesSSZ(listData[elemOffset:], version)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+ payloadAttributes = pa
+ }
+ }
+ }
+ }
+
+ ctx := r.Context()
+ var resp engine.ForkChoiceResponse
+
+ switch version {
+ case 1:
+ resp, err = s.api.ForkchoiceUpdatedV1(*fcs, payloadAttributes)
+ case 2:
+ resp, err = s.api.ForkchoiceUpdatedV2(*fcs, payloadAttributes)
+ case 3:
+ resp, err = s.api.ForkchoiceUpdatedV3(*fcs, payloadAttributes)
+ default:
+ sszErrorResponse(w, http.StatusBadRequest, -32601, fmt.Sprintf("unsupported forkchoiceUpdated version: %d", version))
+ return
+ }
+ _ = ctx // context used by api methods internally
+
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ sszResponse(w, engine.EncodeForkChoiceResponseSSZ(&resp))
+}
+
+// --- 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) {
+ log.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
+ }
+
+ var payloadID engine.PayloadID
+ copy(payloadID[:], body)
+
+ switch version {
+ case 1:
+ result, err := s.api.GetPayloadV1(payloadID)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ envelope := &engine.ExecutionPayloadEnvelope{ExecutionPayload: result}
+ sszResponse(w, engine.EncodeExecutionPayloadEnvelopeSSZ(envelope, 1))
+ case 2:
+ result, err := s.api.GetPayloadV2(payloadID)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ sszResponse(w, engine.EncodeExecutionPayloadEnvelopeSSZ(result, 2))
+ case 3:
+ result, err := s.api.GetPayloadV3(payloadID)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ sszResponse(w, engine.EncodeExecutionPayloadEnvelopeSSZ(result, 3))
+ case 4:
+ result, err := s.api.GetPayloadV4(payloadID)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ sszResponse(w, engine.EncodeExecutionPayloadEnvelopeSSZ(result, 4))
+ case 5:
+ result, err := s.api.GetPayloadV5(payloadID)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+ // V5 uses same payload format as V4 for SSZ encoding
+ sszResponse(w, engine.EncodeExecutionPayloadEnvelopeSSZ(result, 4))
+ 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) {
+ log.Info("[SSZ-REST] Received GetBlobsV1")
+
+ body, err := readBody(r, 1024*1024)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, "failed to read request body")
+ return
+ }
+
+ hashes, err := engine.DecodeGetBlobsRequestSSZ(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ result, err := s.api.GetBlobsV1(hashes)
+ if err != nil {
+ s.handleEngineError(w, err)
+ return
+ }
+
+ sszResponse(w, encodeGetBlobsV1Response(result))
+}
+
+func encodeGetBlobsV1Response(blobs []*engine.BlobAndProofV1) []byte {
+ const blobAndProofSize = 131072 + 48
+
+ var count int
+ for _, b := range blobs {
+ if b != nil {
+ count++
+ }
+ }
+
+ fixedSize := 4 // list_offset
+ listSize := count * blobAndProofSize
+ buf := make([]byte, fixedSize+listSize)
+
+ binary.LittleEndian.PutUint32(buf[0:4], uint32(fixedSize))
+
+ pos := fixedSize
+ for _, b := range blobs {
+ if b == nil {
+ continue
+ }
+ copy(buf[pos:pos+131072], b.Blob)
+ pos += 131072
+ copy(buf[pos:pos+48], b.Proof)
+ pos += 48
+ }
+
+ return buf
+}
+
+// --- exchangeCapabilities handler ---
+
+func (s *SszRestServer) handleExchangeCapabilities(w http.ResponseWriter, r *http.Request) {
+ log.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.DecodeCapabilitiesSSZ(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+
+ result := s.api.ExchangeCapabilities(capabilities)
+ sszResponse(w, engine.EncodeCapabilitiesSSZ(result))
+}
+
+// --- getClientVersion handler ---
+
+func (s *SszRestServer) handleGetClientVersion(w http.ResponseWriter, r *http.Request) {
+ log.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.ClientVersionV1
+ if len(body) > 0 {
+ cv, err := engine.DecodeClientVersionSSZ(body)
+ if err != nil {
+ sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error())
+ return
+ }
+ callerVersion = *cv
+ }
+
+ result := s.api.GetClientVersionV1(callerVersion)
+ sszResponse(w, engine.EncodeClientVersionsSSZ(result))
+}
+
+// handleEngineError converts engine errors to appropriate HTTP error responses.
+func (s *SszRestServer) handleEngineError(w http.ResponseWriter, err error) {
+ log.Warn("[SSZ-REST] Engine error", "err", err)
+ if engineErr, ok := err.(*engine.EngineAPIError); ok {
+ code := engineErr.ErrorCode()
+ switch {
+ case code == -32602: // InvalidParams
+ sszErrorResponse(w, http.StatusBadRequest, code, err.Error())
+ case code == -38005: // UnsupportedFork
+ sszErrorResponse(w, http.StatusBadRequest, code, err.Error())
+ default:
+ sszErrorResponse(w, http.StatusInternalServerError, code, err.Error())
+ }
+ return
+ }
+ sszErrorResponse(w, http.StatusInternalServerError, -32603, err.Error())
+}
+
+// Port returns the configured port for this SSZ-REST server.
+func (s *SszRestServer) Port() int {
+ return s.port
+}
diff --git a/eth/catalyst/ssz_rest_test.go b/eth/catalyst/ssz_rest_test.go
new file mode 100644
index 0000000000..502132eeba
--- /dev/null
+++ b/eth/catalyst/ssz_rest_test.go
@@ -0,0 +1,141 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package catalyst
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/beacon/engine"
+ "github.com/ethereum/go-ethereum/node"
+ "github.com/golang-jwt/jwt/v4"
+)
+
+func makeJWTToken(secret []byte) string {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+ "iat": time.Now().Unix(),
+ })
+ ss, _ := token.SignedString(secret)
+ return ss
+}
+
+type testResponseWriter struct {
+ code int
+ headers http.Header
+ body bytes.Buffer
+}
+
+func (w *testResponseWriter) Header() http.Header { return w.headers }
+func (w *testResponseWriter) Write(b []byte) (int, error) { return w.body.Write(b) }
+func (w *testResponseWriter) WriteHeader(code int) { w.code = code }
+
+// TestSszRestJWTAuth tests that JWT auth is enforced.
+func TestSszRestJWTAuth(t *testing.T) {
+ secret := make([]byte, 32)
+ rand.Read(secret)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /test", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok"))
+ })
+
+ handler := node.NewJWTHandler(secret, mux)
+
+ // Test with valid token
+ token := makeJWTToken(secret)
+ req, _ := http.NewRequest("POST", "/test", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ rr := &testResponseWriter{headers: make(http.Header)}
+ handler.ServeHTTP(rr, req)
+ if rr.code != http.StatusOK {
+ t.Errorf("expected 200 with valid JWT, got %d", rr.code)
+ }
+
+ // Test without token
+ req2, _ := http.NewRequest("POST", "/test", nil)
+ rr2 := &testResponseWriter{headers: make(http.Header)}
+ handler.ServeHTTP(rr2, req2)
+ if rr2.code != http.StatusUnauthorized {
+ t.Errorf("expected 401 without JWT, got %d", rr2.code)
+ }
+}
+
+// TestSszRestCapabilities tests the exchange_capabilities SSZ encoding.
+func TestSszRestCapabilities(t *testing.T) {
+ caps := []string{"engine_newPayloadV4", "engine_forkchoiceUpdatedV3"}
+ encoded := engine.EncodeCapabilitiesSSZ(caps)
+ decoded, err := engine.DecodeCapabilitiesSSZ(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(decoded) != 2 {
+ t.Fatalf("expected 2 caps, got %d", len(decoded))
+ }
+ if decoded[0] != "engine_newPayloadV4" {
+ t.Errorf("unexpected cap: %s", decoded[0])
+ }
+}
+
+// TestSszRestErrorFormat tests that error responses are JSON per EIP-8161.
+func TestSszRestErrorFormat(t *testing.T) {
+ rr := &testResponseWriter{headers: make(http.Header), code: 200}
+ sszErrorResponse(rr, http.StatusBadRequest, -32602, "test error")
+
+ if rr.code != http.StatusBadRequest {
+ t.Errorf("expected 400, got %d", rr.code)
+ }
+ if rr.headers.Get("Content-Type") != "application/json" {
+ t.Errorf("expected application/json, got %s", rr.headers.Get("Content-Type"))
+ }
+
+ var errResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ if err := json.Unmarshal(rr.body.Bytes(), &errResp); err != nil {
+ t.Fatal(err)
+ }
+ if errResp.Code != -32602 {
+ t.Errorf("expected -32602, got %d", errResp.Code)
+ }
+ if errResp.Message != "test error" {
+ t.Errorf("expected 'test error', got '%s'", errResp.Message)
+ }
+}
+
+// TestSszRestSuccessFormat tests that success responses are SSZ per EIP-8161.
+func TestSszRestSuccessFormat(t *testing.T) {
+ rr := &testResponseWriter{headers: make(http.Header), code: 200}
+ data := []byte{0x01, 0x02, 0x03}
+ sszResponse(rr, data)
+
+ if rr.code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rr.code)
+ }
+ if rr.headers.Get("Content-Type") != "application/octet-stream" {
+ t.Errorf("expected application/octet-stream, got %s", rr.headers.Get("Content-Type"))
+ }
+ if !bytes.Equal(rr.body.Bytes(), data) {
+ t.Errorf("body mismatch")
+ }
+}
diff --git a/geth b/geth
new file mode 100755
index 0000000000..6e9690009f
Binary files /dev/null and b/geth differ
diff --git a/node/config.go b/node/config.go
index 255b0f0aa9..435c4ad145 100644
--- a/node/config.go
+++ b/node/config.go
@@ -147,6 +147,13 @@ type Config struct {
// for the authenticated api. This is by default {'localhost'}.
AuthVirtualHosts []string `toml:",omitempty"`
+ // SszRestEnabled enables the SSZ-REST Engine API transport (EIP-8161).
+ SszRestEnabled bool `toml:",omitempty"`
+
+ // SszRestPort is the port for the SSZ-REST Engine API server.
+ // Defaults to 8552 if not specified.
+ SszRestPort int `toml:",omitempty"`
+
// WSHost is the host interface on which to start the websocket RPC server. If
// this field is empty, no websocket API endpoint will be started.
WSHost string
diff --git a/node/defaults.go b/node/defaults.go
index 3410fa2ae5..b6de065bb2 100644
--- a/node/defaults.go
+++ b/node/defaults.go
@@ -29,12 +29,13 @@ import (
)
const (
- DefaultHTTPHost = "localhost" // Default host interface for the HTTP RPC server
- DefaultHTTPPort = 8545 // Default TCP port for the HTTP RPC server
- DefaultWSHost = "localhost" // Default host interface for the websocket RPC server
- DefaultWSPort = 8546 // Default TCP port for the websocket RPC server
- DefaultAuthHost = "localhost" // Default host interface for the authenticated apis
- DefaultAuthPort = 8551 // Default port for the authenticated apis
+ DefaultHTTPHost = "localhost" // Default host interface for the HTTP RPC server
+ DefaultHTTPPort = 8545 // Default TCP port for the HTTP RPC server
+ DefaultWSHost = "localhost" // Default host interface for the websocket RPC server
+ DefaultWSPort = 8546 // Default TCP port for the websocket RPC server
+ DefaultAuthHost = "localhost" // Default host interface for the authenticated apis
+ DefaultAuthPort = 8551 // Default port for the authenticated apis
+ DefaultSszRestPort = 8552 // Default port for the SSZ-REST Engine API (EIP-8161)
)
const (
diff --git a/node/jwt_handler.go b/node/jwt_handler.go
index 637ae19686..6a160c1800 100644
--- a/node/jwt_handler.go
+++ b/node/jwt_handler.go
@@ -31,6 +31,12 @@ type jwtHandler struct {
next http.Handler
}
+// NewJWTHandler creates a http.Handler with jwt authentication support.
+// This is the exported version for use by the SSZ-REST server (EIP-8161).
+func NewJWTHandler(secret []byte, next http.Handler) http.Handler {
+ return newJWTHandler(secret, next)
+}
+
// newJWTHandler creates a http.Handler with jwt authentication support.
func newJWTHandler(secret []byte, next http.Handler) http.Handler {
return &jwtHandler{