diff --git a/beacon/engine/ssz.go b/beacon/engine/ssz.go new file mode 100644 index 0000000000..51f11e67c1 --- /dev/null +++ b/beacon/engine/ssz.go @@ -0,0 +1,1370 @@ +// 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. +func EncodePayloadStatusSSZ(ps *PayloadStatusV1) []byte { + // Build Union[None, Hash32] for latest_valid_hash + var hashUnion []byte + if ps.LatestValidHash != nil { + hashUnion = make([]byte, 33) // selector(1) + hash(32) + hashUnion[0] = 1 + copy(hashUnion[1:33], ps.LatestValidHash[:]) + } else { + hashUnion = []byte{0} + } + + var errorBytes []byte + if ps.ValidationError != nil { + errorBytes = []byte(*ps.ValidationError) + } + + buf := make([]byte, payloadStatusFixedSize+len(hashUnion)+len(errorBytes)) + buf[0] = EngineStatusToSSZ(ps.Status) + binary.LittleEndian.PutUint32(buf[1:5], uint32(payloadStatusFixedSize)) + 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 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 Union[None, Hash32] + unionData := buf[hashOffset:errOffset] + if len(unionData) > 0 { + if unionData[0] == 1 { + if len(unionData) < 33 { + return nil, fmt.Errorf("PayloadStatusSSZ: Union hash data too short") + } + hash := common.BytesToHash(unionData[1:33]) + ps.LatestValidHash = &hash + } + } + + // 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 Union[None, uint64] for payload ID + var pidUnion []byte + if resp.PayloadID != nil { + pidUnion = make([]byte, 9) // selector(1) + 8 bytes + pidUnion[0] = 1 + copy(pidUnion[1:9], resp.PayloadID[:]) + } else { + pidUnion = []byte{0} + } + + buf := make([]byte, forkchoiceUpdatedResponseFixedSize+len(psBytes)+len(pidUnion)) + 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):], pidUnion) + 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 Union[None, PayloadID] + pidData := buf[pidOffset:] + if len(pidData) > 0 && pidData[0] == 1 { + if len(pidData) < 9 { + return nil, fmt.Errorf("ForkChoiceResponseSSZ: Union payload_id data too short") + } + var pid PayloadID + copy(pid[:], pidData[1:9]) + resp.PayloadID = &pid + } + + return resp, nil +} + +// --- CommunicationChannel SSZ --- + +// EncodeCommunicationChannelsSSZ encodes communication channels to SSZ. +func EncodeCommunicationChannelsSSZ(channels []CommunicationChannel) []byte { + if len(channels) == 0 { + return []byte{} + } + + var totalSize int + for _, ch := range channels { + totalSize += 4 + len(ch.Protocol) + 4 + len(ch.URL) + } + + buf := make([]byte, 4+totalSize) + 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 +} + +// DecodeCommunicationChannelsSSZ decodes communication channels from SSZ bytes. +func DecodeCommunicationChannelsSSZ(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 +} + +// --- 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)) + pos += 4 + } + + // 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]) + 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..28e98b4c77 --- /dev/null +++ b/beacon/engine/ssz_test.go @@ -0,0 +1,448 @@ +// 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 TestCommunicationChannelsSSZRoundTrip(t *testing.T) { + channels := []CommunicationChannel{ + {Protocol: "json_rpc", URL: "localhost:8551"}, + {Protocol: "ssz_rest", URL: "http://localhost:8552"}, + } + encoded := EncodeCommunicationChannelsSSZ(channels) + decoded, err := DecodeCommunicationChannelsSSZ(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if len(decoded) != len(channels) { + t.Fatalf("length mismatch: got %d, want %d", len(decoded), len(channels)) + } + for i, ch := range channels { + if decoded[i].Protocol != ch.Protocol { + t.Errorf("channel[%d].Protocol mismatch: got %s, want %s", i, decoded[i].Protocol, ch.Protocol) + } + if decoded[i].URL != ch.URL { + t.Errorf("channel[%d].URL mismatch: got %s, want %s", i, decoded[i].URL, ch.URL) + } + } +} + +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/beacon/engine/types.go b/beacon/engine/types.go index 5c94e67de1..d9b8183624 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -412,3 +412,15 @@ type ClientVersionV1 struct { func (v *ClientVersionV1) String() string { return fmt.Sprintf("%s-%s-%s-%s", v.Code, v.Name, v.Version, v.Commit) } + +// CommunicationChannel represents a communication protocol supported by the EL (EIP-8160). +type CommunicationChannel struct { + Protocol string `json:"protocol"` + URL string `json:"url"` +} + +// ExchangeCapabilitiesV2Response is the response to engine_exchangeCapabilitiesV2 (EIP-8160). +type ExchangeCapabilitiesV2Response struct { + Capabilities []string `json:"capabilities"` + SupportedProtocols []CommunicationChannel `json:"supportedProtocols"` +} diff --git a/cmd/geth/main.go b/cmd/geth/main.go index ca75775be2..bbfae95ec3 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -171,6 +171,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 e114eb2cd4..488885ce35 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -688,6 +688,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{ @@ -1346,6 +1358,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 1e019ffb15..ca7072171a 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -48,15 +48,37 @@ 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) + + // Configure SSZ-REST fields from the node config + cfg := stack.Config() + api.authAddr = cfg.AuthAddr + api.authPort = cfg.AuthPort + api.sszRestEnabled = cfg.SszRestEnabled + api.sszRestPort = cfg.SszRestPort + 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 } @@ -122,6 +144,12 @@ type ConsensusAPI struct { forkchoiceLock sync.Mutex // Lock for the forkChoiceUpdated method newPayloadLock sync.Mutex // Lock for the NewPayload method + + // SSZ-REST server config (EIP-8161) + sszRestEnabled bool + sszRestPort int + authAddr string + authPort int } // NewConsensusAPI creates a new consensus api for the given backend. @@ -1078,14 +1106,21 @@ 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, + "ExchangeCapabilitiesV2": true, + "GetClientCommunicationChannelsV1": 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 } @@ -1107,6 +1142,37 @@ func (api *ConsensusAPI) GetClientVersionV1(info engine.ClientVersionV1) []engin } } +// ExchangeCapabilitiesV2 extends ExchangeCapabilities with supported protocols (EIP-8160). +func (api *ConsensusAPI) ExchangeCapabilitiesV2(fromCl []string) engine.ExchangeCapabilitiesV2Response { + capabilities := api.ExchangeCapabilities(fromCl) + return engine.ExchangeCapabilitiesV2Response{ + Capabilities: capabilities, + SupportedProtocols: api.getSupportedProtocols(), + } +} + +// GetClientCommunicationChannelsV1 returns the communication protocols supported by this EL (EIP-8160). +func (api *ConsensusAPI) GetClientCommunicationChannelsV1() []engine.CommunicationChannel { + return api.getSupportedProtocols() +} + +// getSupportedProtocols returns the list of communication protocols supported by this EL. +func (api *ConsensusAPI) getSupportedProtocols() []engine.CommunicationChannel { + channels := []engine.CommunicationChannel{ + { + Protocol: "json_rpc", + URL: fmt.Sprintf("%s:%d", api.authAddr, api.authPort), + }, + } + if api.sszRestEnabled && api.sszRestPort > 0 { + channels = append(channels, engine.CommunicationChannel{ + Protocol: "ssz_rest", + URL: fmt.Sprintf("http://%s:%d", api.authAddr, api.sszRestPort), + }) + } + return channels +} + // GetPayloadBodiesByHashV1 implements engine_getPayloadBodiesByHashV1 which allows for retrieval of a list // of block bodies by the engine api. func (api *ConsensusAPI) GetPayloadBodiesByHashV1(hashes []common.Hash) []*engine.ExecutionPayloadBody { diff --git a/eth/catalyst/ssz_rest.go b/eth/catalyst/ssz_rest.go new file mode 100644 index 0000000000..1c09c0df36 --- /dev/null +++ b/eth/catalyst/ssz_rest.go @@ -0,0 +1,565 @@ +// 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) + + // 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) +} + +// --- 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)) && attrOffset < uint32(len(body)) { + unionData := body[attrOffset:] + if len(unionData) > 0 { + selector := unionData[0] + if selector == 1 && len(unionData) > 1 { + pa, err := engine.DecodePayloadAttributesSSZ(unionData[1:], 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)) +} + +// --- exchangeCapabilitiesV2 handler (EIP-8160) --- + +func (s *SszRestServer) handleExchangeCapabilitiesV2(w http.ResponseWriter, r *http.Request) { + log.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.DecodeCapabilitiesSSZ(body) + if err != nil { + sszErrorResponse(w, http.StatusBadRequest, -32602, err.Error()) + return + } + + result := s.api.ExchangeCapabilitiesV2(capabilities) + + capBuf := engine.EncodeCapabilitiesSSZ(result.Capabilities) + chanBuf := engine.EncodeCommunicationChannelsSSZ(result.SupportedProtocols) + + // SSZ Container: capabilities_offset(4) + channels_offset(4) + 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) { + log.Info("[SSZ-REST] Received GetClientCommunicationChannels") + + result := s.api.GetClientCommunicationChannelsV1() + sszResponse(w, engine.EncodeCommunicationChannelsSSZ(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..8c8c1df243 --- /dev/null +++ b/eth/catalyst/ssz_rest_test.go @@ -0,0 +1,227 @@ +// 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") + } +} + +// TestSszRestExchangeCapabilitiesV2Format tests the V2 response container format. +func TestSszRestExchangeCapabilitiesV2Format(t *testing.T) { + caps := []string{"engine_newPayloadV4", "engine_getPayloadV4"} + channels := []engine.CommunicationChannel{ + {Protocol: "json_rpc", URL: "localhost:8551"}, + {Protocol: "ssz_rest", URL: "http://localhost:8552"}, + } + + capBuf := engine.EncodeCapabilitiesSSZ(caps) + chanBuf := engine.EncodeCommunicationChannelsSSZ(channels) + + // Build the V2 response container: offset(4) + offset(4) + data + fixedSize := uint32(8) + buf := make([]byte, 8+len(capBuf)+len(chanBuf)) + le32(buf[0:4], fixedSize) + le32(buf[4:8], fixedSize+uint32(len(capBuf))) + copy(buf[8:], capBuf) + copy(buf[8+len(capBuf):], chanBuf) + + // Decode + capOffset := rd32(buf[0:4]) + chanOffset := rd32(buf[4:8]) + + decodedCaps, err := engine.DecodeCapabilitiesSSZ(buf[capOffset:chanOffset]) + if err != nil { + t.Fatal(err) + } + decodedChannels, err := engine.DecodeCommunicationChannelsSSZ(buf[chanOffset:]) + if err != nil { + t.Fatal(err) + } + + if len(decodedCaps) != 2 || decodedCaps[0] != "engine_newPayloadV4" { + t.Errorf("caps mismatch: %v", decodedCaps) + } + if len(decodedChannels) != 2 || decodedChannels[1].Protocol != "ssz_rest" { + t.Errorf("channels mismatch: %v", decodedChannels) + } +} + +// TestSszRestGetSupportedProtocols tests the getSupportedProtocols helper. +func TestSszRestGetSupportedProtocols(t *testing.T) { + api := &ConsensusAPI{ + sszRestEnabled: true, + sszRestPort: 8552, + authAddr: "127.0.0.1", + authPort: 8551, + } + + channels := api.getSupportedProtocols() + if len(channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(channels)) + } + if channels[0].Protocol != "json_rpc" { + t.Errorf("first channel should be json_rpc, got %s", channels[0].Protocol) + } + if channels[1].Protocol != "ssz_rest" { + t.Errorf("second channel should be ssz_rest, got %s", channels[1].Protocol) + } + if channels[1].URL != "http://127.0.0.1:8552" { + t.Errorf("unexpected URL: %s", channels[1].URL) + } + + // Without SSZ-REST + api2 := &ConsensusAPI{ + sszRestEnabled: false, + authAddr: "127.0.0.1", + authPort: 8551, + } + channels2 := api2.getSupportedProtocols() + if len(channels2) != 1 { + t.Fatalf("expected 1 channel without SSZ-REST, got %d", len(channels2)) + } +} + +func le32(buf []byte, v uint32) { + buf[0] = byte(v) + buf[1] = byte(v >> 8) + buf[2] = byte(v >> 16) + buf[3] = byte(v >> 24) +} + +func rd32(buf []byte) uint32 { + return uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24 +} 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 403a7f88a3..7fa5df421b 100644 --- a/node/defaults.go +++ b/node/defaults.go @@ -34,7 +34,8 @@ const ( 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 + 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{