From b694357889f5a6fe56dd84306e58a3edd3ff11c5 Mon Sep 17 00:00:00 2001 From: Marius van der Wijden Date: Mon, 8 Jun 2026 17:11:08 +0200 Subject: [PATCH] eth/catalyst: added rest-ssz engine api --- beacon/engine/ssz/attributes_forks.go | 136 +++++++++ beacon/engine/ssz/blobs.go | 238 ++++++++++++++++ beacon/engine/ssz/bodies.go | 162 +++++++++++ beacon/engine/ssz/constants.go | 50 ++++ beacon/engine/ssz/convert.go | 236 ++++++++++++++++ beacon/engine/ssz/envelopes.go | 189 +++++++++++++ beacon/engine/ssz/optional.go | 36 +++ beacon/engine/ssz/payload_amsterdam.go | 150 ++++++++++ beacon/engine/ssz/payload_forks.go | 185 ++++++++++++ beacon/engine/ssz/roundtrip_test.go | 135 +++++++++ beacon/engine/ssz/shared.go | 164 +++++++++++ eth/catalyst/api.go | 10 +- eth/engineapi/backend.go | 68 +++++ eth/engineapi/blobs.go | 149 ++++++++++ eth/engineapi/bodies.go | 107 +++++++ eth/engineapi/capabilities.go | 57 ++++ eth/engineapi/errors.go | 55 ++++ eth/engineapi/forkchoice.go | 77 +++++ eth/engineapi/get_payload.go | 93 ++++++ eth/engineapi/identity.go | 39 +++ eth/engineapi/media.go | 76 +++++ eth/engineapi/payloads.go | 89 ++++++ eth/engineapi/router.go | 106 +++++++ eth/engineapi/router_test.go | 373 +++++++++++++++++++++++++ go.mod | 3 + go.sum | 4 + node/node.go | 18 +- node/rpcstack.go | 11 +- 28 files changed, 3013 insertions(+), 3 deletions(-) create mode 100644 beacon/engine/ssz/attributes_forks.go create mode 100644 beacon/engine/ssz/blobs.go create mode 100644 beacon/engine/ssz/bodies.go create mode 100644 beacon/engine/ssz/constants.go create mode 100644 beacon/engine/ssz/convert.go create mode 100644 beacon/engine/ssz/envelopes.go create mode 100644 beacon/engine/ssz/optional.go create mode 100644 beacon/engine/ssz/payload_amsterdam.go create mode 100644 beacon/engine/ssz/payload_forks.go create mode 100644 beacon/engine/ssz/roundtrip_test.go create mode 100644 beacon/engine/ssz/shared.go create mode 100644 eth/engineapi/backend.go create mode 100644 eth/engineapi/blobs.go create mode 100644 eth/engineapi/bodies.go create mode 100644 eth/engineapi/capabilities.go create mode 100644 eth/engineapi/errors.go create mode 100644 eth/engineapi/forkchoice.go create mode 100644 eth/engineapi/get_payload.go create mode 100644 eth/engineapi/identity.go create mode 100644 eth/engineapi/media.go create mode 100644 eth/engineapi/payloads.go create mode 100644 eth/engineapi/router.go create mode 100644 eth/engineapi/router_test.go diff --git a/beacon/engine/ssz/attributes_forks.go b/beacon/engine/ssz/attributes_forks.go new file mode 100644 index 0000000000..38ecbf2e2c --- /dev/null +++ b/beacon/engine/ssz/attributes_forks.go @@ -0,0 +1,136 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/karalabe/ssz" +) + +// PayloadAttributesCancun is the Cancun/Prague/Osaka build attribute shape. +type PayloadAttributesCancun struct { + Timestamp uint64 + PrevRandao common.Hash + SuggestedFeeRecipient common.Address + Withdrawals []*Withdrawal + ParentBeaconBlockRoot common.Hash +} + +func (a *PayloadAttributesCancun) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 8 + 32 + 20 + 4(offset) + 32 = 96 + size := uint32(96) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticObjects(siz, a.Withdrawals) + return size +} + +func (a *PayloadAttributesCancun) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint64(c, &a.Timestamp) + ssz.DefineStaticBytes(c, &a.PrevRandao) + ssz.DefineStaticBytes(c, &a.SuggestedFeeRecipient) + ssz.DefineSliceOfStaticObjectsOffset(c, &a.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineStaticBytes(c, &a.ParentBeaconBlockRoot) + + ssz.DefineSliceOfStaticObjectsContent(c, &a.Withdrawals, MaxWithdrawalsPerPayload) +} + +// PayloadAttributesShanghai = Cancun minus parent_beacon_block_root. +type PayloadAttributesShanghai struct { + Timestamp uint64 + PrevRandao common.Hash + SuggestedFeeRecipient common.Address + Withdrawals []*Withdrawal +} + +func (a *PayloadAttributesShanghai) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(8 + 32 + 20 + 4) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticObjects(siz, a.Withdrawals) + return size +} + +func (a *PayloadAttributesShanghai) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint64(c, &a.Timestamp) + ssz.DefineStaticBytes(c, &a.PrevRandao) + ssz.DefineStaticBytes(c, &a.SuggestedFeeRecipient) + ssz.DefineSliceOfStaticObjectsOffset(c, &a.Withdrawals, MaxWithdrawalsPerPayload) + + ssz.DefineSliceOfStaticObjectsContent(c, &a.Withdrawals, MaxWithdrawalsPerPayload) +} + +// PayloadAttributesParis is the original (no withdrawals) shape. +type PayloadAttributesParis struct { + Timestamp uint64 + PrevRandao common.Hash + SuggestedFeeRecipient common.Address +} + +func (*PayloadAttributesParis) SizeSSZ(*ssz.Sizer) uint32 { return 60 } + +func (a *PayloadAttributesParis) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint64(c, &a.Timestamp) + ssz.DefineStaticBytes(c, &a.PrevRandao) + ssz.DefineStaticBytes(c, &a.SuggestedFeeRecipient) +} + +// ExecutionPayloadBodyCancun = Shanghai-shaped body (no BAL). +type ExecutionPayloadBodyCancun struct { + Transactions [][]byte + Withdrawals []*Withdrawal +} + +func (b *ExecutionPayloadBodyCancun) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(8) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicBytes(siz, b.Transactions) + size += ssz.SizeSliceOfStaticObjects(siz, b.Withdrawals) + return size +} + +func (b *ExecutionPayloadBodyCancun) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicBytesOffset(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsOffset(c, &b.Withdrawals, MaxWithdrawalsPerPayload) + + ssz.DefineSliceOfDynamicBytesContent(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsContent(c, &b.Withdrawals, MaxWithdrawalsPerPayload) +} + +// ExecutionPayloadBodyParis is the original transactions-only body. +type ExecutionPayloadBodyParis struct { + Transactions [][]byte +} + +func (b *ExecutionPayloadBodyParis) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicBytes(siz, b.Transactions) + return size +} + +func (b *ExecutionPayloadBodyParis) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicBytesOffset(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + + ssz.DefineSliceOfDynamicBytesContent(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) +} diff --git a/beacon/engine/ssz/blobs.go b/beacon/engine/ssz/blobs.go new file mode 100644 index 0000000000..817ee1c5fe --- /dev/null +++ b/beacon/engine/ssz/blobs.go @@ -0,0 +1,238 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/karalabe/ssz" +) + +// BlobsVersionedHashesRequest is the SSZ body of POST /blobs/v{1,2,3}. +type BlobsVersionedHashesRequest struct { + VersionedHashes []common.Hash +} + +func (r *BlobsVersionedHashesRequest) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, r.VersionedHashes) + return size +} + +func (r *BlobsVersionedHashesRequest) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticBytesOffset(c, &r.VersionedHashes, MaxBlobsRequest) + ssz.DefineSliceOfStaticBytesContent(c, &r.VersionedHashes, MaxBlobsRequest) +} + +// BlobAndProofV1 — Cancun whole-blob with one KZG proof. +type BlobAndProofV1 struct { + Blob *Blob + Proof [48]byte +} + +func (*BlobAndProofV1) SizeSSZ(*ssz.Sizer) uint32 { return BytesPerBlob + 48 } + +func (e *BlobAndProofV1) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticObject(c, &e.Blob) + ssz.DefineStaticBytes(c, &e.Proof) +} + +// BlobV1Entry pairs an availability flag with the contents. +type BlobV1Entry struct { + Available bool + Contents *BlobAndProofV1 +} + +func (*BlobV1Entry) SizeSSZ(*ssz.Sizer) uint32 { return 1 + BytesPerBlob + 48 } + +func (e *BlobV1Entry) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineStaticObject(c, &e.Contents) +} + +// BlobsV1Response is the SSZ response of POST /blobs/v1. +type BlobsV1Response struct { + Entries []*BlobV1Entry +} + +func (r *BlobsV1Response) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticObjects(siz, r.Entries) + return size +} + +func (r *BlobsV1Response) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticObjectsOffset(c, &r.Entries, MaxBlobsRequest) + ssz.DefineSliceOfStaticObjectsContent(c, &r.Entries, MaxBlobsRequest) +} + +// BlobAndProofV2 — Osaka whole-blob with CELLS_PER_EXT_BLOB cell proofs. +// proofs is a List with maxItems=128, so it is dynamic. +type BlobAndProofV2 struct { + Blob *Blob + Proofs [][48]byte +} + +func (b *BlobAndProofV2) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // blob (static) + offset(proofs) + size := uint32(BytesPerBlob + 4) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, b.Proofs) + return size +} + +func (b *BlobAndProofV2) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticObject(c, &b.Blob) + ssz.DefineSliceOfStaticBytesOffset(c, &b.Proofs, CellsPerExtBlob) + + ssz.DefineSliceOfStaticBytesContent(c, &b.Proofs, CellsPerExtBlob) +} + +// BlobV2Entry is shared by /blobs/v2 and /blobs/v3 (V3 just enables +// per-entry available=false reporting). +type BlobV2Entry struct { + Available bool + Contents *BlobAndProofV2 +} + +func (e *BlobV2Entry) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(1 + 4) // bool + offset(contents) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Contents) + return size +} + +func (e *BlobV2Entry) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineDynamicObjectOffset(c, &e.Contents) + + ssz.DefineDynamicObjectContent(c, &e.Contents) +} + +type BlobsV2Response struct { + Entries []*BlobV2Entry +} + +func (r *BlobsV2Response) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, r.Entries) + return size +} + +func (r *BlobsV2Response) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBlobsRequest) + ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBlobsRequest) +} + +// BlobsV4Request is the SSZ body of POST /blobs/v4 (cell-range selection). +type BlobsV4Request struct { + VersionedHashes []common.Hash + IndicesBitarray *Bitvector128 +} + +func (r *BlobsV4Request) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // offset(hashes) + 16(bitvector) + size := uint32(4 + CellsPerExtBlob/8) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, r.VersionedHashes) + return size +} + +func (r *BlobsV4Request) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticBytesOffset(c, &r.VersionedHashes, MaxBlobsRequest) + ssz.DefineStaticObject(c, &r.IndicesBitarray) + + ssz.DefineSliceOfStaticBytesContent(c, &r.VersionedHashes, MaxBlobsRequest) +} + +// BlobCellsAndProofs — Amsterdam cell-range response payload. +type BlobCellsAndProofs struct { + BlobCells []*OptionalBlobCell // List[Optional[ByteVector[BYTES_PER_CELL]], CELLS_PER_EXT_BLOB] + Proofs []*OptionalProof // List[Optional[Bytes48], CELLS_PER_EXT_BLOB] +} + +func (b *BlobCellsAndProofs) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(8) // 2 offsets + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, b.BlobCells) + size += ssz.SizeSliceOfDynamicObjects(siz, b.Proofs) + return size +} + +func (b *BlobCellsAndProofs) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &b.BlobCells, CellsPerExtBlob) + ssz.DefineSliceOfDynamicObjectsOffset(c, &b.Proofs, CellsPerExtBlob) + + ssz.DefineSliceOfDynamicObjectsContent(c, &b.BlobCells, CellsPerExtBlob) + ssz.DefineSliceOfDynamicObjectsContent(c, &b.Proofs, CellsPerExtBlob) +} + +// BlobV4Entry pairs availability with the cell-range contents. +type BlobV4Entry struct { + Available bool + Contents *BlobCellsAndProofs +} + +func (e *BlobV4Entry) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(1 + 4) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Contents) + return size +} + +func (e *BlobV4Entry) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineDynamicObjectOffset(c, &e.Contents) + + ssz.DefineDynamicObjectContent(c, &e.Contents) +} + +type BlobsV4Response struct { + Entries []*BlobV4Entry +} + +func (r *BlobsV4Response) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, r.Entries) + return size +} + +func (r *BlobsV4Response) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBlobsRequest) + ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBlobsRequest) +} diff --git a/beacon/engine/ssz/bodies.go b/beacon/engine/ssz/bodies.go new file mode 100644 index 0000000000..6753309fe9 --- /dev/null +++ b/beacon/engine/ssz/bodies.go @@ -0,0 +1,162 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/karalabe/ssz" +) + +// BodiesByHashRequest is the SSZ body of POST /{fork}/bodies/hash. +type BodiesByHashRequest struct { + BlockHashes []common.Hash +} + +func (r *BodiesByHashRequest) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, r.BlockHashes) + return size +} + +func (r *BodiesByHashRequest) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticBytesOffset(c, &r.BlockHashes, MaxBodiesRequest) + ssz.DefineSliceOfStaticBytesContent(c, &r.BlockHashes, MaxBodiesRequest) +} + +// BodyEntryAmsterdam is the per-block entry returned by /amsterdam/bodies/... +type BodyEntryAmsterdam struct { + Available bool + Body *ExecutionPayloadBodyAmsterdam +} + +func (e *BodyEntryAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(1 + 4) // bool + offset + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Body) + return size +} + +func (e *BodyEntryAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineDynamicObjectOffset(c, &e.Body) + + ssz.DefineDynamicObjectContent(c, &e.Body) +} + +// BodiesResponseAmsterdam is the SSZ response of /amsterdam/bodies/... +type BodiesResponseAmsterdam struct { + Entries []*BodyEntryAmsterdam +} + +func (r *BodiesResponseAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, r.Entries) + return size +} + +func (r *BodiesResponseAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBodiesRequest) + ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBodiesRequest) +} + +// BodyEntryCancun is the /cancun/bodies/... entry (Shanghai-shape body). +type BodyEntryCancun struct { + Available bool + Body *ExecutionPayloadBodyCancun +} + +func (e *BodyEntryCancun) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(1 + 4) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Body) + return size +} + +func (e *BodyEntryCancun) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineDynamicObjectOffset(c, &e.Body) + + ssz.DefineDynamicObjectContent(c, &e.Body) +} + +type BodiesResponseCancun struct { + Entries []*BodyEntryCancun +} + +func (r *BodiesResponseCancun) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, r.Entries) + return size +} + +func (r *BodiesResponseCancun) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBodiesRequest) + ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBodiesRequest) +} + +// BodyEntryParis is the /paris/bodies/... entry (transactions only). +type BodyEntryParis struct { + Available bool + Body *ExecutionPayloadBodyParis +} + +func (e *BodyEntryParis) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(1 + 4) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Body) + return size +} + +func (e *BodyEntryParis) DefineSSZ(c *ssz.Codec) { + ssz.DefineBool(c, &e.Available) + ssz.DefineDynamicObjectOffset(c, &e.Body) + + ssz.DefineDynamicObjectContent(c, &e.Body) +} + +type BodiesResponseParis struct { + Entries []*BodyEntryParis +} + +func (r *BodiesResponseParis) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + size := uint32(4) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, r.Entries) + return size +} + +func (r *BodiesResponseParis) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBodiesRequest) + ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBodiesRequest) +} diff --git a/beacon/engine/ssz/constants.go b/beacon/engine/ssz/constants.go new file mode 100644 index 0000000000..1ceb71173e --- /dev/null +++ b/beacon/engine/ssz/constants.go @@ -0,0 +1,50 @@ +// Copyright 2026 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 ssz contains the SSZ wire types for the REST-SSZ Engine API +// (execution-apis PR #793). The containers here are intentionally separate +// from beacon/engine.ExecutableData & friends; they map onto the JSON-RPC +// types via convert.go. +package ssz + +// MAX_* constants from refactor-ssz.md. +const ( + MaxBytesPerTx = 1 << 30 // 1 GiB, EIP-4844 + MaxTxsPerPayload = 1 << 20 // 1,048,576, Bellatrix + MaxWithdrawalsPerPayload = 16 // Capella + MaxExtraDataBytes = 32 // Bellatrix + MaxBlobCommitmentsPerBlock = 1 << 12 // 4,096, Deneb + FieldElementsPerBlob = 4096 // EIP-4844 + BytesPerFieldElement = 32 // EIP-4844 + BytesPerBlob = FieldElementsPerBlob * BytesPerFieldElement + CellsPerExtBlob = 128 // EIP-7594 + BytesPerCell = BytesPerBlob / CellsPerExtBlob + MaxBalBytes = MaxBytesPerTx // EIP-7928 placeholder + MaxExecutionRequestsPerPayload = 1 << 8 // 256, EIP-7685 + MaxBytesPerExecutionRequest = MaxBytesPerTx // placeholder + MaxVersionedHashesPerRequest = 128 // Osaka + MaxBlobsRequest = MaxVersionedHashesPerRequest + MaxBodiesRequest = 128 // see spec open question; capabilities example uses 128 + MaxErrorBytes = 1024 +) + +// Status enum values for PayloadStatus. +const ( + StatusValid uint8 = 0 + StatusInvalid uint8 = 1 + StatusSyncing uint8 = 2 + StatusAccepted uint8 = 3 +) diff --git a/beacon/engine/ssz/convert.go b/beacon/engine/ssz/convert.go new file mode 100644 index 0000000000..70c67d9798 --- /dev/null +++ b/beacon/engine/ssz/convert.go @@ -0,0 +1,236 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/holiman/uint256" +) + +// statusToEnum maps the JSON-RPC PayloadStatusV1.Status string to the SSZ +// uint8. ACCEPTED is only valid on /payloads responses; the caller is +// responsible for downstream restrictions. +func statusToEnum(s string) uint8 { + switch s { + case engine.VALID: + return StatusValid + case engine.INVALID: + return StatusInvalid + case engine.SYNCING: + return StatusSyncing + case engine.ACCEPTED: + return StatusAccepted + } + return StatusInvalid +} + +// statusFromEnum is the inverse of statusToEnum. +func statusFromEnum(s uint8) string { + switch s { + case StatusValid: + return engine.VALID + case StatusInvalid: + return engine.INVALID + case StatusSyncing: + return engine.SYNCING + case StatusAccepted: + return engine.ACCEPTED + } + return engine.INVALID +} + +// PayloadStatusFromV1 converts the JSON-RPC PayloadStatusV1 into its SSZ wire form. +func PayloadStatusFromV1(s *engine.PayloadStatusV1) *PayloadStatus { + out := &PayloadStatus{Status: statusToEnum(s.Status)} + if s.LatestValidHash != nil { + out.LatestValidHash = []common.Hash{*s.LatestValidHash} + } + if s.ValidationError != nil { + out.ValidationError = [][]byte{[]byte(*s.ValidationError)} + } + return out +} + +// PayloadStatusToV1 is the inverse of PayloadStatusFromV1. +func PayloadStatusToV1(s *PayloadStatus) engine.PayloadStatusV1 { + out := engine.PayloadStatusV1{Status: statusFromEnum(s.Status)} + if len(s.LatestValidHash) == 1 { + h := s.LatestValidHash[0] + out.LatestValidHash = &h + } + if len(s.ValidationError) == 1 { + msg := string(s.ValidationError[0]) + out.ValidationError = &msg + } + return out +} + +// ForkchoiceStateFromV1 converts ForkchoiceStateV1 to its SSZ form. +func ForkchoiceStateFromV1(s engine.ForkchoiceStateV1) *ForkchoiceState { + return &ForkchoiceState{ + HeadBlockHash: s.HeadBlockHash, + SafeBlockHash: s.SafeBlockHash, + FinalizedBlockHash: s.FinalizedBlockHash, + } +} + +// ForkchoiceStateToV1 is the inverse of ForkchoiceStateFromV1. +func ForkchoiceStateToV1(s *ForkchoiceState) engine.ForkchoiceStateV1 { + return engine.ForkchoiceStateV1{ + HeadBlockHash: s.HeadBlockHash, + SafeBlockHash: s.SafeBlockHash, + FinalizedBlockHash: s.FinalizedBlockHash, + } +} + +// withdrawalsFromTypes converts geth Withdrawals into SSZ Withdrawals. +func withdrawalsFromTypes(ws []*types.Withdrawal) []*Withdrawal { + out := make([]*Withdrawal, len(ws)) + for i, w := range ws { + out[i] = &Withdrawal{ + Index: w.Index, + ValidatorIndex: w.Validator, + Address: w.Address, + Amount: w.Amount, + } + } + return out +} + +// withdrawalsToTypes is the inverse of withdrawalsFromTypes. +func withdrawalsToTypes(ws []*Withdrawal) []*types.Withdrawal { + out := make([]*types.Withdrawal, len(ws)) + for i, w := range ws { + out[i] = &types.Withdrawal{ + Index: w.Index, + Validator: w.ValidatorIndex, + Address: w.Address, + Amount: w.Amount, + } + } + return out +} + +// PayloadAttributesAmsterdamFromEngine converts a JSON-RPC engine.PayloadAttributes +// into its Amsterdam SSZ form. The caller is responsible for ensuring the +// attributes carry every Amsterdam-required field. +func PayloadAttributesAmsterdamFromEngine(a *engine.PayloadAttributes) *PayloadAttributesAmsterdam { + out := &PayloadAttributesAmsterdam{ + Timestamp: a.Timestamp, + PrevRandao: a.Random, + SuggestedFeeRecipient: a.SuggestedFeeRecipient, + Withdrawals: withdrawalsFromTypes(a.Withdrawals), + } + if a.BeaconRoot != nil { + out.ParentBeaconBlockRoot = *a.BeaconRoot + } + if a.SlotNumber != nil { + out.SlotNumber = *a.SlotNumber + } + return out +} + +// PayloadAttributesAmsterdamToEngine is the inverse of the From helper. The +// target_gas_limit field has no representation in engine.PayloadAttributes +// today and is dropped on the way down; the caller can fish it out of the SSZ +// struct directly. +func PayloadAttributesAmsterdamToEngine(a *PayloadAttributesAmsterdam) *engine.PayloadAttributes { + root := a.ParentBeaconBlockRoot + slot := a.SlotNumber + return &engine.PayloadAttributes{ + Timestamp: a.Timestamp, + Random: a.PrevRandao, + SuggestedFeeRecipient: a.SuggestedFeeRecipient, + Withdrawals: withdrawalsToTypes(a.Withdrawals), + BeaconRoot: &root, + SlotNumber: &slot, + } +} + +// ExecutionPayloadAmsterdamFromEngine converts an ExecutableData into the SSZ +// Amsterdam payload shape. The block_access_list and slot_number fields are +// expected to be present; missing values yield empty/zero defaults. +func ExecutionPayloadAmsterdamFromEngine(d *engine.ExecutableData) *ExecutionPayloadAmsterdam { + var bloom [256]byte + copy(bloom[:], d.LogsBloom) + var fee *uint256.Int + if d.BaseFeePerGas != nil { + fee = new(uint256.Int) + fee.SetFromBig(d.BaseFeePerGas) + } + out := &ExecutionPayloadAmsterdam{ + ParentHash: d.ParentHash, + FeeRecipient: d.FeeRecipient, + StateRoot: d.StateRoot, + ReceiptsRoot: d.ReceiptsRoot, + LogsBloom: bloom, + PrevRandao: d.Random, + BlockNumber: d.Number, + GasLimit: d.GasLimit, + GasUsed: d.GasUsed, + Timestamp: d.Timestamp, + ExtraData: append([]byte(nil), d.ExtraData...), + BaseFeePerGas: fee, + BlockHash: d.BlockHash, + Transactions: d.Transactions, + Withdrawals: withdrawalsFromTypes(d.Withdrawals), + } + if d.BlobGasUsed != nil { + out.BlobGasUsed = *d.BlobGasUsed + } + if d.ExcessBlobGas != nil { + out.ExcessBlobGas = *d.ExcessBlobGas + } + if d.SlotNumber != nil { + out.SlotNumber = *d.SlotNumber + } + // BlockAccessList is not yet wired through ExecutableData; the caller + // passes it as a separate field on the envelope when constructing the + // final wire form. + return out +} + +// ExecutionPayloadAmsterdamToEngine is the inverse helper. The caller is +// responsible for the BAL payload that ExecutableData doesn't yet carry. +func ExecutionPayloadAmsterdamToEngine(p *ExecutionPayloadAmsterdam) *engine.ExecutableData { + bloom := append([]byte(nil), p.LogsBloom[:]...) + out := &engine.ExecutableData{ + ParentHash: p.ParentHash, + FeeRecipient: p.FeeRecipient, + StateRoot: p.StateRoot, + ReceiptsRoot: p.ReceiptsRoot, + LogsBloom: bloom, + Random: p.PrevRandao, + Number: p.BlockNumber, + GasLimit: p.GasLimit, + GasUsed: p.GasUsed, + Timestamp: p.Timestamp, + ExtraData: append([]byte(nil), p.ExtraData...), + BlockHash: p.BlockHash, + Transactions: p.Transactions, + Withdrawals: withdrawalsToTypes(p.Withdrawals), + BlobGasUsed: &p.BlobGasUsed, + ExcessBlobGas: &p.ExcessBlobGas, + SlotNumber: &p.SlotNumber, + } + if p.BaseFeePerGas != nil { + out.BaseFeePerGas = p.BaseFeePerGas.ToBig() + } + return out +} diff --git a/beacon/engine/ssz/envelopes.go b/beacon/engine/ssz/envelopes.go new file mode 100644 index 0000000000..d69641e555 --- /dev/null +++ b/beacon/engine/ssz/envelopes.go @@ -0,0 +1,189 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + "github.com/karalabe/ssz" +) + +// ExecutionPayloadEnvelopeAmsterdam is the request body of +// POST /amsterdam/payloads. +type ExecutionPayloadEnvelopeAmsterdam struct { + Payload *ExecutionPayloadAmsterdam + ParentBeaconBlockRoot common.Hash + ExecutionRequests [][]byte +} + +func (e *ExecutionPayloadEnvelopeAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // offset(payload) + 32(root) + offset(requests) + size := uint32(40) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, e.Payload) + size += ssz.SizeSliceOfDynamicBytes(siz, e.ExecutionRequests) + return size +} + +func (e *ExecutionPayloadEnvelopeAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineDynamicObjectOffset(c, &e.Payload) + ssz.DefineStaticBytes(c, &e.ParentBeaconBlockRoot) + ssz.DefineSliceOfDynamicBytesOffset(c, &e.ExecutionRequests, MaxExecutionRequestsPerPayload, MaxBytesPerExecutionRequest) + + ssz.DefineDynamicObjectContent(c, &e.Payload) + ssz.DefineSliceOfDynamicBytesContent(c, &e.ExecutionRequests, MaxExecutionRequestsPerPayload, MaxBytesPerExecutionRequest) +} + +// BlobsBundleV2 is the cell-proof blob bundle carried by BuiltPayload +// from Osaka onwards. +type BlobsBundleV2 struct { + Commitments [][48]byte + Proofs [][48]byte + Blobs []*Blob +} + +func (b *BlobsBundleV2) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 3 offsets + size := uint32(12) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, b.Commitments) + size += ssz.SizeSliceOfStaticBytes(siz, b.Proofs) + size += ssz.SizeSliceOfStaticObjects(siz, b.Blobs) + return size +} + +func (b *BlobsBundleV2) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticBytesOffset(c, &b.Commitments, MaxBlobCommitmentsPerBlock) + ssz.DefineSliceOfStaticBytesOffset(c, &b.Proofs, MaxBlobCommitmentsPerBlock*CellsPerExtBlob) + ssz.DefineSliceOfStaticObjectsOffset(c, &b.Blobs, MaxBlobCommitmentsPerBlock) + + ssz.DefineSliceOfStaticBytesContent(c, &b.Commitments, MaxBlobCommitmentsPerBlock) + ssz.DefineSliceOfStaticBytesContent(c, &b.Proofs, MaxBlobCommitmentsPerBlock*CellsPerExtBlob) + ssz.DefineSliceOfStaticObjectsContent(c, &b.Blobs, MaxBlobCommitmentsPerBlock) +} + +// BuiltPayloadAmsterdam is the response of GET /amsterdam/payloads/{id}. +type BuiltPayloadAmsterdam struct { + Payload *ExecutionPayloadAmsterdam + BlockValue *uint256.Int + BlobsBundle *BlobsBundleV2 + ExecutionRequests [][]byte + ShouldOverrideBuilder bool +} + +func (p *BuiltPayloadAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // offset(payload) + 32(value) + offset(bundle) + offset(requests) + 1(override) + size := uint32(4 + 32 + 4 + 4 + 1) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, p.Payload) + size += ssz.SizeDynamicObject(siz, p.BlobsBundle) + size += ssz.SizeSliceOfDynamicBytes(siz, p.ExecutionRequests) + return size +} + +func (p *BuiltPayloadAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineDynamicObjectOffset(c, &p.Payload) + ssz.DefineUint256(c, &p.BlockValue) + ssz.DefineDynamicObjectOffset(c, &p.BlobsBundle) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.ExecutionRequests, MaxExecutionRequestsPerPayload, MaxBytesPerExecutionRequest) + ssz.DefineBool(c, &p.ShouldOverrideBuilder) + + ssz.DefineDynamicObjectContent(c, &p.Payload) + ssz.DefineDynamicObjectContent(c, &p.BlobsBundle) + ssz.DefineSliceOfDynamicBytesContent(c, &p.ExecutionRequests, MaxExecutionRequestsPerPayload, MaxBytesPerExecutionRequest) +} + +// ForkchoiceUpdateAmsterdam is the request body of POST /amsterdam/forkchoice. +// +// PayloadAttributes is modelled as Optional[PayloadAttributesAmsterdam] = a slice +// of length 0 or 1. CustodyColumns is Optional[Bitvector[128]] — a slice of +// length 0 or 1 of the 16-byte Bitvector wrapper. +type ForkchoiceUpdateAmsterdam struct { + ForkchoiceState *ForkchoiceState + PayloadAttributes []*PayloadAttributesAmsterdam + CustodyColumns []*Bitvector128 +} + +func (f *ForkchoiceUpdateAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 96 (state) + 4(offset attrs) + 4(offset custody) + size := uint32(96 + 8) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicObjects(siz, f.PayloadAttributes) + size += ssz.SizeSliceOfStaticObjects(siz, f.CustodyColumns) + return size +} + +func (f *ForkchoiceUpdateAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticObject(c, &f.ForkchoiceState) + ssz.DefineSliceOfDynamicObjectsOffset(c, &f.PayloadAttributes, 1) + ssz.DefineSliceOfStaticObjectsOffset(c, &f.CustodyColumns, 1) + + ssz.DefineSliceOfDynamicObjectsContent(c, &f.PayloadAttributes, 1) + ssz.DefineSliceOfStaticObjectsContent(c, &f.CustodyColumns, 1) +} + +// Validate enforces the Optional[T] = List[T,1] length invariants. +func (f *ForkchoiceUpdateAmsterdam) Validate() error { + if err := checkOptional(f.PayloadAttributes); err != nil { + return err + } + return checkOptional(f.CustodyColumns) +} + +// ForkchoiceUpdateResponseAmsterdam is the response of POST /amsterdam/forkchoice. +type ForkchoiceUpdateResponseAmsterdam struct { + PayloadStatus *PayloadStatus + PayloadID [][8]byte // Optional[Bytes8] +} + +func (r *ForkchoiceUpdateResponseAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // offset(status) + offset(id) + size := uint32(8) + if fixed { + return size + } + size += ssz.SizeDynamicObject(siz, r.PayloadStatus) + size += ssz.SizeSliceOfStaticBytes(siz, r.PayloadID) + return size +} + +func (r *ForkchoiceUpdateResponseAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineDynamicObjectOffset(c, &r.PayloadStatus) + ssz.DefineSliceOfStaticBytesOffset(c, &r.PayloadID, 1) + + ssz.DefineDynamicObjectContent(c, &r.PayloadStatus) + ssz.DefineSliceOfStaticBytesContent(c, &r.PayloadID, 1) +} + +// Validate enforces optional-list invariants and inner status invariants. +func (r *ForkchoiceUpdateResponseAmsterdam) Validate() error { + if err := checkOptional(r.PayloadID); err != nil { + return err + } + if r.PayloadStatus == nil { + return nil + } + return r.PayloadStatus.Validate() +} diff --git a/beacon/engine/ssz/optional.go b/beacon/engine/ssz/optional.go new file mode 100644 index 0000000000..d24b4ecee8 --- /dev/null +++ b/beacon/engine/ssz/optional.go @@ -0,0 +1,36 @@ +// Copyright 2026 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 ssz + +import "errors" + +// The spec models `Optional[T]` as `List[T, 1]`: 0 elements = absent, +// 1 element = present. We mirror that on the Go side by representing +// optional fields as length-0-or-1 slices and validating the length +// during a post-decode pass. + +// errOptionalTooLong is returned when an optional list decoded with more +// than one element. +var errOptionalTooLong = errors.New("ssz: optional list has more than 1 element") + +// checkOptional validates that an optional slice has length 0 or 1. +func checkOptional[T any](s []T) error { + if len(s) > 1 { + return errOptionalTooLong + } + return nil +} diff --git a/beacon/engine/ssz/payload_amsterdam.go b/beacon/engine/ssz/payload_amsterdam.go new file mode 100644 index 0000000000..a21dde94d7 --- /dev/null +++ b/beacon/engine/ssz/payload_amsterdam.go @@ -0,0 +1,150 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + "github.com/karalabe/ssz" +) + +// ExecutionPayloadAmsterdam carries the Amsterdam-shaped payload over the wire. +type ExecutionPayloadAmsterdam struct { + ParentHash common.Hash + FeeRecipient common.Address + StateRoot common.Hash + ReceiptsRoot common.Hash + LogsBloom [256]byte + PrevRandao common.Hash + BlockNumber uint64 + GasLimit uint64 + GasUsed uint64 + Timestamp uint64 + ExtraData []byte + BaseFeePerGas *uint256.Int + BlockHash common.Hash + Transactions [][]byte + Withdrawals []*Withdrawal + BlobGasUsed uint64 + ExcessBlobGas uint64 + BlockAccessList []byte + SlotNumber uint64 +} + +func (p *ExecutionPayloadAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 5 hashes(32) + addr(20) + bloom(256) + 4 u64s + uint256(32) + // + 4 offsets(4) + 2 u64s (blob gas) + u64 (slot) = 540 + size := uint32(5*32 + 20 + 256 + 4*8 + 32 + 4*4 + 2*8 + 8) + if fixed { + return size + } + size += ssz.SizeDynamicBytes(siz, p.ExtraData) + size += ssz.SizeSliceOfDynamicBytes(siz, p.Transactions) + size += ssz.SizeSliceOfStaticObjects(siz, p.Withdrawals) + size += ssz.SizeDynamicBytes(siz, p.BlockAccessList) + return size +} + +func (p *ExecutionPayloadAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticBytes(c, &p.ParentHash) + ssz.DefineStaticBytes(c, &p.FeeRecipient) + ssz.DefineStaticBytes(c, &p.StateRoot) + ssz.DefineStaticBytes(c, &p.ReceiptsRoot) + ssz.DefineStaticBytes(c, &p.LogsBloom) + ssz.DefineStaticBytes(c, &p.PrevRandao) + ssz.DefineUint64(c, &p.BlockNumber) + ssz.DefineUint64(c, &p.GasLimit) + ssz.DefineUint64(c, &p.GasUsed) + ssz.DefineUint64(c, &p.Timestamp) + ssz.DefineDynamicBytesOffset(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineUint256(c, &p.BaseFeePerGas) + ssz.DefineStaticBytes(c, &p.BlockHash) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsOffset(c, &p.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineUint64(c, &p.BlobGasUsed) + ssz.DefineUint64(c, &p.ExcessBlobGas) + ssz.DefineDynamicBytesOffset(c, &p.BlockAccessList, MaxBalBytes) + ssz.DefineUint64(c, &p.SlotNumber) + + ssz.DefineDynamicBytesContent(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineSliceOfDynamicBytesContent(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsContent(c, &p.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineDynamicBytesContent(c, &p.BlockAccessList, MaxBalBytes) +} + +// PayloadAttributesAmsterdam carries Amsterdam build attributes. +type PayloadAttributesAmsterdam struct { + Timestamp uint64 + PrevRandao common.Hash + SuggestedFeeRecipient common.Address + Withdrawals []*Withdrawal + ParentBeaconBlockRoot common.Hash + SlotNumber uint64 + TargetGasLimit uint64 +} + +func (a *PayloadAttributesAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // timestamp(8) + randao(32) + fee_recipient(20) + offset(4) + // + parent_beacon_block_root(32) + slot(8) + target_gas(8) = 112 + size := uint32(112) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticObjects(siz, a.Withdrawals) + return size +} + +func (a *PayloadAttributesAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint64(c, &a.Timestamp) + ssz.DefineStaticBytes(c, &a.PrevRandao) + ssz.DefineStaticBytes(c, &a.SuggestedFeeRecipient) + ssz.DefineSliceOfStaticObjectsOffset(c, &a.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineStaticBytes(c, &a.ParentBeaconBlockRoot) + ssz.DefineUint64(c, &a.SlotNumber) + ssz.DefineUint64(c, &a.TargetGasLimit) + + ssz.DefineSliceOfStaticObjectsContent(c, &a.Withdrawals, MaxWithdrawalsPerPayload) +} + +// ExecutionPayloadBodyAmsterdam mirrors the /amsterdam/bodies response shape. +type ExecutionPayloadBodyAmsterdam struct { + Transactions [][]byte + Withdrawals []*Withdrawal + BlockAccessList []byte +} + +func (b *ExecutionPayloadBodyAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 3 offsets + size := uint32(12) + if fixed { + return size + } + size += ssz.SizeSliceOfDynamicBytes(siz, b.Transactions) + size += ssz.SizeSliceOfStaticObjects(siz, b.Withdrawals) + size += ssz.SizeDynamicBytes(siz, b.BlockAccessList) + return size +} + +func (b *ExecutionPayloadBodyAmsterdam) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfDynamicBytesOffset(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsOffset(c, &b.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineDynamicBytesOffset(c, &b.BlockAccessList, MaxBalBytes) + + ssz.DefineSliceOfDynamicBytesContent(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsContent(c, &b.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineDynamicBytesContent(c, &b.BlockAccessList, MaxBalBytes) +} diff --git a/beacon/engine/ssz/payload_forks.go b/beacon/engine/ssz/payload_forks.go new file mode 100644 index 0000000000..9d48d8706d --- /dev/null +++ b/beacon/engine/ssz/payload_forks.go @@ -0,0 +1,185 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + "github.com/karalabe/ssz" +) + +// ExecutionPayloadCancun is the Cancun/Prague/Osaka payload shape. +// Prague and Osaka don't change the inner payload — execution_requests +// and the BlobsBundle revision change at the envelope level, not here. +type ExecutionPayloadCancun struct { + ParentHash common.Hash + FeeRecipient common.Address + StateRoot common.Hash + ReceiptsRoot common.Hash + LogsBloom [256]byte + PrevRandao common.Hash + BlockNumber uint64 + GasLimit uint64 + GasUsed uint64 + Timestamp uint64 + ExtraData []byte + BaseFeePerGas *uint256.Int + BlockHash common.Hash + Transactions [][]byte + Withdrawals []*Withdrawal + BlobGasUsed uint64 + ExcessBlobGas uint64 +} + +func (p *ExecutionPayloadCancun) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 5 hashes(32) + addr(20) + bloom(256) + 4 u64s + uint256(32) + // + 3 offsets(4) + 2 u64s (blob gas) = 528 + size := uint32(5*32 + 20 + 256 + 4*8 + 32 + 3*4 + 2*8) + if fixed { + return size + } + size += ssz.SizeDynamicBytes(siz, p.ExtraData) + size += ssz.SizeSliceOfDynamicBytes(siz, p.Transactions) + size += ssz.SizeSliceOfStaticObjects(siz, p.Withdrawals) + return size +} + +func (p *ExecutionPayloadCancun) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticBytes(c, &p.ParentHash) + ssz.DefineStaticBytes(c, &p.FeeRecipient) + ssz.DefineStaticBytes(c, &p.StateRoot) + ssz.DefineStaticBytes(c, &p.ReceiptsRoot) + ssz.DefineStaticBytes(c, &p.LogsBloom) + ssz.DefineStaticBytes(c, &p.PrevRandao) + ssz.DefineUint64(c, &p.BlockNumber) + ssz.DefineUint64(c, &p.GasLimit) + ssz.DefineUint64(c, &p.GasUsed) + ssz.DefineUint64(c, &p.Timestamp) + ssz.DefineDynamicBytesOffset(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineUint256(c, &p.BaseFeePerGas) + ssz.DefineStaticBytes(c, &p.BlockHash) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsOffset(c, &p.Withdrawals, MaxWithdrawalsPerPayload) + ssz.DefineUint64(c, &p.BlobGasUsed) + ssz.DefineUint64(c, &p.ExcessBlobGas) + + ssz.DefineDynamicBytesContent(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineSliceOfDynamicBytesContent(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsContent(c, &p.Withdrawals, MaxWithdrawalsPerPayload) +} + +// ExecutionPayloadShanghai is the Shanghai payload shape (Cancun minus blob gas). +type ExecutionPayloadShanghai struct { + ParentHash common.Hash + FeeRecipient common.Address + StateRoot common.Hash + ReceiptsRoot common.Hash + LogsBloom [256]byte + PrevRandao common.Hash + BlockNumber uint64 + GasLimit uint64 + GasUsed uint64 + Timestamp uint64 + ExtraData []byte + BaseFeePerGas *uint256.Int + BlockHash common.Hash + Transactions [][]byte + Withdrawals []*Withdrawal +} + +func (p *ExecutionPayloadShanghai) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 5 hashes(32) + addr(20) + bloom(256) + 4 u64s + uint256(32) + 3 offsets(4) = 512 + size := uint32(5*32 + 20 + 256 + 4*8 + 32 + 3*4) + if fixed { + return size + } + size += ssz.SizeDynamicBytes(siz, p.ExtraData) + size += ssz.SizeSliceOfDynamicBytes(siz, p.Transactions) + size += ssz.SizeSliceOfStaticObjects(siz, p.Withdrawals) + return size +} + +func (p *ExecutionPayloadShanghai) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticBytes(c, &p.ParentHash) + ssz.DefineStaticBytes(c, &p.FeeRecipient) + ssz.DefineStaticBytes(c, &p.StateRoot) + ssz.DefineStaticBytes(c, &p.ReceiptsRoot) + ssz.DefineStaticBytes(c, &p.LogsBloom) + ssz.DefineStaticBytes(c, &p.PrevRandao) + ssz.DefineUint64(c, &p.BlockNumber) + ssz.DefineUint64(c, &p.GasLimit) + ssz.DefineUint64(c, &p.GasUsed) + ssz.DefineUint64(c, &p.Timestamp) + ssz.DefineDynamicBytesOffset(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineUint256(c, &p.BaseFeePerGas) + ssz.DefineStaticBytes(c, &p.BlockHash) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsOffset(c, &p.Withdrawals, MaxWithdrawalsPerPayload) + + ssz.DefineDynamicBytesContent(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineSliceOfDynamicBytesContent(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + ssz.DefineSliceOfStaticObjectsContent(c, &p.Withdrawals, MaxWithdrawalsPerPayload) +} + +// ExecutionPayloadParis is the original Bellatrix/Paris payload shape. +type ExecutionPayloadParis struct { + ParentHash common.Hash + FeeRecipient common.Address + StateRoot common.Hash + ReceiptsRoot common.Hash + LogsBloom [256]byte + PrevRandao common.Hash + BlockNumber uint64 + GasLimit uint64 + GasUsed uint64 + Timestamp uint64 + ExtraData []byte + BaseFeePerGas *uint256.Int + BlockHash common.Hash + Transactions [][]byte +} + +func (p *ExecutionPayloadParis) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // 5 hashes(32) + addr(20) + bloom(256) + 4 u64s + uint256(32) + 2 offsets(4) = 508 + size := uint32(5*32 + 20 + 256 + 4*8 + 32 + 2*4) + if fixed { + return size + } + size += ssz.SizeDynamicBytes(siz, p.ExtraData) + size += ssz.SizeSliceOfDynamicBytes(siz, p.Transactions) + return size +} + +func (p *ExecutionPayloadParis) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticBytes(c, &p.ParentHash) + ssz.DefineStaticBytes(c, &p.FeeRecipient) + ssz.DefineStaticBytes(c, &p.StateRoot) + ssz.DefineStaticBytes(c, &p.ReceiptsRoot) + ssz.DefineStaticBytes(c, &p.LogsBloom) + ssz.DefineStaticBytes(c, &p.PrevRandao) + ssz.DefineUint64(c, &p.BlockNumber) + ssz.DefineUint64(c, &p.GasLimit) + ssz.DefineUint64(c, &p.GasUsed) + ssz.DefineUint64(c, &p.Timestamp) + ssz.DefineDynamicBytesOffset(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineUint256(c, &p.BaseFeePerGas) + ssz.DefineStaticBytes(c, &p.BlockHash) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) + + ssz.DefineDynamicBytesContent(c, &p.ExtraData, MaxExtraDataBytes) + ssz.DefineSliceOfDynamicBytesContent(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx) +} diff --git a/beacon/engine/ssz/roundtrip_test.go b/beacon/engine/ssz/roundtrip_test.go new file mode 100644 index 0000000000..25feb1a277 --- /dev/null +++ b/beacon/engine/ssz/roundtrip_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 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 ssz + +import ( + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + "github.com/karalabe/ssz" +) + +func encDec[T ssz.Object](t *testing.T, obj T, newEmpty func() T) { + t.Helper() + size := ssz.Size(obj) + buf := make([]byte, size) + if err := ssz.EncodeToBytes(buf, obj); err != nil { + t.Fatalf("encode: %v", err) + } + got := newEmpty() + if err := ssz.DecodeFromBytes(buf, got); err != nil { + t.Fatalf("decode: %v", err) + } + // SSZ decode of an empty list yields []T{}, while encoding either nil + // or []T{} produces the same bytes. Compare via re-encode rather than + // DeepEqual to avoid spurious nil-vs-empty failures. + rebuf := make([]byte, ssz.Size(got)) + if err := ssz.EncodeToBytes(rebuf, got); err != nil { + t.Fatalf("re-encode: %v", err) + } + if !reflect.DeepEqual(buf, rebuf) { + t.Fatalf("round-trip mismatch\nwant bytes: %x\n got bytes: %x", buf, rebuf) + } +} + +func TestWithdrawalRoundtrip(t *testing.T) { + encDec(t, &Withdrawal{ + Index: 7, + ValidatorIndex: 42, + Address: common.Address{0xaa, 0xbb}, + Amount: 123456, + }, func() *Withdrawal { return new(Withdrawal) }) +} + +func TestForkchoiceStateRoundtrip(t *testing.T) { + encDec(t, &ForkchoiceState{ + HeadBlockHash: common.Hash{0x01}, + SafeBlockHash: common.Hash{0x02}, + FinalizedBlockHash: common.Hash{0x03}, + }, func() *ForkchoiceState { return new(ForkchoiceState) }) +} + +func TestPayloadStatusRoundtrip(t *testing.T) { + h := common.Hash{0xab} + encDec(t, &PayloadStatus{ + Status: StatusValid, + LatestValidHash: []common.Hash{h}, + ValidationError: [][]byte{[]byte("err detail")}, + }, func() *PayloadStatus { return new(PayloadStatus) }) + + // Absent optionals. + encDec(t, &PayloadStatus{Status: StatusSyncing}, func() *PayloadStatus { return new(PayloadStatus) }) +} + +func TestExecutionPayloadAmsterdamRoundtrip(t *testing.T) { + p := &ExecutionPayloadAmsterdam{ + ParentHash: common.Hash{0x11}, + FeeRecipient: common.Address{0x22}, + StateRoot: common.Hash{0x33}, + ReceiptsRoot: common.Hash{0x44}, + PrevRandao: common.Hash{0x55}, + BlockNumber: 100, + GasLimit: 30000000, + GasUsed: 21000, + Timestamp: 1700000000, + ExtraData: []byte("ext"), + BaseFeePerGas: uint256.NewInt(7e9), + BlockHash: common.Hash{0x66}, + Transactions: [][]byte{{0x02, 0x03}, {0x04}}, + Withdrawals: []*Withdrawal{{Index: 1, ValidatorIndex: 2, Amount: 3}}, + BlobGasUsed: 131072, + ExcessBlobGas: 0, + BlockAccessList: []byte{0xde, 0xad, 0xbe, 0xef}, + SlotNumber: 200, + } + encDec(t, p, func() *ExecutionPayloadAmsterdam { return new(ExecutionPayloadAmsterdam) }) +} + +func TestForkchoiceUpdateAmsterdamRoundtrip(t *testing.T) { + // With payload_attributes and custody_columns present. + bits := &Bitvector128{Bytes: make([]byte, CellsPerExtBlob/8)} + bits.Bytes[0] = 0x01 + attrs := &PayloadAttributesAmsterdam{ + Timestamp: 1700000000, + PrevRandao: common.Hash{0x55}, + SuggestedFeeRecipient: common.Address{0x66}, + Withdrawals: []*Withdrawal{}, + ParentBeaconBlockRoot: common.Hash{0x77}, + SlotNumber: 42, + TargetGasLimit: 30000000, + } + fcu := &ForkchoiceUpdateAmsterdam{ + ForkchoiceState: &ForkchoiceState{ + HeadBlockHash: common.Hash{0xaa}, + SafeBlockHash: common.Hash{0xbb}, + FinalizedBlockHash: common.Hash{0xcc}, + }, + PayloadAttributes: []*PayloadAttributesAmsterdam{attrs}, + CustodyColumns: []*Bitvector128{bits}, + } + encDec(t, fcu, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) }) + + // Without optionals. + bare := &ForkchoiceUpdateAmsterdam{ + ForkchoiceState: &ForkchoiceState{ + HeadBlockHash: common.Hash{0xdd}, + }, + } + encDec(t, bare, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) }) +} diff --git a/beacon/engine/ssz/shared.go b/beacon/engine/ssz/shared.go new file mode 100644 index 0000000000..fd774d62d5 --- /dev/null +++ b/beacon/engine/ssz/shared.go @@ -0,0 +1,164 @@ +// Copyright 2026 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 ssz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/karalabe/ssz" +) + +// Blob wraps a single 131072-byte blob. karalabe/ssz's generic +// constraint doesn't list 131072 as a "common" fixed length, so we +// wrap it as a static-object containing a length-checked []byte. +type Blob struct { + Bytes []byte +} + +func (*Blob) SizeSSZ(*ssz.Sizer) uint32 { return BytesPerBlob } + +func (b *Blob) DefineSSZ(c *ssz.Codec) { + ssz.DefineCheckedStaticBytes(c, &b.Bytes, BytesPerBlob) +} + +// BlobCell wraps a single BYTES_PER_CELL = 1024 byte cell. Same wrapper +// rationale as Blob. +type BlobCell struct { + Bytes []byte +} + +func (*BlobCell) SizeSSZ(*ssz.Sizer) uint32 { return BytesPerCell } + +func (b *BlobCell) DefineSSZ(c *ssz.Codec) { + ssz.DefineCheckedStaticBytes(c, &b.Bytes, BytesPerCell) +} + +// Bitvector128 wraps a packed bitvector of length CELLS_PER_EXT_BLOB = 128 +// (16 bytes). Same wrapper rationale as Blob. +type Bitvector128 struct { + Bytes []byte +} + +func (*Bitvector128) SizeSSZ(*ssz.Sizer) uint32 { return CellsPerExtBlob / 8 } + +func (b *Bitvector128) DefineSSZ(c *ssz.Codec) { + ssz.DefineCheckedStaticBytes(c, &b.Bytes, CellsPerExtBlob/8) +} + +// OptionalBlobCell models `Optional[ByteVector[BYTES_PER_CELL]]` = +// `List[BlobCell, 1]`. Length 0 = absent, length 1 = present (1024 bytes). +type OptionalBlobCell struct { + Cells []*BlobCell +} + +func (o *OptionalBlobCell) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + if fixed { + return 0 + } + return ssz.SizeSliceOfStaticObjects(siz, o.Cells) +} + +func (o *OptionalBlobCell) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticObjectsContent(c, &o.Cells, 1) +} + +func (o *OptionalBlobCell) Validate() error { return checkOptional(o.Cells) } + +// OptionalProof models `Optional[Bytes48]` = `List[Bytes48, 1]`. +type OptionalProof struct { + Proofs [][48]byte +} + +func (o *OptionalProof) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + if fixed { + return 0 + } + return ssz.SizeSliceOfStaticBytes(siz, o.Proofs) +} + +func (o *OptionalProof) DefineSSZ(c *ssz.Codec) { + ssz.DefineSliceOfStaticBytesContent(c, &o.Proofs, 1) +} + +func (o *OptionalProof) Validate() error { return checkOptional(o.Proofs) } + +// Withdrawal mirrors the consensus-specs Withdrawal container. +type Withdrawal struct { + Index uint64 + ValidatorIndex uint64 + Address common.Address + Amount uint64 +} + +func (*Withdrawal) SizeSSZ(*ssz.Sizer) uint32 { return 44 } + +func (w *Withdrawal) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint64(c, &w.Index) + ssz.DefineUint64(c, &w.ValidatorIndex) + ssz.DefineStaticBytes(c, &w.Address) + ssz.DefineUint64(c, &w.Amount) +} + +// ForkchoiceState carries head/safe/finalized block hashes. +type ForkchoiceState struct { + HeadBlockHash common.Hash + SafeBlockHash common.Hash + FinalizedBlockHash common.Hash +} + +func (*ForkchoiceState) SizeSSZ(*ssz.Sizer) uint32 { return 96 } + +func (f *ForkchoiceState) DefineSSZ(c *ssz.Codec) { + ssz.DefineStaticBytes(c, &f.HeadBlockHash) + ssz.DefineStaticBytes(c, &f.SafeBlockHash) + ssz.DefineStaticBytes(c, &f.FinalizedBlockHash) +} + +// PayloadStatus is the response shape for /payloads and the inner status of +// /forkchoice. Status values are defined as constants in constants.go. +type PayloadStatus struct { + Status uint8 + LatestValidHash []common.Hash // Optional[Hash32] — length 0 or 1 + ValidationError [][]byte // Optional[String] — length 0 or 1 +} + +func (p *PayloadStatus) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { + // status(1) + offset(4) + offset(4) + size := uint32(9) + if fixed { + return size + } + size += ssz.SizeSliceOfStaticBytes(siz, p.LatestValidHash) + size += ssz.SizeSliceOfDynamicBytes(siz, p.ValidationError) + return size +} + +func (p *PayloadStatus) DefineSSZ(c *ssz.Codec) { + ssz.DefineUint8(c, &p.Status) + ssz.DefineSliceOfStaticBytesOffset(c, &p.LatestValidHash, 1) + ssz.DefineSliceOfDynamicBytesOffset(c, &p.ValidationError, 1, MaxErrorBytes) + + ssz.DefineSliceOfStaticBytesContent(c, &p.LatestValidHash, 1) + ssz.DefineSliceOfDynamicBytesContent(c, &p.ValidationError, 1, MaxErrorBytes) +} + +// Validate enforces the Optional[T] = List[T,1] length invariants. +func (p *PayloadStatus) Validate() error { + if err := checkOptional(p.LatestValidHash); err != nil { + return err + } + return checkOptional(p.ValidationError) +} diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 1def169ae0..a98dc1def0 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "net/http" "reflect" "strconv" "sync" @@ -35,6 +36,7 @@ import ( "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/eth" + "github.com/ethereum/go-ethereum/eth/engineapi" "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/internal/telemetry" "github.com/ethereum/go-ethereum/internal/version" @@ -49,14 +51,20 @@ import ( // Register adds the engine API and related APIs to the full node. func Register(stack *node.Node, backend *eth.Ethereum) error { + api := NewConsensusAPI(backend) stack.RegisterAPIs([]rpc.API{ newTestingAPI(backend), { Namespace: "engine", - Service: NewConsensusAPI(backend), + Service: api, Authenticated: true, }, }) + // Mount the REST + SSZ Engine API (execution-apis #793) under + // /engine/v2/ on the authenticated HTTP server. The router shares + // catalyst's business logic via the restBackend adapter. + stack.RegisterAuthHandler("engine-rest", engineapi.BasePath+"/", + http.StripPrefix(engineapi.BasePath, engineapi.NewRouter(newRESTBackend(api)))) return nil } diff --git a/eth/engineapi/backend.go b/eth/engineapi/backend.go new file mode 100644 index 0000000000..8132c826fa --- /dev/null +++ b/eth/engineapi/backend.go @@ -0,0 +1,68 @@ +// Copyright 2026 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 engineapi implements the REST + SSZ Engine API (execution-apis #793). +// +// The package is deliberately decoupled from eth/catalyst: the JSON-RPC engine +// surface and this REST surface share the same backing logic through the +// Backend interface defined here, but neither imports the other. The catalyst +// package supplies a Backend impl at node-startup time. +package engineapi + +import ( + "context" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params/forks" +) + +// Backend is the contract a host node implements to drive the REST handlers. +type Backend interface { + // ForkchoiceUpdated applies a forkchoice update and optionally starts + // a payload build. version selects the JSON-RPC PayloadVersion the + // catalyst-layer expects. + ForkchoiceUpdated(ctx context.Context, state engine.ForkchoiceStateV1, attrs *engine.PayloadAttributes, version engine.PayloadVersion) (engine.ForkChoiceResponse, error) + + // NewPayload validates and imports a new payload. + NewPayload(ctx context.Context, data engine.ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash, requests [][]byte) (engine.PayloadStatusV1, error) + + // GetPayload returns a previously-built payload. allowedForks filters + // to the fork the URL selected; an empty slice disables the check. + GetPayload(id engine.PayloadID, allowedForks []forks.Fork) (*engine.ExecutionPayloadEnvelope, error) + + // GetBlobs returns blob bundle entries indexed by versioned hash. + // cellProofs=false matches /blobs/v1 semantics (single proof per blob); + // true matches /blobs/v2,v3 (cell proofs). + GetBlobs(hashes []common.Hash, cellProofs bool) ([]*engine.BlobAndProofV2, []*engine.BlobAndProofV1, error) + + // BodiesByHash returns block bodies for the given hashes. Order matches + // the input; an entry is nil for unknown/pruned blocks. The second slice + // returns the timestamp of each known block (zero for unknown) so the + // router can enforce the URL fork's era window. + BodiesByHash(hashes []common.Hash) ([]*types.Body, []uint64) + + // BodiesByRange returns block bodies for [from, from+count). Same shape + // as BodiesByHash. + BodiesByRange(from, count uint64) ([]*types.Body, []uint64) + + // ForkFromTimestamp returns the fork active at timestamp ts. + ForkFromTimestamp(ts uint64) forks.Fork + + // ClientVersion returns the EL client identity for /identity responses. + ClientVersion() engine.ClientVersionV1 +} diff --git a/eth/engineapi/blobs.go b/eth/engineapi/blobs.go new file mode 100644 index 0000000000..c13362fca9 --- /dev/null +++ b/eth/engineapi/blobs.go @@ -0,0 +1,149 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + + "github.com/ethereum/go-ethereum/beacon/engine" + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" +) + +// handleBlobs dispatches POST /engine/v2/blobs/v{1,2,3,4}. +// suffix is the path remainder after "/blobs/". +func (rt *Router) handleBlobs(w http.ResponseWriter, r *http.Request, suffix string) { + if r.Method != http.MethodPost { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + switch suffix { + case "v1": + rt.handleBlobsV1(w, r) + case "v2": + rt.handleBlobsV2(w, r, false) + case "v3": + rt.handleBlobsV2(w, r, true) + case "v4": + rt.handleBlobsV4(w, r) + default: + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + } +} + +func (rt *Router) handleBlobsV1(w http.ResponseWriter, r *http.Request) { + req := new(sszt.BlobsVersionedHashesRequest) + if !readSSZRequest(w, r, req, maxPayloadBytes) { + return + } + if len(req.VersionedHashes) > sszt.MaxBlobsRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return + } + _, v1, err := rt.backend.GetBlobs(req.VersionedHashes, false) + if err != nil { + mapBackendErr(w, err) + return + } + // Backend returns nil entries for misses; map to available=false. + resp := &sszt.BlobsV1Response{Entries: make([]*sszt.BlobV1Entry, len(v1))} + for i, bp := range v1 { + e := &sszt.BlobV1Entry{Contents: emptyBlobAndProofV1()} + if bp != nil { + e.Available = true + e.Contents = &sszt.BlobAndProofV1{Blob: &sszt.Blob{Bytes: bp.Blob}} + copy(e.Contents.Proof[:], bp.Proof) + } + resp.Entries[i] = e + } + writeSSZResponse(w, resp) +} + +// handleBlobsV2 serves both /v2 (all-or-nothing) and /v3 (partial). The +// allowPartial flag selects between them. +func (rt *Router) handleBlobsV2(w http.ResponseWriter, r *http.Request, allowPartial bool) { + req := new(sszt.BlobsVersionedHashesRequest) + if !readSSZRequest(w, r, req, maxPayloadBytes) { + return + } + if len(req.VersionedHashes) > sszt.MaxBlobsRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return + } + v2, _, err := rt.backend.GetBlobs(req.VersionedHashes, true) + if err != nil { + mapBackendErr(w, err) + return + } + if !allowPartial { + // V2 is all-or-nothing: missing entries collapse the response to 204. + for _, bp := range v2 { + if bp == nil { + w.WriteHeader(http.StatusNoContent) + return + } + } + } + resp := &sszt.BlobsV2Response{Entries: make([]*sszt.BlobV2Entry, len(v2))} + for i, bp := range v2 { + e := &sszt.BlobV2Entry{Contents: emptyBlobAndProofV2()} + if bp != nil { + e.Available = true + e.Contents = blobAndProofV2FromEngine(bp) + } + resp.Entries[i] = e + } + writeSSZResponse(w, resp) +} + +// handleBlobsV4 implements POST /engine/v2/blobs/v4 (cell-range selection). +// The custody/cell-range logic is not yet plumbed into the txpool; we return +// 204 to signal the EL cannot serve it. The handler still validates the +// request body and the indices_bitarray length to keep the wire contract live. +func (rt *Router) handleBlobsV4(w http.ResponseWriter, r *http.Request) { + req := new(sszt.BlobsV4Request) + if !readSSZRequest(w, r, req, maxPayloadBytes) { + return + } + if req.IndicesBitarray == nil || len(req.IndicesBitarray.Bytes) != sszt.CellsPerExtBlob/8 { + writeProblem(w, http.StatusBadRequest, ErrInvalidBody, "indices_bitarray length") + return + } + if len(req.VersionedHashes) > sszt.MaxBlobsRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func emptyBlobAndProofV1() *sszt.BlobAndProofV1 { + return &sszt.BlobAndProofV1{Blob: &sszt.Blob{Bytes: make([]byte, sszt.BytesPerBlob)}} +} + +func emptyBlobAndProofV2() *sszt.BlobAndProofV2 { + return &sszt.BlobAndProofV2{Blob: &sszt.Blob{Bytes: make([]byte, sszt.BytesPerBlob)}} +} + +func blobAndProofV2FromEngine(bp *engine.BlobAndProofV2) *sszt.BlobAndProofV2 { + out := &sszt.BlobAndProofV2{ + Blob: &sszt.Blob{Bytes: append([]byte(nil), bp.Blob...)}, + Proofs: make([][48]byte, len(bp.CellProofs)), + } + for i, p := range bp.CellProofs { + copy(out.Proofs[i][:], p) + } + return out +} diff --git a/eth/engineapi/bodies.go b/eth/engineapi/bodies.go new file mode 100644 index 0000000000..abc907ed38 --- /dev/null +++ b/eth/engineapi/bodies.go @@ -0,0 +1,107 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + "strconv" + + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params/forks" +) + +// handleBodiesByHash implements POST /engine/v2/{fork}/bodies/hash. +func (rt *Router) handleBodiesByHash(w http.ResponseWriter, r *http.Request, fork forks.Fork) { + if fork != forks.Amsterdam { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + return + } + req := new(sszt.BodiesByHashRequest) + if !readSSZRequest(w, r, req, maxPayloadBytes) { + return + } + if len(req.BlockHashes) > sszt.MaxBodiesRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return + } + bodies, timestamps := rt.backend.BodiesByHash(req.BlockHashes) + writeSSZResponse(w, buildBodiesResponseAmsterdam(rt.backend, fork, bodies, timestamps)) +} + +// handleBodiesByRange implements GET /engine/v2/{fork}/bodies?from=&count=. +func (rt *Router) handleBodiesByRange(w http.ResponseWriter, r *http.Request, fork forks.Fork) { + if fork != forks.Amsterdam { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + return + } + q := r.URL.Query() + from, err := strconv.ParseUint(q.Get("from"), 10, 64) + if err != nil { + writeProblem(w, http.StatusBadRequest, ErrInvalidRequest, "missing or bad from") + return + } + count, err := strconv.ParseUint(q.Get("count"), 10, 64) + if err != nil || count == 0 { + writeProblem(w, http.StatusBadRequest, ErrInvalidRequest, "missing or bad count") + return + } + if count > sszt.MaxBodiesRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return + } + bodies, timestamps := rt.backend.BodiesByRange(from, count) + writeSSZResponse(w, buildBodiesResponseAmsterdam(rt.backend, fork, bodies, timestamps)) +} + +// buildBodiesResponseAmsterdam assembles a BodiesResponseAmsterdam, marking +// out-of-era blocks as available=false per the URL fork window. +func buildBodiesResponseAmsterdam(b Backend, fork forks.Fork, bodies []*types.Body, ts []uint64) *sszt.BodiesResponseAmsterdam { + out := &sszt.BodiesResponseAmsterdam{ + Entries: make([]*sszt.BodyEntryAmsterdam, len(bodies)), + } + for i, body := range bodies { + entry := &sszt.BodyEntryAmsterdam{Body: new(sszt.ExecutionPayloadBodyAmsterdam)} + if body != nil && b.ForkFromTimestamp(ts[i]) == fork { + entry.Available = true + entry.Body = bodyToAmsterdamSSZ(body) + } + out.Entries[i] = entry + } + return out +} + +// bodyToAmsterdamSSZ flattens a *types.Body into the Amsterdam body shape. +func bodyToAmsterdamSSZ(body *types.Body) *sszt.ExecutionPayloadBodyAmsterdam { + out := &sszt.ExecutionPayloadBodyAmsterdam{ + Transactions: make([][]byte, len(body.Transactions)), + Withdrawals: make([]*sszt.Withdrawal, len(body.Withdrawals)), + } + for i, tx := range body.Transactions { + out.Transactions[i], _ = tx.MarshalBinary() + } + for i, w := range body.Withdrawals { + out.Withdrawals[i] = &sszt.Withdrawal{ + Index: w.Index, + ValidatorIndex: w.Validator, + Address: w.Address, + Amount: w.Amount, + } + } + // BlockAccessList is not yet on types.Body; once wired through, copy here. + return out +} diff --git a/eth/engineapi/capabilities.go b/eth/engineapi/capabilities.go new file mode 100644 index 0000000000..a426170dcc --- /dev/null +++ b/eth/engineapi/capabilities.go @@ -0,0 +1,57 @@ +// Copyright 2026 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 engineapi + +import ( + "encoding/json" + "net/http" + + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" +) + +// capabilitiesResponse mirrors the structured shape in refactor.md § +// "Capabilities format". +type capabilitiesResponse struct { + SupportedForks []string `json:"supported_forks"` + ForkScopedEndpoints []string `json:"fork_scoped_endpoints"` + IndependentlyVersioned map[string][]string `json:"independently_versioned"` + UnscopedEndpoints []string `json:"unscoped_endpoints"` + Limits map[string]uint64 `json:"limits"` +} + +// handleCapabilities implements GET /engine/v2/capabilities. +func (rt *Router) handleCapabilities(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + resp := capabilitiesResponse{ + SupportedForks: []string{"amsterdam"}, + ForkScopedEndpoints: []string{"payloads", "forkchoice", "bodies"}, + IndependentlyVersioned: map[string][]string{ + "blobs": {"v1", "v2", "v3", "v4"}, + }, + UnscopedEndpoints: []string{"capabilities", "identity"}, + Limits: map[string]uint64{ + "bodies.max_count": sszt.MaxBodiesRequest, + "blobs.max_versioned_hashes": sszt.MaxBlobsRequest, + "payload.max_bytes": maxPayloadBytes, + }, + } + w.Header().Set("Content-Type", jsonContentType) + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/eth/engineapi/errors.go b/eth/engineapi/errors.go new file mode 100644 index 0000000000..b02ef82492 --- /dev/null +++ b/eth/engineapi/errors.go @@ -0,0 +1,55 @@ +// Copyright 2026 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 engineapi + +import ( + "encoding/json" + "net/http" +) + +// Error type URIs from refactor.md § Error model. +const ( + ErrParseError = "/engine-api/errors/parse-error" + ErrInvalidRequest = "/engine-api/errors/invalid-request" + ErrSSZDecode = "/engine-api/errors/ssz-decode-error" + ErrUnsupportedFork = "/engine-api/errors/unsupported-fork" + ErrMethodNotFound = "/engine-api/errors/method-not-found" + ErrUnknownPayload = "/engine-api/errors/unknown-payload" + ErrInvalidForkchoice = "/engine-api/errors/invalid-forkchoice" + ErrReorgTooDeep = "/engine-api/errors/reorg-too-deep" + ErrRequestTooLarge = "/engine-api/errors/request-too-large" + ErrUnsupportedMedia = "/engine-api/errors/unsupported-media-type" + ErrInvalidBody = "/engine-api/errors/invalid-body" + ErrInvalidAttributes = "/engine-api/errors/invalid-attributes" + ErrInternal = "/engine-api/errors/internal" +) + +const problemJSONContentType = "application/problem+json" + +// problem is the RFC 7807 body shape. Only type and detail are populated, +// matching the spec's two-field profile. +type problem struct { + Type string `json:"type"` + Detail string `json:"detail,omitempty"` +} + +// writeProblem emits an RFC 7807 error body. detail is optional and may be empty. +func writeProblem(w http.ResponseWriter, status int, typeURI, detail string) { + w.Header().Set("Content-Type", problemJSONContentType) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(problem{Type: typeURI, Detail: detail}) +} diff --git a/eth/engineapi/forkchoice.go b/eth/engineapi/forkchoice.go new file mode 100644 index 0000000000..ef4568eb47 --- /dev/null +++ b/eth/engineapi/forkchoice.go @@ -0,0 +1,77 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + + "github.com/ethereum/go-ethereum/beacon/engine" + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" + "github.com/ethereum/go-ethereum/params/forks" +) + +// handleForkchoice implements POST /engine/v2/{fork}/forkchoice. +func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork forks.Fork) { + if fork != forks.Amsterdam { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + return + } + fcu := new(sszt.ForkchoiceUpdateAmsterdam) + if !readSSZRequest(w, r, fcu, maxPayloadBytes) { + return + } + if err := fcu.Validate(); err != nil { + writeProblem(w, http.StatusBadRequest, ErrInvalidBody, err.Error()) + return + } + state := sszt.ForkchoiceStateToV1(fcu.ForkchoiceState) + var attrs *engine.PayloadAttributes + if len(fcu.PayloadAttributes) == 1 { + ssz := fcu.PayloadAttributes[0] + // If PayloadAttributes is present the URL fork MUST match the fork + // the new payload would belong to. Today only Amsterdam URL exists + // in this implementation so the timestamp check is implicit; we + // keep an explicit guard for future fork URLs. + if rt.backend.ForkFromTimestamp(ssz.Timestamp) != fork { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, + "payload_attributes timestamp does not match URL fork") + return + } + attrs = sszt.PayloadAttributesAmsterdamToEngine(ssz) + // target_gas_limit and custody_columns are not yet plumbed into + // the JSON-RPC engine API. Custody is parsed-but-stubbed per + // agreed scope; target_gas_limit will be picked up when the + // underlying miner gains the corresponding setting. + } + + resp, err := rt.backend.ForkchoiceUpdated(r.Context(), state, attrs, engine.PayloadV4) + if err != nil { + mapBackendErr(w, err) + return + } + // /forkchoice MUST NOT return ACCEPTED. + if resp.PayloadStatus.Status == engine.ACCEPTED { + resp.PayloadStatus.Status = engine.INVALID + } + out := &sszt.ForkchoiceUpdateResponseAmsterdam{ + PayloadStatus: sszt.PayloadStatusFromV1(&resp.PayloadStatus), + } + if resp.PayloadID != nil { + out.PayloadID = [][8]byte{[8]byte(*resp.PayloadID)} + } + writeSSZResponse(w, out) +} diff --git a/eth/engineapi/get_payload.go b/eth/engineapi/get_payload.go new file mode 100644 index 0000000000..ccc51a8578 --- /dev/null +++ b/eth/engineapi/get_payload.go @@ -0,0 +1,93 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + + "github.com/ethereum/go-ethereum/beacon/engine" + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/params/forks" + "github.com/holiman/uint256" +) + +// handleGetPayload implements GET /engine/v2/{fork}/payloads/{payloadId}. +func (rt *Router) handleGetPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork, idHex string) { + if fork != forks.Amsterdam { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + return + } + raw, err := hexutil.Decode(idHex) + if err != nil || len(raw) != 8 { + writeProblem(w, http.StatusBadRequest, ErrInvalidRequest, "malformed payloadId") + return + } + var id engine.PayloadID + copy(id[:], raw) + + env, err := rt.backend.GetPayload(id, []forks.Fork{forks.Amsterdam}) + if err != nil { + mapBackendErr(w, err) + return + } + + out := buildBuiltPayloadAmsterdam(env) + w.Header().Set("Cache-Control", "no-store") + writeSSZResponse(w, out) +} + +// buildBuiltPayloadAmsterdam packages an engine.ExecutionPayloadEnvelope into +// the SSZ BuiltPayload shape. BlockValue/BlobsBundle/Requests come straight +// across; the inner payload goes through the SSZ converter. +func buildBuiltPayloadAmsterdam(env *engine.ExecutionPayloadEnvelope) *sszt.BuiltPayloadAmsterdam { + out := &sszt.BuiltPayloadAmsterdam{ + Payload: sszt.ExecutionPayloadAmsterdamFromEngine(env.ExecutionPayload), + BlockValue: new(uint256.Int), + ExecutionRequests: env.Requests, + ShouldOverrideBuilder: env.Override, + } + if env.BlockValue != nil { + out.BlockValue.SetFromBig(env.BlockValue) + } + if env.BlobsBundle != nil { + out.BlobsBundle = convertBlobsBundle(env.BlobsBundle) + } else { + out.BlobsBundle = new(sszt.BlobsBundleV2) + } + return out +} + +// convertBlobsBundle copies the JSON BlobsBundle into the SSZ V2 layout. +// Inputs are length-validated by the caller's miner pipeline. +func convertBlobsBundle(b *engine.BlobsBundle) *sszt.BlobsBundleV2 { + out := &sszt.BlobsBundleV2{ + Commitments: make([][48]byte, len(b.Commitments)), + Proofs: make([][48]byte, len(b.Proofs)), + Blobs: make([]*sszt.Blob, len(b.Blobs)), + } + for i, c := range b.Commitments { + copy(out.Commitments[i][:], c) + } + for i, p := range b.Proofs { + copy(out.Proofs[i][:], p) + } + for i, blob := range b.Blobs { + out.Blobs[i] = &sszt.Blob{Bytes: append([]byte(nil), blob...)} + } + return out +} diff --git a/eth/engineapi/identity.go b/eth/engineapi/identity.go new file mode 100644 index 0000000000..54986fb512 --- /dev/null +++ b/eth/engineapi/identity.go @@ -0,0 +1,39 @@ +// Copyright 2026 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 engineapi + +import ( + "encoding/json" + "net/http" + + "github.com/ethereum/go-ethereum/beacon/engine" +) + +// handleIdentity implements GET /engine/v2/identity. +// The CL surfaces itself via the X-Engine-Client-Version request header; the +// EL responds with its own ClientVersion in JSON. +func (rt *Router) handleIdentity(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + cv := rt.backend.ClientVersion() + w.Header().Set("Content-Type", jsonContentType) + _ = json.NewEncoder(w).Encode(struct { + Versions []engine.ClientVersionV1 `json:"versions"` + }{Versions: []engine.ClientVersionV1{cv}}) +} diff --git a/eth/engineapi/media.go b/eth/engineapi/media.go new file mode 100644 index 0000000000..34df822a48 --- /dev/null +++ b/eth/engineapi/media.go @@ -0,0 +1,76 @@ +// Copyright 2026 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 engineapi + +import ( + "io" + "net/http" + "strings" + + "github.com/karalabe/ssz" +) + +const ( + sszContentType = "application/octet-stream" + jsonContentType = "application/json" + + // maxPayloadBytes mirrors the capabilities advertisement + // limits.payload.max_bytes (64 MiB). Per-endpoint helpers may lower it. + maxPayloadBytes = 64 * 1024 * 1024 +) + +// readSSZRequest enforces the SSZ content-type and Content-Length cap, then +// decodes the request body into obj. Writes the appropriate problem response +// on failure and returns false; the caller should return immediately. +func readSSZRequest(w http.ResponseWriter, r *http.Request, obj ssz.Object, max int64) bool { + if !strings.EqualFold(r.Header.Get("Content-Type"), sszContentType) { + writeProblem(w, http.StatusUnsupportedMediaType, ErrUnsupportedMedia, "expected "+sszContentType) + return false + } + if r.ContentLength > max { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return false + } + body, err := io.ReadAll(io.LimitReader(r.Body, max+1)) + if err != nil { + writeProblem(w, http.StatusBadRequest, ErrParseError, err.Error()) + return false + } + if int64(len(body)) > max { + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + return false + } + if err := ssz.DecodeFromBytes(body, obj); err != nil { + writeProblem(w, http.StatusBadRequest, ErrSSZDecode, "") + return false + } + return true +} + +// writeSSZResponse encodes obj into the response body. On encode failure it +// writes an internal-error problem and returns false. +func writeSSZResponse(w http.ResponseWriter, obj ssz.Object) bool { + buf := make([]byte, ssz.Size(obj)) + if err := ssz.EncodeToBytes(buf, obj); err != nil { + writeProblem(w, http.StatusInternalServerError, ErrInternal, err.Error()) + return false + } + w.Header().Set("Content-Type", sszContentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf) + return true +} diff --git a/eth/engineapi/payloads.go b/eth/engineapi/payloads.go new file mode 100644 index 0000000000..18858fe7fd --- /dev/null +++ b/eth/engineapi/payloads.go @@ -0,0 +1,89 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + + "github.com/ethereum/go-ethereum/beacon/engine" + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/params/forks" +) + +// handleNewPayload implements POST /engine/v2/{fork}/payloads. +func (rt *Router) handleNewPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork) { + if fork != forks.Amsterdam { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + return + } + env := new(sszt.ExecutionPayloadEnvelopeAmsterdam) + if !readSSZRequest(w, r, env, maxPayloadBytes) { + return + } + data := sszt.ExecutionPayloadAmsterdamToEngine(env.Payload) + + // The spec drops expectedBlobVersionedHashes; we recompute them from the + // payload's transactions before passing to the EL. + versionedHashes, err := versionedHashesFromTxs(data.Transactions) + if err != nil { + writeProblem(w, http.StatusUnprocessableEntity, ErrInvalidBody, err.Error()) + return + } + root := env.ParentBeaconBlockRoot + status, err := rt.backend.NewPayload(r.Context(), *data, versionedHashes, &root, env.ExecutionRequests) + if err != nil { + mapBackendErr(w, err) + return + } + writeSSZResponse(w, sszt.PayloadStatusFromV1(&status)) +} + +// versionedHashesFromTxs extracts the blob versioned-hash list from the +// payload's RLP-encoded transactions. The CL no longer sends it; the EL +// recomputes for the block-hash check. +func versionedHashesFromTxs(raw [][]byte) ([]common.Hash, error) { + txs, err := engine.DecodeTransactions(raw) + if err != nil { + return nil, err + } + var hashes []common.Hash + for _, tx := range txs { + hashes = append(hashes, tx.BlobHashes()...) + } + return hashes, nil +} + +// mapBackendErr translates engine-level errors into the spec's error model. +func mapBackendErr(w http.ResponseWriter, err error) { + switch err { + case engine.UnknownPayload: + writeProblem(w, http.StatusNotFound, ErrUnknownPayload, "") + case engine.InvalidForkChoiceState: + writeProblem(w, http.StatusConflict, ErrInvalidForkchoice, "") + case engine.InvalidPayloadAttributes: + writeProblem(w, http.StatusUnprocessableEntity, ErrInvalidAttributes, "") + case engine.UnsupportedFork: + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "") + case engine.TooLargeRequest: + writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "") + case engine.InvalidParams: + writeProblem(w, http.StatusBadRequest, ErrInvalidRequest, "") + default: + writeProblem(w, http.StatusInternalServerError, ErrInternal, err.Error()) + } +} diff --git a/eth/engineapi/router.go b/eth/engineapi/router.go new file mode 100644 index 0000000000..7b379c4341 --- /dev/null +++ b/eth/engineapi/router.go @@ -0,0 +1,106 @@ +// Copyright 2026 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 engineapi + +import ( + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/params/forks" +) + +// BasePath is the mount prefix for the REST Engine API. Handlers below assume +// requests have already been routed under this prefix. +const BasePath = "/engine/v2" + +// supportedForks lists the fork URL segments the router recognises. Order is +// chronological; the router does an exact prefix match so /amsterdam/payloads +// is not confused with /amsterdam-foo/payloads. +var supportedForks = map[string]forks.Fork{ + "paris": forks.Paris, + "shanghai": forks.Shanghai, + "cancun": forks.Cancun, + "prague": forks.Prague, + "osaka": forks.Osaka, + "amsterdam": forks.Amsterdam, +} + +// Router is the http.Handler implementing the REST Engine API. +type Router struct { + backend Backend +} + +// NewRouter constructs a Router backed by b. +func NewRouter(b Backend) *Router { + return &Router{backend: b} +} + +// ServeHTTP dispatches to the per-endpoint handler. The caller is expected to +// have stripped the BasePath prefix and applied authentication. +func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + // The spec forbids trailing slashes; net/http's ServeMux would redirect, + // so police it here. + if len(path) > 1 && strings.HasSuffix(path, "/") { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + switch { + case path == "/capabilities": + rt.handleCapabilities(w, r) + case path == "/identity": + rt.handleIdentity(w, r) + case strings.HasPrefix(path, "/blobs/"): + rt.handleBlobs(w, r, path[len("/blobs/"):]) + default: + rt.handleForkScoped(w, r, path) + } +} + +// handleForkScoped routes /{fork}/... paths. +func (rt *Router) handleForkScoped(w http.ResponseWriter, r *http.Request, path string) { + if len(path) < 2 || path[0] != '/' { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + rest := path[1:] + slash := strings.IndexByte(rest, '/') + if slash < 0 { + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + return + } + forkName, sub := rest[:slash], rest[slash:] + fork, ok := supportedForks[forkName] + if !ok { + writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "unknown fork "+forkName) + return + } + switch { + case sub == "/payloads" && r.Method == http.MethodPost: + rt.handleNewPayload(w, r, fork) + case sub == "/forkchoice" && r.Method == http.MethodPost: + rt.handleForkchoice(w, r, fork) + case strings.HasPrefix(sub, "/payloads/") && r.Method == http.MethodGet: + rt.handleGetPayload(w, r, fork, sub[len("/payloads/"):]) + case sub == "/bodies/hash" && r.Method == http.MethodPost: + rt.handleBodiesByHash(w, r, fork) + case sub == "/bodies" && r.Method == http.MethodGet: + rt.handleBodiesByRange(w, r, fork) + default: + writeProblem(w, http.StatusNotFound, ErrMethodNotFound, "") + } +} diff --git a/eth/engineapi/router_test.go b/eth/engineapi/router_test.go new file mode 100644 index 0000000000..e7cd2f8d67 --- /dev/null +++ b/eth/engineapi/router_test.go @@ -0,0 +1,373 @@ +// Copyright 2026 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 engineapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/beacon/engine" + sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params/forks" + "github.com/holiman/uint256" + "github.com/karalabe/ssz" +) + +// stubBackend captures requests to verify the router routes correctly. +type stubBackend struct { + fcuStatus engine.PayloadStatusV1 + fcuID *engine.PayloadID + npStatus engine.PayloadStatusV1 + npErr error + + lastFCUState engine.ForkchoiceStateV1 + lastFCUAttrs *engine.PayloadAttributes + lastNPData engine.ExecutableData + lastNPHashes []common.Hash + lastNPRequest [][]byte + + envelope *engine.ExecutionPayloadEnvelope + getErr error + + bodies []*types.Body + bodyTimes []uint64 + v1Blobs []*engine.BlobAndProofV1 + v2Blobs []*engine.BlobAndProofV2 + forkAtTime func(uint64) forks.Fork +} + +func (s *stubBackend) ForkchoiceUpdated(_ context.Context, state engine.ForkchoiceStateV1, attrs *engine.PayloadAttributes, _ engine.PayloadVersion) (engine.ForkChoiceResponse, error) { + s.lastFCUState = state + s.lastFCUAttrs = attrs + return engine.ForkChoiceResponse{PayloadStatus: s.fcuStatus, PayloadID: s.fcuID}, nil +} + +func (s *stubBackend) NewPayload(_ context.Context, data engine.ExecutableData, hashes []common.Hash, _ *common.Hash, requests [][]byte) (engine.PayloadStatusV1, error) { + s.lastNPData = data + s.lastNPHashes = hashes + s.lastNPRequest = requests + return s.npStatus, s.npErr +} + +func (s *stubBackend) GetPayload(engine.PayloadID, []forks.Fork) (*engine.ExecutionPayloadEnvelope, error) { + return s.envelope, s.getErr +} + +func (s *stubBackend) GetBlobs([]common.Hash, bool) ([]*engine.BlobAndProofV2, []*engine.BlobAndProofV1, error) { + return s.v2Blobs, s.v1Blobs, nil +} + +func (s *stubBackend) BodiesByHash(hashes []common.Hash) ([]*types.Body, []uint64) { + return s.bodies, s.bodyTimes +} + +func (s *stubBackend) BodiesByRange(from, count uint64) ([]*types.Body, []uint64) { + return s.bodies, s.bodyTimes +} + +func (s *stubBackend) ForkFromTimestamp(ts uint64) forks.Fork { + if s.forkAtTime != nil { + return s.forkAtTime(ts) + } + return forks.Amsterdam +} + +func (s *stubBackend) ClientVersion() engine.ClientVersionV1 { + return engine.ClientVersionV1{Code: "GE", Name: "geth", Version: "test", Commit: "0xdeadbeef"} +} + +func newTestServer(t *testing.T, b Backend) *httptest.Server { + t.Helper() + return httptest.NewServer(http.StripPrefix(BasePath, NewRouter(b))) +} + +func sszPost(t *testing.T, srv *httptest.Server, path string, body ssz.Object) *http.Response { + t.Helper() + buf := make([]byte, ssz.Size(body)) + if err := ssz.EncodeToBytes(buf, body); err != nil { + t.Fatalf("encode: %v", err) + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+BasePath+path, bytes.NewReader(buf)) + req.Header.Set("Content-Type", sszContentType) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("post %s: %v", path, err) + } + return resp +} + +func decodeSSZ[T ssz.Object](t *testing.T, resp *http.Response, obj T) { + t.Helper() + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := ssz.DecodeFromBytes(body, obj); err != nil { + t.Fatalf("decode response: %v\nbody: %x", err, body) + } +} + +func TestRouterNewPayload(t *testing.T) { + id := engine.PayloadID{1, 2, 3} + b := &stubBackend{ + npStatus: engine.PayloadStatusV1{Status: engine.VALID, LatestValidHash: &common.Hash{0xaa}}, + fcuID: &id, + } + srv := newTestServer(t, b) + defer srv.Close() + + env := &sszt.ExecutionPayloadEnvelopeAmsterdam{ + Payload: &sszt.ExecutionPayloadAmsterdam{ + BaseFeePerGas: uint256.NewInt(7e9), + LogsBloom: [256]byte{}, + }, + ParentBeaconBlockRoot: common.Hash{0x55}, + } + resp := sszPost(t, srv, "/amsterdam/payloads", env) + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + got := new(sszt.PayloadStatus) + decodeSSZ(t, resp, got) + if got.Status != sszt.StatusValid { + t.Errorf("status=%d want VALID(%d)", got.Status, sszt.StatusValid) + } + if len(got.LatestValidHash) != 1 || got.LatestValidHash[0] != (common.Hash{0xaa}) { + t.Errorf("LatestValidHash=%v", got.LatestValidHash) + } +} + +func TestRouterForkchoice(t *testing.T) { + id := engine.PayloadID{9, 9, 9} + b := &stubBackend{ + fcuStatus: engine.PayloadStatusV1{Status: engine.VALID}, + fcuID: &id, + } + srv := newTestServer(t, b) + defer srv.Close() + + fcu := &sszt.ForkchoiceUpdateAmsterdam{ + ForkchoiceState: &sszt.ForkchoiceState{HeadBlockHash: common.Hash{0xaa}}, + } + resp := sszPost(t, srv, "/amsterdam/forkchoice", fcu) + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + got := new(sszt.ForkchoiceUpdateResponseAmsterdam) + decodeSSZ(t, resp, got) + if got.PayloadStatus == nil || got.PayloadStatus.Status != sszt.StatusValid { + t.Errorf("status: %#v", got.PayloadStatus) + } + if len(got.PayloadID) != 1 || got.PayloadID[0] != [8]byte(id) { + t.Errorf("PayloadID=%v want %v", got.PayloadID, id) + } +} + +func TestRouterUnsupportedFork(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + + // Bogus fork in URL. + resp, err := srv.Client().Post(srv.URL+BasePath+"/bogus/payloads", sszContentType, bytes.NewReader(nil)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 400 { + t.Fatalf("want 400, got %d", resp.StatusCode) + } + + // Real fork but not Amsterdam. + resp, err = srv.Client().Post(srv.URL+BasePath+"/cancun/payloads", sszContentType, bytes.NewReader(nil)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 400 { + t.Fatalf("want 400 for cancun, got %d", resp.StatusCode) + } +} + +func TestRouterUnsupportedMediaType(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + resp, err := srv.Client().Post(srv.URL+BasePath+"/amsterdam/payloads", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 415 { + t.Fatalf("want 415, got %d", resp.StatusCode) + } +} + +func TestRouterCapabilities(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + BasePath + "/capabilities") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + var c capabilitiesResponse + if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { + t.Fatal(err) + } + if len(c.SupportedForks) == 0 || c.SupportedForks[0] != "amsterdam" { + t.Errorf("supported forks: %v", c.SupportedForks) + } + if got := c.Limits["blobs.max_versioned_hashes"]; got != sszt.MaxBlobsRequest { + t.Errorf("blobs limit: %d", got) + } +} + +func TestRouterIdentity(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + BasePath + "/identity") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + var body struct { + Versions []engine.ClientVersionV1 `json:"versions"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Versions) != 1 || body.Versions[0].Code != "GE" { + t.Errorf("identity: %#v", body) + } +} + +func TestRouterTrailingSlash404(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + BasePath + "/capabilities/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("want 404 for trailing slash, got %d", resp.StatusCode) + } +} + +func TestRouterSSZDecodeError(t *testing.T) { + srv := newTestServer(t, &stubBackend{}) + defer srv.Close() + req, _ := http.NewRequest(http.MethodPost, srv.URL+BasePath+"/amsterdam/payloads", bytes.NewReader([]byte{0xff})) + req.Header.Set("Content-Type", sszContentType) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Fatalf("want 400 ssz-decode-error, got %d", resp.StatusCode) + } + var p problem + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatal(err) + } + if p.Type != ErrSSZDecode { + t.Errorf("type=%s want %s", p.Type, ErrSSZDecode) + } +} + +func TestRouterBlobsV2AllOrNothing(t *testing.T) { + // One requested hash, but backend returns a nil entry -> /v2 should 204. + b := &stubBackend{v2Blobs: []*engine.BlobAndProofV2{nil}} + srv := newTestServer(t, b) + defer srv.Close() + req := &sszt.BlobsVersionedHashesRequest{VersionedHashes: []common.Hash{{0x01}}} + resp := sszPost(t, srv, "/blobs/v2", req) + defer resp.Body.Close() + if resp.StatusCode != 204 { + t.Fatalf("want 204, got %d", resp.StatusCode) + } +} + +func TestRouterBlobsV3PartialOK(t *testing.T) { + b := &stubBackend{v2Blobs: []*engine.BlobAndProofV2{nil, {Blob: make([]byte, sszt.BytesPerBlob)}}} + srv := newTestServer(t, b) + defer srv.Close() + req := &sszt.BlobsVersionedHashesRequest{VersionedHashes: []common.Hash{{0x01}, {0x02}}} + resp := sszPost(t, srv, "/blobs/v3", req) + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + got := new(sszt.BlobsV2Response) + decodeSSZ(t, resp, got) + if len(got.Entries) != 2 { + t.Fatalf("entries=%d", len(got.Entries)) + } + if got.Entries[0].Available { + t.Error("entry 0 should be unavailable") + } + if !got.Entries[1].Available { + t.Error("entry 1 should be available") + } +} + +func TestRouterGetPayloadCacheControl(t *testing.T) { + env := &engine.ExecutionPayloadEnvelope{ + ExecutionPayload: &engine.ExecutableData{LogsBloom: make([]byte, 256)}, + BlockValue: uint256.NewInt(0).ToBig(), + } + srv := newTestServer(t, &stubBackend{envelope: env}) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + BasePath + "/amsterdam/payloads/0x0102030405060708") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("want 200, got %d", resp.StatusCode) + } + if cc := resp.Header.Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control=%q want no-store", cc) + } +} + +func TestRouterGetPayloadUnknown(t *testing.T) { + srv := newTestServer(t, &stubBackend{getErr: engine.UnknownPayload}) + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + BasePath + "/amsterdam/payloads/0x0102030405060708") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("want 404, got %d", resp.StatusCode) + } +} diff --git a/go.mod b/go.mod index 17897a62c0..48dc94bcb8 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/jackpal/go-nat-pmp v1.0.2 github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 + github.com/karalabe/ssz v0.3.0 github.com/klauspost/compress v1.17.8 github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-colorable v0.1.13 @@ -86,6 +87,8 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 // indirect + github.com/prysmaticlabs/gohashtree v0.0.4-beta // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect diff --git a/go.sum b/go.sum index bad8a44cfd..7c0e810d7f 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/karalabe/ssz v0.3.0 h1:wSiAszX7Gr7TVheCebrDgRKEUnfHuD0WxQ2uDcbI4uU= +github.com/karalabe/ssz v0.3.0/go.mod h1:7BZG/jckt43eKw7sl/AF6gTcL0oxgFPme39m54v8rDI= github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4= github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -329,6 +331,8 @@ github.com/protolambda/zrnt v0.34.1 h1:qW55rnhZJDnOb3TwFiFRJZi3yTXFrJdGOFQM7vCwY github.com/protolambda/zrnt v0.34.1/go.mod h1:A0fezkp9Tt3GBLATSPIbuY4ywYESyAuc/FFmPKg8Lqs= github.com/protolambda/ztyp v0.2.2 h1:rVcL3vBu9W/aV646zF6caLS/dyn9BN8NYiuJzicLNyY= github.com/protolambda/ztyp v0.2.2/go.mod h1:9bYgKGqg3wJqT9ac1gI2hnVb0STQq7p/1lapqrqY1dU= +github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94= +github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= diff --git a/node/node.go b/node/node.go index 56ecd7d522..19f2905eaf 100644 --- a/node/node.go +++ b/node/node.go @@ -148,8 +148,9 @@ func New(conf *Config) (*Node, error) { // Configure RPC servers. node.http = newHTTPServer(node.log, conf.HTTPTimeouts) + // The REST + SSZ Engine API (execution-apis #793) requires HTTP/2, + // so the authenticated HTTP server speaks h2c alongside HTTP/1.1. node.httpAuth = newHTTPServer(node.log, conf.HTTPTimeouts) - node.httpAuth.disableHTTP2 = true // Engine API does not need HTTP/2 node.ws = newHTTPServer(node.log, rpc.DefaultHTTPTimeouts) node.wsAuth = newHTTPServer(node.log, rpc.DefaultHTTPTimeouts) node.wsAuth.disableHTTP2 = true @@ -434,6 +435,7 @@ func (n *Node) startRPC() error { if err := server.setListenAddr(n.config.AuthAddr, port); err != nil { return err } + server.authSecret = secret sharedConfig := rpcEndpointConfig{ jwtSecret: secret, batchItemLimit: engineAPIBatchItemLimit, @@ -605,6 +607,20 @@ func (n *Node) RegisterHandler(name, path string, handler http.Handler) { n.http.handlerNames[path] = name } +// RegisterAuthHandler mounts a handler on the authenticated HTTP server's mux. +// JWT auth is applied to it via the same middleware that protects the +// JSON-RPC engine namespace. Used by the REST + SSZ Engine API. +func (n *Node) RegisterAuthHandler(name, path string, handler http.Handler) { + n.lock.Lock() + defer n.lock.Unlock() + + if n.state != initializingState { + panic("can't register HTTP handler on running/stopped node") + } + n.httpAuth.mux.Handle(path, handler) + n.httpAuth.handlerNames[path] = name +} + // Attach creates an RPC client attached to an in-process API handler. func (n *Node) Attach() *rpc.Client { return rpc.DialInProc(n.inprocHandler) diff --git a/node/rpcstack.go b/node/rpcstack.go index 1db2ed3f44..6ab15c6655 100644 --- a/node/rpcstack.go +++ b/node/rpcstack.go @@ -93,6 +93,11 @@ type httpServer struct { // disableHTTP2 disables HTTP/2 support on this server when set to true. disableHTTP2 bool + + // authSecret, when set, causes the server to apply JWT authentication + // to mux-routed handlers. Used by the auth server to protect REST + // endpoints registered via Node.RegisterAuthHandler. + authSecret []byte } const ( @@ -221,7 +226,11 @@ func (h *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { // These are made available when RPC is enabled. muxHandler, pattern := h.mux.Handler(r) if pattern != "" { - muxHandler.ServeHTTP(w, r) + if len(h.authSecret) != 0 { + newJWTHandler(h.authSecret, muxHandler).ServeHTTP(w, r) + } else { + muxHandler.ServeHTTP(w, r) + } return }