diff --git a/beacon/engine/ssz/attributes_forks.go b/beacon/engine/ssz/attributes_forks.go
deleted file mode 100644
index 38ecbf2e2c..0000000000
--- a/beacon/engine/ssz/attributes_forks.go
+++ /dev/null
@@ -1,136 +0,0 @@
-// 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/bodies.go b/beacon/engine/ssz/bodies.go
index 6753309fe9..252535a228 100644
--- a/beacon/engine/ssz/bodies.go
+++ b/beacon/engine/ssz/bodies.go
@@ -17,6 +17,8 @@
package ssz
import (
+ "fmt"
+
"github.com/ethereum/go-ethereum/common"
"github.com/karalabe/ssz"
)
@@ -40,13 +42,70 @@ func (r *BodiesByHashRequest) DefineSSZ(c *ssz.Codec) {
ssz.DefineSliceOfStaticBytesContent(c, &r.BlockHashes, MaxBodiesRequest)
}
-// BodyEntryAmsterdam is the per-block entry returned by /amsterdam/bodies/...
-type BodyEntryAmsterdam struct {
- Available bool
- Body *ExecutionPayloadBodyAmsterdam
+// ExecutionPayloadBody is the monolithic body shape returned by /{fork}/bodies.
+//
+// Field groups:
+// - base (Paris): Transactions
+// - withdrawals (Shanghai): Withdrawals
+// - bal (Amsterdam): BlockAccessList
+type ExecutionPayloadBody struct {
+ Transactions [][]byte
+ Withdrawals []*Withdrawal // Shanghai+
+ BlockAccessList []byte // Amsterdam+
}
-func (e *BodyEntryAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
+func (b *ExecutionPayloadBody) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
+ var size uint32 = 4 // transactions offset
+ if siz.Fork() >= ssz.ForkShapella {
+ size += 4 // withdrawals offset
+ }
+ if siz.Fork() >= forkAmsterdam {
+ size += 4 // block_access_list offset
+ }
+ if fixed {
+ return size
+ }
+ size += ssz.SizeSliceOfDynamicBytes(siz, b.Transactions)
+ if siz.Fork() >= ssz.ForkShapella {
+ size += ssz.SizeSliceOfStaticObjects(siz, b.Withdrawals)
+ }
+ if siz.Fork() >= forkAmsterdam {
+ size += ssz.SizeDynamicBytes(siz, b.BlockAccessList)
+ }
+ return size
+}
+
+func (b *ExecutionPayloadBody) DefineSSZ(c *ssz.Codec) {
+ // offset phase
+ ssz.DefineSliceOfDynamicBytesOffset(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx)
+ ssz.DefineSliceOfStaticObjectsOffsetOnFork(c, &b.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+ ssz.DefineDynamicBytesOffsetOnFork(c, &b.BlockAccessList, MaxBalBytes, fromAmsterdam)
+
+ // content phase
+ ssz.DefineSliceOfDynamicBytesContent(c, &b.Transactions, MaxTxsPerPayload, MaxBytesPerTx)
+ ssz.DefineSliceOfStaticObjectsContentOnFork(c, &b.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+ ssz.DefineDynamicBytesContentOnFork(c, &b.BlockAccessList, MaxBalBytes, fromAmsterdam)
+}
+
+// Validate enforces that fields absent for the given fork are empty.
+func (b *ExecutionPayloadBody) Validate(fork ssz.Fork) error {
+ if fork < ssz.ForkShapella && len(b.Withdrawals) > 0 {
+ return fmt.Errorf("withdrawals set but fork %d predates Shanghai", fork)
+ }
+ if fork < forkAmsterdam && len(b.BlockAccessList) > 0 {
+ return fmt.Errorf("block_access_list set but fork %d predates Amsterdam", fork)
+ }
+ return nil
+}
+
+// BodyEntry is the per-block entry returned by /{fork}/bodies/... The Body's
+// wire shape follows the codec fork, so this single type spans all forks.
+type BodyEntry struct {
+ Available bool
+ Body *ExecutionPayloadBody
+}
+
+func (e *BodyEntry) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
size := uint32(1 + 4) // bool + offset
if fixed {
return size
@@ -55,19 +114,19 @@ func (e *BodyEntryAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
return size
}
-func (e *BodyEntryAmsterdam) DefineSSZ(c *ssz.Codec) {
+func (e *BodyEntry) 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
+// BodiesResponse is the SSZ response of /{fork}/bodies/...
+type BodiesResponse struct {
+ Entries []*BodyEntry
}
-func (r *BodiesResponseAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
+func (r *BodiesResponse) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
size := uint32(4)
if fixed {
return size
@@ -76,87 +135,7 @@ func (r *BodiesResponseAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
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) {
+func (r *BodiesResponse) DefineSSZ(c *ssz.Codec) {
ssz.DefineSliceOfDynamicObjectsOffset(c, &r.Entries, MaxBodiesRequest)
ssz.DefineSliceOfDynamicObjectsContent(c, &r.Entries, MaxBodiesRequest)
}
diff --git a/beacon/engine/ssz/bytecompat_test.go b/beacon/engine/ssz/bytecompat_test.go
new file mode 100644
index 0000000000..8d71807364
--- /dev/null
+++ b/beacon/engine/ssz/bytecompat_test.go
@@ -0,0 +1,146 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// Byte-compatibility guard for the monolith refactor. The golden hex strings
+// below were captured from the original per-fork structs (ExecutionPayload
+// {Paris,Shanghai,Cancun,Amsterdam} etc.) before they were collapsed into the
+// monolith types. Encoding the monolith with the same sample data on the
+// corresponding fork MUST reproduce these bytes exactly — that is the proof
+// the refactor is wire-compatible.
+
+package ssz
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/holiman/uint256"
+ "github.com/karalabe/ssz"
+)
+
+func encodeHexOnFork(t *testing.T, obj ssz.Object, fork ssz.Fork) string {
+ t.Helper()
+ buf := make([]byte, ssz.SizeOnFork(obj, fork))
+ if err := ssz.EncodeToBytesOnFork(buf, obj, fork); err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ return fmt.Sprintf("%x", buf)
+}
+
+// golden bytes captured from the pre-refactor per-fork structs.
+const (
+ goldenPayloadParis = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f1536500000000fc01000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000ff010000657874080000000a000000020304"
+ goldenPayloadShanghai = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000000002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000030200000e020000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+ goldenPayloadCancun = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000130200001e02000000000200000000000000000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+ goldenPayloadAmsterdam = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001c02000000863ba10100000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000001f0200002a0200000000020000000000000000000000000056020000c800000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef"
+
+ goldenAttrsParis = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000"
+ goldenAttrsShanghai = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000400000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+ goldenAttrsCancun = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000006000000077000000000000000000000000000000000000000000000000000000000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+ goldenAttrsAmsterdam = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000007000000077000000000000000000000000000000000000000000000000000000000000002a0000000000000080c3c901000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+
+ goldenBodyParis = "04000000080000000a000000020304"
+ goldenBodyCancun = "0800000013000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000"
+ goldenBodyAmsterdam = "0c0000001700000043000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef"
+)
+
+// sample field values, shared so only fork-specific fields move the bytes.
+var (
+ gParentHash = common.Hash{0x11}
+ gFeeRecipient = common.Address{0x22}
+ gStateRoot = common.Hash{0x33}
+ gReceiptsRoot = common.Hash{0x44}
+ gPrevRandao = common.Hash{0x55}
+ gBlockHash = common.Hash{0x66}
+ gExtra = []byte("ext")
+ gTxs = [][]byte{{0x02, 0x03}, {0x04}}
+ gWds = []*Withdrawal{{Index: 1, ValidatorIndex: 2, Address: common.Address{0x09}, Amount: 3}}
+ gBaseFee = uint256.NewInt(7e9)
+ gBeaconRoot = common.Hash{0x77}
+ gBAL = []byte{0xde, 0xad, 0xbe, 0xef}
+)
+
+// basePayload returns a monolith populated for the given fork (only the fork's
+// active fields are set, matching what the converters do).
+func basePayload(fork ssz.Fork) *ExecutionPayload {
+ p := &ExecutionPayload{
+ ParentHash: gParentHash, FeeRecipient: gFeeRecipient, StateRoot: gStateRoot,
+ ReceiptsRoot: gReceiptsRoot, PrevRandao: gPrevRandao, BlockNumber: 100,
+ GasLimit: 30000000, GasUsed: 21000, Timestamp: 1700000000, ExtraData: gExtra,
+ BaseFeePerGas: gBaseFee, BlockHash: gBlockHash, Transactions: gTxs,
+ }
+ if fork >= ssz.ForkShapella {
+ p.Withdrawals = gWds
+ }
+ if fork >= ssz.ForkDencun {
+ blob, excess := uint64(131072), uint64(0)
+ p.BlobGasUsed, p.ExcessBlobGas = &blob, &excess
+ }
+ if fork >= forkAmsterdam {
+ slot := uint64(200)
+ p.BlockAccessList = gBAL
+ p.SlotNumber = &slot
+ }
+ return p
+}
+
+func baseAttrs(fork ssz.Fork) *PayloadAttributes {
+ a := &PayloadAttributes{
+ Timestamp: 1700000000, PrevRandao: gPrevRandao, SuggestedFeeRecipient: gFeeRecipient,
+ }
+ if fork >= ssz.ForkShapella {
+ a.Withdrawals = gWds
+ }
+ if fork >= ssz.ForkDencun {
+ root := gBeaconRoot
+ a.ParentBeaconBlockRoot = &root
+ }
+ if fork >= forkAmsterdam {
+ slot, tgl := uint64(42), uint64(30000000)
+ a.SlotNumber, a.TargetGasLimit = &slot, &tgl
+ }
+ return a
+}
+
+func baseBody(fork ssz.Fork) *ExecutionPayloadBody {
+ b := &ExecutionPayloadBody{Transactions: gTxs}
+ if fork >= ssz.ForkShapella {
+ b.Withdrawals = gWds
+ }
+ if fork >= forkAmsterdam {
+ b.BlockAccessList = gBAL
+ }
+ return b
+}
+
+func TestMonolithByteCompat(t *testing.T) {
+ cases := []struct {
+ name string
+ fork ssz.Fork
+ obj ssz.Object
+ want string
+ }{
+ {"payload.paris", ssz.ForkParis, basePayload(ssz.ForkParis), goldenPayloadParis},
+ {"payload.shanghai", ssz.ForkShapella, basePayload(ssz.ForkShapella), goldenPayloadShanghai},
+ {"payload.cancun", ssz.ForkDencun, basePayload(ssz.ForkDencun), goldenPayloadCancun},
+ {"payload.amsterdam", forkAmsterdam, basePayload(forkAmsterdam), goldenPayloadAmsterdam},
+
+ {"attrs.paris", ssz.ForkParis, baseAttrs(ssz.ForkParis), goldenAttrsParis},
+ {"attrs.shanghai", ssz.ForkShapella, baseAttrs(ssz.ForkShapella), goldenAttrsShanghai},
+ {"attrs.cancun", ssz.ForkDencun, baseAttrs(ssz.ForkDencun), goldenAttrsCancun},
+ {"attrs.amsterdam", forkAmsterdam, baseAttrs(forkAmsterdam), goldenAttrsAmsterdam},
+
+ {"body.paris", ssz.ForkParis, baseBody(ssz.ForkParis), goldenBodyParis},
+ {"body.cancun", ssz.ForkDencun, baseBody(ssz.ForkDencun), goldenBodyCancun},
+ {"body.amsterdam", forkAmsterdam, baseBody(forkAmsterdam), goldenBodyAmsterdam},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := encodeHexOnFork(t, tc.obj, tc.fork)
+ if got != tc.want {
+ t.Errorf("byte mismatch for %s\nwant %s\n got %s", tc.name, tc.want, got)
+ }
+ })
+ }
+}
diff --git a/beacon/engine/ssz/convert.go b/beacon/engine/ssz/convert.go
index 70c67d9798..9b46bac469 100644
--- a/beacon/engine/ssz/convert.go
+++ b/beacon/engine/ssz/convert.go
@@ -21,6 +21,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/holiman/uint256"
+ "github.com/karalabe/ssz"
)
// statusToEnum maps the JSON-RPC PayloadStatusV1.Status string to the SSZ
@@ -127,46 +128,68 @@ func withdrawalsToTypes(ws []*Withdrawal) []*types.Withdrawal {
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{
+// PayloadAttributesFromEngine converts a JSON-RPC engine.PayloadAttributes into
+// the monolithic SSZ form for the given fork. Only the fields the fork's wire
+// shape carries are populated; the caller should pass the same fork to the
+// codec. target_gas_limit has no representation in engine.PayloadAttributes
+// today, so it is left nil for Amsterdam unless explicitly populated by the
+// caller afterwards.
+func PayloadAttributesFromEngine(a *engine.PayloadAttributes, fork ssz.Fork) *PayloadAttributes {
+ out := &PayloadAttributes{
Timestamp: a.Timestamp,
PrevRandao: a.Random,
SuggestedFeeRecipient: a.SuggestedFeeRecipient,
- Withdrawals: withdrawalsFromTypes(a.Withdrawals),
}
- if a.BeaconRoot != nil {
- out.ParentBeaconBlockRoot = *a.BeaconRoot
+ if fork >= ssz.ForkShapella {
+ out.Withdrawals = withdrawalsFromTypes(a.Withdrawals)
}
- if a.SlotNumber != nil {
- out.SlotNumber = *a.SlotNumber
+ if fork >= ssz.ForkDencun {
+ root := common.Hash{}
+ if a.BeaconRoot != nil {
+ root = *a.BeaconRoot
+ }
+ out.ParentBeaconBlockRoot = &root
+ }
+ if fork >= forkAmsterdam {
+ slot := uint64(0)
+ if a.SlotNumber != nil {
+ slot = *a.SlotNumber
+ }
+ out.SlotNumber = &slot
+ // target_gas_limit not carried by engine.PayloadAttributes; default 0.
+ tgl := uint64(0)
+ out.TargetGasLimit = &tgl
}
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{
+// PayloadAttributesToEngine is the inverse of PayloadAttributesFromEngine. The
+// target_gas_limit field has no representation in engine.PayloadAttributes and
+// is dropped; the caller can read it from the SSZ struct directly.
+func PayloadAttributesToEngine(a *PayloadAttributes) *engine.PayloadAttributes {
+ out := &engine.PayloadAttributes{
Timestamp: a.Timestamp,
Random: a.PrevRandao,
SuggestedFeeRecipient: a.SuggestedFeeRecipient,
Withdrawals: withdrawalsToTypes(a.Withdrawals),
- BeaconRoot: &root,
- SlotNumber: &slot,
}
+ if a.ParentBeaconBlockRoot != nil {
+ root := *a.ParentBeaconBlockRoot
+ out.BeaconRoot = &root
+ }
+ if a.SlotNumber != nil {
+ slot := *a.SlotNumber
+ out.SlotNumber = &slot
+ }
+ return out
}
-// 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 {
+// ExecutionPayloadFromEngine converts an ExecutableData into the monolithic SSZ
+// payload for the given fork. Only the fork's active fields are populated so
+// the codec (driven by the same fork) and Validate(fork) stay consistent.
+// BlockAccessList is not yet wired through ExecutableData; the caller sets it
+// separately for Amsterdam.
+func ExecutionPayloadFromEngine(d *engine.ExecutableData, fork ssz.Fork) *ExecutionPayload {
var bloom [256]byte
copy(bloom[:], d.LogsBloom)
var fee *uint256.Int
@@ -174,7 +197,7 @@ func ExecutionPayloadAmsterdamFromEngine(d *engine.ExecutableData) *ExecutionPay
fee = new(uint256.Int)
fee.SetFromBig(d.BaseFeePerGas)
}
- out := &ExecutionPayloadAmsterdam{
+ out := &ExecutionPayload{
ParentHash: d.ParentHash,
FeeRecipient: d.FeeRecipient,
StateRoot: d.StateRoot,
@@ -189,48 +212,66 @@ func ExecutionPayloadAmsterdamFromEngine(d *engine.ExecutableData) *ExecutionPay
BaseFeePerGas: fee,
BlockHash: d.BlockHash,
Transactions: d.Transactions,
- Withdrawals: withdrawalsFromTypes(d.Withdrawals),
}
- if d.BlobGasUsed != nil {
- out.BlobGasUsed = *d.BlobGasUsed
+ if fork >= ssz.ForkShapella {
+ out.Withdrawals = withdrawalsFromTypes(d.Withdrawals)
}
- if d.ExcessBlobGas != nil {
- out.ExcessBlobGas = *d.ExcessBlobGas
+ if fork >= ssz.ForkDencun {
+ blobGas := uint64(0)
+ if d.BlobGasUsed != nil {
+ blobGas = *d.BlobGasUsed
+ }
+ excess := uint64(0)
+ if d.ExcessBlobGas != nil {
+ excess = *d.ExcessBlobGas
+ }
+ out.BlobGasUsed = &blobGas
+ out.ExcessBlobGas = &excess
}
- if d.SlotNumber != nil {
- out.SlotNumber = *d.SlotNumber
+ if fork >= forkAmsterdam {
+ slot := uint64(0)
+ if d.SlotNumber != nil {
+ slot = *d.SlotNumber
+ }
+ out.SlotNumber = &slot
}
- // 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 {
+// ExecutionPayloadToEngine is the inverse helper. The caller is responsible for
+// the BAL payload that ExecutableData doesn't yet carry.
+func ExecutionPayloadToEngine(p *ExecutionPayload) *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,
+ 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),
}
if p.BaseFeePerGas != nil {
out.BaseFeePerGas = p.BaseFeePerGas.ToBig()
}
+ if p.BlobGasUsed != nil {
+ blobGas := *p.BlobGasUsed
+ out.BlobGasUsed = &blobGas
+ }
+ if p.ExcessBlobGas != nil {
+ excess := *p.ExcessBlobGas
+ out.ExcessBlobGas = &excess
+ }
+ if p.SlotNumber != nil {
+ slot := *p.SlotNumber
+ out.SlotNumber = &slot
+ }
return out
}
diff --git a/beacon/engine/ssz/envelopes.go b/beacon/engine/ssz/envelopes.go
index d69641e555..944cd7e328 100644
--- a/beacon/engine/ssz/envelopes.go
+++ b/beacon/engine/ssz/envelopes.go
@@ -25,7 +25,7 @@ import (
// ExecutionPayloadEnvelopeAmsterdam is the request body of
// POST /amsterdam/payloads.
type ExecutionPayloadEnvelopeAmsterdam struct {
- Payload *ExecutionPayloadAmsterdam
+ Payload *ExecutionPayload
ParentBeaconBlockRoot common.Hash
ExecutionRequests [][]byte
}
@@ -82,7 +82,7 @@ func (b *BlobsBundleV2) DefineSSZ(c *ssz.Codec) {
// BuiltPayloadAmsterdam is the response of GET /amsterdam/payloads/{id}.
type BuiltPayloadAmsterdam struct {
- Payload *ExecutionPayloadAmsterdam
+ Payload *ExecutionPayload
BlockValue *uint256.Int
BlobsBundle *BlobsBundleV2
ExecutionRequests [][]byte
@@ -120,7 +120,7 @@ func (p *BuiltPayloadAmsterdam) DefineSSZ(c *ssz.Codec) {
// length 0 or 1 of the 16-byte Bitvector wrapper.
type ForkchoiceUpdateAmsterdam struct {
ForkchoiceState *ForkchoiceState
- PayloadAttributes []*PayloadAttributesAmsterdam
+ PayloadAttributes []*PayloadAttributes
CustodyColumns []*Bitvector128
}
diff --git a/beacon/engine/ssz/forkmap.go b/beacon/engine/ssz/forkmap.go
new file mode 100644
index 0000000000..d66901af00
--- /dev/null
+++ b/beacon/engine/ssz/forkmap.go
@@ -0,0 +1,60 @@
+// 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/params/forks"
+ "github.com/karalabe/ssz"
+)
+
+// karalabe/ssz's Fork enum currently stops at Pectra (Prague). The monolith
+// types below gate fields with ForkFilter{Added,Removed}, and the codec only
+// performs ordered comparisons (`fork < Added`, `fork >= Removed`) against
+// Codec.fork. Any strictly-monotonic values placed after ForkPectra therefore
+// behave correctly. We define local aliases for the forks the library doesn't
+// name yet.
+//
+// IMPORTANT: these are only meaningful relative to one another and to the
+// library's own enum; never persist them. If a future karalabe/ssz release
+// adds Osaka/Amsterdam (or new forks between Pectra and these), revisit this
+// table so the ordering stays gap-consistent with the upstream enum.
+const (
+ forkOsaka = ssz.ForkPectra + 1
+ forkAmsterdam = ssz.ForkPectra + 2
+)
+
+// ForkFor maps a geth params/forks.Fork onto the karalabe/ssz Fork value
+// the codec multiplexes on. The bool is false for forks that predate the
+// Engine API (and thus have no SSZ wire representation here).
+func ForkFor(f forks.Fork) (ssz.Fork, bool) {
+ switch f {
+ case forks.Paris:
+ return ssz.ForkParis, true
+ case forks.Shanghai:
+ return ssz.ForkShapella, true // EL Shanghai == CL Shapella
+ case forks.Cancun:
+ return ssz.ForkDencun, true // EL Cancun == CL Dencun
+ case forks.Prague:
+ return ssz.ForkPectra, true // EL Prague == CL Pectra
+ case forks.Osaka:
+ return forkOsaka, true
+ case forks.Amsterdam:
+ return forkAmsterdam, true
+ default:
+ return ssz.ForkUnknown, false
+ }
+}
diff --git a/beacon/engine/ssz/payload.go b/beacon/engine/ssz/payload.go
new file mode 100644
index 0000000000..751d648252
--- /dev/null
+++ b/beacon/engine/ssz/payload.go
@@ -0,0 +1,251 @@
+// 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 (
+ "fmt"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/holiman/uint256"
+ "github.com/karalabe/ssz"
+)
+
+// Fork-boundary filters. Fields gated by these only participate in size,
+// encode, decode and hash when Codec.fork is in range — see forkmap.go for how
+// the geth fork maps onto ssz.Fork. Grouping the filters here (rather than one
+// flag per field) keeps the "field-group" structure visible: each filter is a
+// group boundary, and DefineSSZ applies it to every field the group owns.
+var (
+ // withdrawals group, introduced in Shanghai (Shapella).
+ fromShanghai = ssz.ForkFilter{Added: ssz.ForkShapella}
+ // blob-gas group, introduced in Cancun (Dencun).
+ fromCancun = ssz.ForkFilter{Added: ssz.ForkDencun}
+ // beacon-root attribute, introduced in Cancun (Dencun).
+ // (shares the Cancun boundary but kept named for clarity at call sites)
+ // bal + slot group, introduced in Amsterdam.
+ fromAmsterdam = ssz.ForkFilter{Added: forkAmsterdam}
+)
+
+// ExecutionPayload is the monolithic execution payload spanning every fork from
+// Paris onward. Fork-specific fields are gated with ...OnFork; the in-memory
+// struct is a superset and the fork passed to the codec selects the wire shape.
+//
+// Field groups (see the filter vars above):
+// - base (Paris): ParentHash … Transactions
+// - withdrawals (Shanghai): Withdrawals
+// - blobGas (Cancun): BlobGasUsed, ExcessBlobGas
+// - bal (Amsterdam): BlockAccessList, SlotNumber
+//
+// Gated scalars are pointers so the codec can distinguish "absent for this
+// fork" (nil) from "present and zero"; Validate(fork) enforces that invariant.
+type ExecutionPayload struct {
+ // base group (Paris)
+ 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 group (Shanghai)
+ Withdrawals []*Withdrawal
+
+ // blobGas group (Cancun)
+ BlobGasUsed *uint64
+ ExcessBlobGas *uint64
+
+ // bal group (Amsterdam)
+ BlockAccessList []byte
+ SlotNumber *uint64
+}
+
+func (p *ExecutionPayload) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
+ // Fixed portion is fork-dependent; let the codec primitives account for it
+ // via the active fork rather than hand-summing magic byte constants.
+ var size uint32
+ // base fixed: 5 hashes(32) + addr(20) + bloom(256) + 4 u64s + uint256(32)
+ // + 2 offsets(extra, txs)(4)
+ size += 5*32 + 20 + 256 + 4*8 + 32 + 2*4
+ if siz.Fork() >= ssz.ForkShapella {
+ size += 4 // withdrawals offset
+ }
+ if siz.Fork() >= ssz.ForkDencun {
+ size += 2 * 8 // blob_gas_used, excess_blob_gas
+ }
+ if siz.Fork() >= forkAmsterdam {
+ size += 4 + 8 // block_access_list offset, slot_number
+ }
+ if fixed {
+ return size
+ }
+ size += ssz.SizeDynamicBytes(siz, p.ExtraData)
+ size += ssz.SizeSliceOfDynamicBytes(siz, p.Transactions)
+ if siz.Fork() >= ssz.ForkShapella {
+ size += ssz.SizeSliceOfStaticObjects(siz, p.Withdrawals)
+ }
+ if siz.Fork() >= forkAmsterdam {
+ size += ssz.SizeDynamicBytes(siz, p.BlockAccessList)
+ }
+ return size
+}
+
+func (p *ExecutionPayload) DefineSSZ(c *ssz.Codec) {
+ // --- offset phase ---
+ 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.DefineSliceOfStaticObjectsOffsetOnFork(c, &p.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+ ssz.DefineUint64PointerOnFork(c, &p.BlobGasUsed, fromCancun)
+ ssz.DefineUint64PointerOnFork(c, &p.ExcessBlobGas, fromCancun)
+ ssz.DefineDynamicBytesOffsetOnFork(c, &p.BlockAccessList, MaxBalBytes, fromAmsterdam)
+ ssz.DefineUint64PointerOnFork(c, &p.SlotNumber, fromAmsterdam)
+
+ // --- content phase ---
+ ssz.DefineDynamicBytesContent(c, &p.ExtraData, MaxExtraDataBytes)
+ ssz.DefineSliceOfDynamicBytesContent(c, &p.Transactions, MaxTxsPerPayload, MaxBytesPerTx)
+ ssz.DefineSliceOfStaticObjectsContentOnFork(c, &p.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+ ssz.DefineDynamicBytesContentOnFork(c, &p.BlockAccessList, MaxBalBytes, fromAmsterdam)
+}
+
+// Validate checks that the in-memory field set matches what the given fork's
+// wire shape carries: gated scalars must be present iff active, and absent
+// fields must be empty so the codec doesn't silently drop populated data.
+func (p *ExecutionPayload) Validate(fork ssz.Fork) error {
+ // withdrawals (Shanghai+)
+ if fork < ssz.ForkShapella && len(p.Withdrawals) > 0 {
+ return fmt.Errorf("withdrawals set but fork %d predates Shanghai", fork)
+ }
+ // blobGas (Cancun+)
+ if fork >= ssz.ForkDencun {
+ if p.BlobGasUsed == nil || p.ExcessBlobGas == nil {
+ return fmt.Errorf("blob gas fields required at fork %d", fork)
+ }
+ } else if p.BlobGasUsed != nil || p.ExcessBlobGas != nil {
+ return fmt.Errorf("blob gas fields set but fork %d predates Cancun", fork)
+ }
+ // bal + slot (Amsterdam+)
+ if fork >= forkAmsterdam {
+ if p.SlotNumber == nil {
+ return fmt.Errorf("slot_number required at fork %d", fork)
+ }
+ } else {
+ if len(p.BlockAccessList) > 0 {
+ return fmt.Errorf("block_access_list set but fork %d predates Amsterdam", fork)
+ }
+ if p.SlotNumber != nil {
+ return fmt.Errorf("slot_number set but fork %d predates Amsterdam", fork)
+ }
+ }
+ return nil
+}
+
+// PayloadAttributes is the monolithic build-attributes container spanning every
+// fork from Paris onward.
+//
+// Field groups:
+// - base (Paris): Timestamp, PrevRandao, SuggestedFeeRecipient
+// - withdrawals (Shanghai): Withdrawals
+// - beaconRoot (Cancun): ParentBeaconBlockRoot
+// - amsterdam (Amsterdam): SlotNumber, TargetGasLimit
+type PayloadAttributes struct {
+ Timestamp uint64
+ PrevRandao common.Hash
+ SuggestedFeeRecipient common.Address
+
+ Withdrawals []*Withdrawal // Shanghai+
+
+ ParentBeaconBlockRoot *common.Hash // Cancun+
+
+ SlotNumber *uint64 // Amsterdam+
+ TargetGasLimit *uint64 // Amsterdam+
+}
+
+func (a *PayloadAttributes) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
+ // base fixed: timestamp(8) + randao(32) + fee_recipient(20)
+ var size uint32 = 8 + 32 + 20
+ if siz.Fork() >= ssz.ForkShapella {
+ size += 4 // withdrawals offset
+ }
+ if siz.Fork() >= ssz.ForkDencun {
+ size += 32 // parent_beacon_block_root
+ }
+ if siz.Fork() >= forkAmsterdam {
+ size += 8 + 8 // slot_number, target_gas_limit
+ }
+ if fixed {
+ return size
+ }
+ if siz.Fork() >= ssz.ForkShapella {
+ size += ssz.SizeSliceOfStaticObjects(siz, a.Withdrawals)
+ }
+ return size
+}
+
+func (a *PayloadAttributes) DefineSSZ(c *ssz.Codec) {
+ // offset phase
+ ssz.DefineUint64(c, &a.Timestamp)
+ ssz.DefineStaticBytes(c, &a.PrevRandao)
+ ssz.DefineStaticBytes(c, &a.SuggestedFeeRecipient)
+ ssz.DefineSliceOfStaticObjectsOffsetOnFork(c, &a.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+ ssz.DefineStaticBytesPointerOnFork(c, &a.ParentBeaconBlockRoot, fromCancun)
+ ssz.DefineUint64PointerOnFork(c, &a.SlotNumber, fromAmsterdam)
+ ssz.DefineUint64PointerOnFork(c, &a.TargetGasLimit, fromAmsterdam)
+
+ // content phase
+ ssz.DefineSliceOfStaticObjectsContentOnFork(c, &a.Withdrawals, MaxWithdrawalsPerPayload, fromShanghai)
+}
+
+// Validate enforces presence/absence of gated fields for the given fork.
+func (a *PayloadAttributes) Validate(fork ssz.Fork) error {
+ if fork < ssz.ForkShapella && len(a.Withdrawals) > 0 {
+ return fmt.Errorf("withdrawals set but fork %d predates Shanghai", fork)
+ }
+ if fork >= ssz.ForkDencun {
+ if a.ParentBeaconBlockRoot == nil {
+ return fmt.Errorf("parent_beacon_block_root required at fork %d", fork)
+ }
+ } else if a.ParentBeaconBlockRoot != nil {
+ return fmt.Errorf("parent_beacon_block_root set but fork %d predates Cancun", fork)
+ }
+ if fork >= forkAmsterdam {
+ if a.SlotNumber == nil || a.TargetGasLimit == nil {
+ return fmt.Errorf("slot_number and target_gas_limit required at fork %d", fork)
+ }
+ } else if a.SlotNumber != nil || a.TargetGasLimit != nil {
+ return fmt.Errorf("amsterdam attributes set but fork %d predates Amsterdam", fork)
+ }
+ return nil
+}
diff --git a/beacon/engine/ssz/payload_amsterdam.go b/beacon/engine/ssz/payload_amsterdam.go
deleted file mode 100644
index a21dde94d7..0000000000
--- a/beacon/engine/ssz/payload_amsterdam.go
+++ /dev/null
@@ -1,150 +0,0 @@
-// 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
deleted file mode 100644
index 9d48d8706d..0000000000
--- a/beacon/engine/ssz/payload_forks.go
+++ /dev/null
@@ -1,185 +0,0 @@
-// 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
index 25feb1a277..2e5afa23b8 100644
--- a/beacon/engine/ssz/roundtrip_test.go
+++ b/beacon/engine/ssz/roundtrip_test.go
@@ -77,8 +77,30 @@ func TestPayloadStatusRoundtrip(t *testing.T) {
encDec(t, &PayloadStatus{Status: StatusSyncing}, func() *PayloadStatus { return new(PayloadStatus) })
}
+// encDecOnFork is the fork-aware sibling of encDec for monolith types.
+func encDecOnFork[T ssz.Object](t *testing.T, obj T, fork ssz.Fork, newEmpty func() T) {
+ t.Helper()
+ size := ssz.SizeOnFork(obj, fork)
+ buf := make([]byte, size)
+ if err := ssz.EncodeToBytesOnFork(buf, obj, fork); err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ got := newEmpty()
+ if err := ssz.DecodeFromBytesOnFork(buf, got, fork); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ rebuf := make([]byte, ssz.SizeOnFork(got, fork))
+ if err := ssz.EncodeToBytesOnFork(rebuf, got, fork); 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 TestExecutionPayloadAmsterdamRoundtrip(t *testing.T) {
- p := &ExecutionPayloadAmsterdam{
+ blob, excess, slot := uint64(131072), uint64(0), uint64(200)
+ p := &ExecutionPayload{
ParentHash: common.Hash{0x11},
FeeRecipient: common.Address{0x22},
StateRoot: common.Hash{0x33},
@@ -93,26 +115,28 @@ func TestExecutionPayloadAmsterdamRoundtrip(t *testing.T) {
BlockHash: common.Hash{0x66},
Transactions: [][]byte{{0x02, 0x03}, {0x04}},
Withdrawals: []*Withdrawal{{Index: 1, ValidatorIndex: 2, Amount: 3}},
- BlobGasUsed: 131072,
- ExcessBlobGas: 0,
+ BlobGasUsed: &blob,
+ ExcessBlobGas: &excess,
BlockAccessList: []byte{0xde, 0xad, 0xbe, 0xef},
- SlotNumber: 200,
+ SlotNumber: &slot,
}
- encDec(t, p, func() *ExecutionPayloadAmsterdam { return new(ExecutionPayloadAmsterdam) })
+ encDecOnFork(t, p, forkAmsterdam, func() *ExecutionPayload { return new(ExecutionPayload) })
}
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{
+ root := common.Hash{0x77}
+ slot, tgl := uint64(42), uint64(30000000)
+ attrs := &PayloadAttributes{
Timestamp: 1700000000,
PrevRandao: common.Hash{0x55},
SuggestedFeeRecipient: common.Address{0x66},
Withdrawals: []*Withdrawal{},
- ParentBeaconBlockRoot: common.Hash{0x77},
- SlotNumber: 42,
- TargetGasLimit: 30000000,
+ ParentBeaconBlockRoot: &root,
+ SlotNumber: &slot,
+ TargetGasLimit: &tgl,
}
fcu := &ForkchoiceUpdateAmsterdam{
ForkchoiceState: &ForkchoiceState{
@@ -120,10 +144,10 @@ func TestForkchoiceUpdateAmsterdamRoundtrip(t *testing.T) {
SafeBlockHash: common.Hash{0xbb},
FinalizedBlockHash: common.Hash{0xcc},
},
- PayloadAttributes: []*PayloadAttributesAmsterdam{attrs},
+ PayloadAttributes: []*PayloadAttributes{attrs},
CustodyColumns: []*Bitvector128{bits},
}
- encDec(t, fcu, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) })
+ encDecOnFork(t, fcu, forkAmsterdam, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) })
// Without optionals.
bare := &ForkchoiceUpdateAmsterdam{
@@ -131,5 +155,5 @@ func TestForkchoiceUpdateAmsterdamRoundtrip(t *testing.T) {
HeadBlockHash: common.Hash{0xdd},
},
}
- encDec(t, bare, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) })
+ encDecOnFork(t, bare, forkAmsterdam, func() *ForkchoiceUpdateAmsterdam { return new(ForkchoiceUpdateAmsterdam) })
}
diff --git a/beacon/engine/ssz/validate_test.go b/beacon/engine/ssz/validate_test.go
new file mode 100644
index 0000000000..0c2568c7dc
--- /dev/null
+++ b/beacon/engine/ssz/validate_test.go
@@ -0,0 +1,120 @@
+// 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 (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/karalabe/ssz"
+)
+
+func u64p(v uint64) *uint64 { return &v }
+
+func TestExecutionPayloadValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ fork ssz.Fork
+ mutate func(*ExecutionPayload)
+ wantErr bool
+ }{
+ {"paris ok", ssz.ForkParis, func(p *ExecutionPayload) {}, false},
+ {"paris with withdrawals", ssz.ForkParis, func(p *ExecutionPayload) {
+ p.Withdrawals = []*Withdrawal{{}}
+ }, true},
+ {"paris with blob gas", ssz.ForkParis, func(p *ExecutionPayload) {
+ p.BlobGasUsed = u64p(1)
+ }, true},
+ {"cancun missing blob gas", ssz.ForkDencun, func(p *ExecutionPayload) {
+ p.BlobGasUsed, p.ExcessBlobGas = nil, nil
+ }, true},
+ {"cancun ok", ssz.ForkDencun, func(p *ExecutionPayload) {
+ p.BlobGasUsed, p.ExcessBlobGas = u64p(0), u64p(0)
+ }, false},
+ {"cancun with bal", ssz.ForkDencun, func(p *ExecutionPayload) {
+ p.BlobGasUsed, p.ExcessBlobGas = u64p(0), u64p(0)
+ p.BlockAccessList = []byte{0x01}
+ }, true},
+ {"amsterdam missing slot", forkAmsterdam, func(p *ExecutionPayload) {
+ p.BlobGasUsed, p.ExcessBlobGas = u64p(0), u64p(0)
+ p.SlotNumber = nil
+ }, true},
+ {"amsterdam ok", forkAmsterdam, func(p *ExecutionPayload) {
+ p.BlobGasUsed, p.ExcessBlobGas = u64p(0), u64p(0)
+ p.SlotNumber = u64p(1)
+ p.BlockAccessList = []byte{0x01}
+ }, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ p := &ExecutionPayload{}
+ tc.mutate(p)
+ err := p.Validate(tc.fork)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("Validate(%d) err=%v wantErr=%v", tc.fork, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestPayloadAttributesValidate(t *testing.T) {
+ root := common.Hash{0x77}
+ tests := []struct {
+ name string
+ fork ssz.Fork
+ mutate func(*PayloadAttributes)
+ wantErr bool
+ }{
+ {"paris ok", ssz.ForkParis, func(a *PayloadAttributes) {}, false},
+ {"cancun missing beacon root", ssz.ForkDencun, func(a *PayloadAttributes) {}, true},
+ {"cancun ok", ssz.ForkDencun, func(a *PayloadAttributes) {
+ a.ParentBeaconBlockRoot = &root
+ }, false},
+ {"paris with beacon root", ssz.ForkParis, func(a *PayloadAttributes) {
+ a.ParentBeaconBlockRoot = &root
+ }, true},
+ {"amsterdam missing slot/tgl", forkAmsterdam, func(a *PayloadAttributes) {
+ a.ParentBeaconBlockRoot = &root
+ }, true},
+ {"amsterdam ok", forkAmsterdam, func(a *PayloadAttributes) {
+ a.ParentBeaconBlockRoot = &root
+ a.SlotNumber, a.TargetGasLimit = u64p(1), u64p(2)
+ }, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ a := &PayloadAttributes{}
+ tc.mutate(a)
+ err := a.Validate(tc.fork)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("Validate(%d) err=%v wantErr=%v", tc.fork, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestExecutionPayloadBodyValidate(t *testing.T) {
+ if err := (&ExecutionPayloadBody{Withdrawals: []*Withdrawal{{}}}).Validate(ssz.ForkParis); err == nil {
+ t.Error("expected error for withdrawals at Paris")
+ }
+ if err := (&ExecutionPayloadBody{BlockAccessList: []byte{1}}).Validate(ssz.ForkDencun); err == nil {
+ t.Error("expected error for bal at Cancun")
+ }
+ if err := (&ExecutionPayloadBody{Withdrawals: []*Withdrawal{{}}}).Validate(forkAmsterdam); err != nil {
+ t.Errorf("unexpected error at Amsterdam: %v", err)
+ }
+}
diff --git a/eth/engineapi/blobs.go b/eth/engineapi/blobs.go
index c13362fca9..0d80da45c6 100644
--- a/eth/engineapi/blobs.go
+++ b/eth/engineapi/blobs.go
@@ -21,6 +21,7 @@ import (
"github.com/ethereum/go-ethereum/beacon/engine"
sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz"
+ "github.com/karalabe/ssz"
)
// handleBlobs dispatches POST /engine/v2/blobs/v{1,2,3,4}.
@@ -46,7 +47,7 @@ func (rt *Router) handleBlobs(w http.ResponseWriter, r *http.Request, suffix str
func (rt *Router) handleBlobsV1(w http.ResponseWriter, r *http.Request) {
req := new(sszt.BlobsVersionedHashesRequest)
- if !readSSZRequest(w, r, req, maxPayloadBytes) {
+ if !readSSZRequest(w, r, req, ssz.ForkUnknown, maxPayloadBytes) {
return
}
if len(req.VersionedHashes) > sszt.MaxBlobsRequest {
@@ -69,14 +70,14 @@ func (rt *Router) handleBlobsV1(w http.ResponseWriter, r *http.Request) {
}
resp.Entries[i] = e
}
- writeSSZResponse(w, resp)
+ writeSSZResponse(w, resp, ssz.ForkUnknown)
}
// 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) {
+ if !readSSZRequest(w, r, req, ssz.ForkUnknown, maxPayloadBytes) {
return
}
if len(req.VersionedHashes) > sszt.MaxBlobsRequest {
@@ -106,7 +107,7 @@ func (rt *Router) handleBlobsV2(w http.ResponseWriter, r *http.Request, allowPar
}
resp.Entries[i] = e
}
- writeSSZResponse(w, resp)
+ writeSSZResponse(w, resp, ssz.ForkUnknown)
}
// handleBlobsV4 implements POST /engine/v2/blobs/v4 (cell-range selection).
@@ -115,7 +116,7 @@ func (rt *Router) handleBlobsV2(w http.ResponseWriter, r *http.Request, allowPar
// 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) {
+ if !readSSZRequest(w, r, req, ssz.ForkUnknown, maxPayloadBytes) {
return
}
if req.IndicesBitarray == nil || len(req.IndicesBitarray.Bytes) != sszt.CellsPerExtBlob/8 {
diff --git a/eth/engineapi/bodies.go b/eth/engineapi/bodies.go
index abc907ed38..6dd77209b8 100644
--- a/eth/engineapi/bodies.go
+++ b/eth/engineapi/bodies.go
@@ -23,16 +23,17 @@ import (
sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/params/forks"
+ "github.com/karalabe/ssz"
)
// 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, "")
+ sf, ok := resolveFork(w, fork, forks.Paris)
+ if !ok {
return
}
req := new(sszt.BodiesByHashRequest)
- if !readSSZRequest(w, r, req, maxPayloadBytes) {
+ if !readSSZRequest(w, r, req, sf, maxPayloadBytes) {
return
}
if len(req.BlockHashes) > sszt.MaxBodiesRequest {
@@ -40,13 +41,13 @@ func (rt *Router) handleBodiesByHash(w http.ResponseWriter, r *http.Request, for
return
}
bodies, timestamps := rt.backend.BodiesByHash(req.BlockHashes)
- writeSSZResponse(w, buildBodiesResponseAmsterdam(rt.backend, fork, bodies, timestamps))
+ writeSSZResponse(w, buildBodiesResponse(rt.backend, fork, sf, bodies, timestamps), sf)
}
// 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, "")
+ sf, ok := resolveFork(w, fork, forks.Paris)
+ if !ok {
return
}
q := r.URL.Query()
@@ -65,43 +66,49 @@ func (rt *Router) handleBodiesByRange(w http.ResponseWriter, r *http.Request, fo
return
}
bodies, timestamps := rt.backend.BodiesByRange(from, count)
- writeSSZResponse(w, buildBodiesResponseAmsterdam(rt.backend, fork, bodies, timestamps))
+ writeSSZResponse(w, buildBodiesResponse(rt.backend, fork, sf, bodies, timestamps), sf)
}
-// 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)),
+// buildBodiesResponse assembles a BodiesResponse for the given fork, marking
+// out-of-era blocks as available=false per the URL fork window. The body shape
+// is fork-driven by the codec; bodyToSSZ populates the superset and the codec
+// emits only the fork's active fields.
+func buildBodiesResponse(b Backend, fork forks.Fork, sf ssz.Fork, bodies []*types.Body, ts []uint64) *sszt.BodiesResponse {
+ out := &sszt.BodiesResponse{
+ Entries: make([]*sszt.BodyEntry, len(bodies)),
}
for i, body := range bodies {
- entry := &sszt.BodyEntryAmsterdam{Body: new(sszt.ExecutionPayloadBodyAmsterdam)}
+ entry := &sszt.BodyEntry{Body: new(sszt.ExecutionPayloadBody)}
if body != nil && b.ForkFromTimestamp(ts[i]) == fork {
entry.Available = true
- entry.Body = bodyToAmsterdamSSZ(body)
+ entry.Body = bodyToSSZ(body, sf)
}
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{
+// bodyToSSZ flattens a *types.Body into the monolithic body shape. Withdrawals
+// are only attached from Shanghai on, matching the fork's wire shape.
+func bodyToSSZ(body *types.Body, sf ssz.Fork) *sszt.ExecutionPayloadBody {
+ out := &sszt.ExecutionPayloadBody{
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,
+ if sf >= ssz.ForkShapella {
+ out.Withdrawals = make([]*sszt.Withdrawal, len(body.Withdrawals))
+ 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.
+ // BlockAccessList is not yet on types.Body; once wired through, copy here
+ // (gated by sf >= forkAmsterdam).
return out
}
diff --git a/eth/engineapi/forkchoice.go b/eth/engineapi/forkchoice.go
index ef4568eb47..15acd786c6 100644
--- a/eth/engineapi/forkchoice.go
+++ b/eth/engineapi/forkchoice.go
@@ -26,12 +26,14 @@ import (
// 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, "")
+ // The execution-apis spec currently only defines the forkchoice envelope
+ // for Amsterdam; the inner attributes shape is fork-driven by the codec.
+ sf, ok := resolveFork(w, fork, forks.Amsterdam)
+ if !ok {
return
}
fcu := new(sszt.ForkchoiceUpdateAmsterdam)
- if !readSSZRequest(w, r, fcu, maxPayloadBytes) {
+ if !readSSZRequest(w, r, fcu, sf, maxPayloadBytes) {
return
}
if err := fcu.Validate(); err != nil {
@@ -41,17 +43,21 @@ func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork
state := sszt.ForkchoiceStateToV1(fcu.ForkchoiceState)
var attrs *engine.PayloadAttributes
if len(fcu.PayloadAttributes) == 1 {
- ssz := fcu.PayloadAttributes[0]
+ attr := fcu.PayloadAttributes[0]
+ if err := attr.Validate(sf); err != nil {
+ writeProblem(w, http.StatusBadRequest, ErrInvalidAttributes, err.Error())
+ return
+ }
// 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 {
+ if rt.backend.ForkFromTimestamp(attr.Timestamp) != fork {
writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork,
"payload_attributes timestamp does not match URL fork")
return
}
- attrs = sszt.PayloadAttributesAmsterdamToEngine(ssz)
+ attrs = sszt.PayloadAttributesToEngine(attr)
// 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
@@ -73,5 +79,5 @@ func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork
if resp.PayloadID != nil {
out.PayloadID = [][8]byte{[8]byte(*resp.PayloadID)}
}
- writeSSZResponse(w, out)
+ writeSSZResponse(w, out, sf)
}
diff --git a/eth/engineapi/forkresolve.go b/eth/engineapi/forkresolve.go
new file mode 100644
index 0000000000..3f4af2c657
--- /dev/null
+++ b/eth/engineapi/forkresolve.go
@@ -0,0 +1,41 @@
+// 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"
+
+ sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz"
+ "github.com/ethereum/go-ethereum/params/forks"
+ "github.com/karalabe/ssz"
+)
+
+// resolveFork maps the URL fork onto the ssz.Fork the codec multiplexes on,
+// enforcing that it is at least min. On failure it writes the appropriate
+// problem response and returns ok=false; the caller should return immediately.
+func resolveFork(w http.ResponseWriter, fork, min forks.Fork) (ssz.Fork, bool) {
+ if fork < min {
+ writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "")
+ return ssz.ForkUnknown, false
+ }
+ sf, ok := sszt.ForkFor(fork)
+ if !ok {
+ writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, "")
+ return ssz.ForkUnknown, false
+ }
+ return sf, true
+}
diff --git a/eth/engineapi/get_payload.go b/eth/engineapi/get_payload.go
index ccc51a8578..5975c0114f 100644
--- a/eth/engineapi/get_payload.go
+++ b/eth/engineapi/get_payload.go
@@ -24,12 +24,13 @@ import (
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/params/forks"
"github.com/holiman/uint256"
+ "github.com/karalabe/ssz"
)
// 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, "")
+ sf, ok := resolveFork(w, fork, forks.Amsterdam)
+ if !ok {
return
}
raw, err := hexutil.Decode(idHex)
@@ -46,17 +47,17 @@ func (rt *Router) handleGetPayload(w http.ResponseWriter, r *http.Request, fork
return
}
- out := buildBuiltPayloadAmsterdam(env)
+ out := buildBuiltPayloadAmsterdam(env, sf)
w.Header().Set("Cache-Control", "no-store")
- writeSSZResponse(w, out)
+ writeSSZResponse(w, out, sf)
}
// 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 {
+func buildBuiltPayloadAmsterdam(env *engine.ExecutionPayloadEnvelope, sf ssz.Fork) *sszt.BuiltPayloadAmsterdam {
out := &sszt.BuiltPayloadAmsterdam{
- Payload: sszt.ExecutionPayloadAmsterdamFromEngine(env.ExecutionPayload),
+ Payload: sszt.ExecutionPayloadFromEngine(env.ExecutionPayload, sf),
BlockValue: new(uint256.Int),
ExecutionRequests: env.Requests,
ShouldOverrideBuilder: env.Override,
diff --git a/eth/engineapi/media.go b/eth/engineapi/media.go
index 34df822a48..4b0063520b 100644
--- a/eth/engineapi/media.go
+++ b/eth/engineapi/media.go
@@ -34,9 +34,10 @@ const (
)
// 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 {
+// decodes the request body into obj for the given fork. 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, fork ssz.Fork, max int64) bool {
if !strings.EqualFold(r.Header.Get("Content-Type"), sszContentType) {
writeProblem(w, http.StatusUnsupportedMediaType, ErrUnsupportedMedia, "expected "+sszContentType)
return false
@@ -54,18 +55,18 @@ func readSSZRequest(w http.ResponseWriter, r *http.Request, obj ssz.Object, max
writeProblem(w, http.StatusRequestEntityTooLarge, ErrRequestTooLarge, "")
return false
}
- if err := ssz.DecodeFromBytes(body, obj); err != nil {
+ if err := ssz.DecodeFromBytesOnFork(body, obj, fork); 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 {
+// writeSSZResponse encodes obj into the response body for the given fork. On
+// encode failure it writes an internal-error problem and returns false.
+func writeSSZResponse(w http.ResponseWriter, obj ssz.Object, fork ssz.Fork) bool {
+ buf := make([]byte, ssz.SizeOnFork(obj, fork))
+ if err := ssz.EncodeToBytesOnFork(buf, obj, fork); err != nil {
writeProblem(w, http.StatusInternalServerError, ErrInternal, err.Error())
return false
}
diff --git a/eth/engineapi/payloads.go b/eth/engineapi/payloads.go
index 18858fe7fd..6e4e946fb7 100644
--- a/eth/engineapi/payloads.go
+++ b/eth/engineapi/payloads.go
@@ -27,15 +27,21 @@ import (
// 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, "")
+ // The execution-apis spec currently only defines the payload envelope for
+ // Amsterdam; the inner payload shape is fork-driven by the codec.
+ sf, ok := resolveFork(w, fork, forks.Amsterdam)
+ if !ok {
return
}
env := new(sszt.ExecutionPayloadEnvelopeAmsterdam)
- if !readSSZRequest(w, r, env, maxPayloadBytes) {
+ if !readSSZRequest(w, r, env, sf, maxPayloadBytes) {
return
}
- data := sszt.ExecutionPayloadAmsterdamToEngine(env.Payload)
+ if err := env.Payload.Validate(sf); err != nil {
+ writeProblem(w, http.StatusUnprocessableEntity, ErrInvalidBody, err.Error())
+ return
+ }
+ data := sszt.ExecutionPayloadToEngine(env.Payload)
// The spec drops expectedBlobVersionedHashes; we recompute them from the
// payload's transactions before passing to the EL.
@@ -50,7 +56,7 @@ func (rt *Router) handleNewPayload(w http.ResponseWriter, r *http.Request, fork
mapBackendErr(w, err)
return
}
- writeSSZResponse(w, sszt.PayloadStatusFromV1(&status))
+ writeSSZResponse(w, sszt.PayloadStatusFromV1(&status), sf)
}
// versionedHashesFromTxs extracts the blob versioned-hash list from the
diff --git a/eth/engineapi/router_test.go b/eth/engineapi/router_test.go
index e7cd2f8d67..1012628401 100644
--- a/eth/engineapi/router_test.go
+++ b/eth/engineapi/router_test.go
@@ -103,10 +103,10 @@ func newTestServer(t *testing.T, b Backend) *httptest.Server {
return httptest.NewServer(http.StripPrefix(BasePath, NewRouter(b)))
}
-func sszPost(t *testing.T, srv *httptest.Server, path string, body ssz.Object) *http.Response {
+func sszPost(t *testing.T, srv *httptest.Server, path string, body ssz.Object, fork ssz.Fork) *http.Response {
t.Helper()
- buf := make([]byte, ssz.Size(body))
- if err := ssz.EncodeToBytes(buf, body); err != nil {
+ buf := make([]byte, ssz.SizeOnFork(body, fork))
+ if err := ssz.EncodeToBytesOnFork(buf, body, fork); err != nil {
t.Fatalf("encode: %v", err)
}
req, _ := http.NewRequest(http.MethodPost, srv.URL+BasePath+path, bytes.NewReader(buf))
@@ -118,14 +118,14 @@ func sszPost(t *testing.T, srv *httptest.Server, path string, body ssz.Object) *
return resp
}
-func decodeSSZ[T ssz.Object](t *testing.T, resp *http.Response, obj T) {
+func decodeSSZ[T ssz.Object](t *testing.T, resp *http.Response, obj T, fork ssz.Fork) {
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 {
+ if err := ssz.DecodeFromBytesOnFork(body, obj, fork); err != nil {
t.Fatalf("decode response: %v\nbody: %x", err, body)
}
}
@@ -139,19 +139,24 @@ func TestRouterNewPayload(t *testing.T) {
srv := newTestServer(t, b)
defer srv.Close()
+ amsterdam, _ := sszt.ForkFor(forks.Amsterdam)
+ blob, excess, slot := uint64(0), uint64(0), uint64(0)
env := &sszt.ExecutionPayloadEnvelopeAmsterdam{
- Payload: &sszt.ExecutionPayloadAmsterdam{
+ Payload: &sszt.ExecutionPayload{
BaseFeePerGas: uint256.NewInt(7e9),
LogsBloom: [256]byte{},
+ BlobGasUsed: &blob,
+ ExcessBlobGas: &excess,
+ SlotNumber: &slot,
},
ParentBeaconBlockRoot: common.Hash{0x55},
}
- resp := sszPost(t, srv, "/amsterdam/payloads", env)
+ resp := sszPost(t, srv, "/amsterdam/payloads", env, amsterdam)
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
got := new(sszt.PayloadStatus)
- decodeSSZ(t, resp, got)
+ decodeSSZ(t, resp, got, amsterdam)
if got.Status != sszt.StatusValid {
t.Errorf("status=%d want VALID(%d)", got.Status, sszt.StatusValid)
}
@@ -169,15 +174,16 @@ func TestRouterForkchoice(t *testing.T) {
srv := newTestServer(t, b)
defer srv.Close()
+ amsterdam, _ := sszt.ForkFor(forks.Amsterdam)
fcu := &sszt.ForkchoiceUpdateAmsterdam{
ForkchoiceState: &sszt.ForkchoiceState{HeadBlockHash: common.Hash{0xaa}},
}
- resp := sszPost(t, srv, "/amsterdam/forkchoice", fcu)
+ resp := sszPost(t, srv, "/amsterdam/forkchoice", fcu, amsterdam)
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
got := new(sszt.ForkchoiceUpdateResponseAmsterdam)
- decodeSSZ(t, resp, got)
+ decodeSSZ(t, resp, got, amsterdam)
if got.PayloadStatus == nil || got.PayloadStatus.Status != sszt.StatusValid {
t.Errorf("status: %#v", got.PayloadStatus)
}
@@ -310,7 +316,7 @@ func TestRouterBlobsV2AllOrNothing(t *testing.T) {
srv := newTestServer(t, b)
defer srv.Close()
req := &sszt.BlobsVersionedHashesRequest{VersionedHashes: []common.Hash{{0x01}}}
- resp := sszPost(t, srv, "/blobs/v2", req)
+ resp := sszPost(t, srv, "/blobs/v2", req, ssz.ForkUnknown)
defer resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("want 204, got %d", resp.StatusCode)
@@ -322,12 +328,12 @@ func TestRouterBlobsV3PartialOK(t *testing.T) {
srv := newTestServer(t, b)
defer srv.Close()
req := &sszt.BlobsVersionedHashesRequest{VersionedHashes: []common.Hash{{0x01}, {0x02}}}
- resp := sszPost(t, srv, "/blobs/v3", req)
+ resp := sszPost(t, srv, "/blobs/v3", req, ssz.ForkUnknown)
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
got := new(sszt.BlobsV2Response)
- decodeSSZ(t, resp, got)
+ decodeSSZ(t, resp, got, ssz.ForkUnknown)
if len(got.Entries) != 2 {
t.Fatalf("entries=%d", len(got.Entries))
}