diff --git a/execution/engineapi/engine_api_methods.go b/execution/engineapi/engine_api_methods.go index 5f28b66c2d6..2892953a055 100644 --- a/execution/engineapi/engine_api_methods.go +++ b/execution/engineapi/engine_api_methods.go @@ -64,26 +64,6 @@ var ourCapabilities = []string{ "engine_getBlobsV1", "engine_getBlobsV2", "engine_getBlobsV3", - "POST /engine/v1/payloads", - "POST /engine/v2/payloads", - "POST /engine/v3/payloads", - "POST /engine/v4/payloads", - "POST /engine/v5/payloads", - "GET /engine/v1/payloads/{payload_id}", - "GET /engine/v2/payloads/{payload_id}", - "GET /engine/v3/payloads/{payload_id}", - "GET /engine/v4/payloads/{payload_id}", - "GET /engine/v5/payloads/{payload_id}", - "GET /engine/v6/payloads/{payload_id}", - "POST /engine/v1/forkchoice", - "POST /engine/v2/forkchoice", - "POST /engine/v3/forkchoice", - "POST /engine/v4/forkchoice", - "POST /engine/v1/blobs", - "POST /engine/v2/blobs", - "POST /engine/v3/blobs", - "POST /engine/v1/client/version", - "POST /engine/v1/capabilities", } // Returns the most recent version of the payload(for the payloadID) at the time of receiving the call diff --git a/execution/engineapi/engine_types/ssz.go b/execution/engineapi/engine_types/ssz.go index 18cec863a2b..1d694eda522 100644 --- a/execution/engineapi/engine_types/ssz.go +++ b/execution/engineapi/engine_types/ssz.go @@ -4,9 +4,8 @@ package engine_types import ( - "encoding/hex" + "encoding/binary" "fmt" - "strings" "github.com/holiman/uint256" @@ -17,18 +16,18 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/clonable" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/ssz" "github.com/erigontech/erigon/execution/types" ) const ( - sszMaxBlobHashes = 4096 - sszMaxGetBlobHashes = 128 - sszMaxBytesPerTransaction = 0x40000000 - sszMaxValidationError = 1024 - sszBlobBytes = 0x20000 - sszKZGBytes = 48 - sszCellsPerExtBlob = 128 - sszMaxCellProofs = 33554432 + sszMaxBlobCommitmentsPerBlock = 4096 + sszMaxBytesPerTransaction = 0x40000000 + sszMaxValidationError = 1024 + sszBlobBytes = 0x20000 + sszKZGBytes = 48 + sszCellsPerExtBlob = 128 + sszMaxCellProofs = sszMaxBlobCommitmentsPerBlock * sszCellsPerExtBlob ) var mainnetBeaconCfg = &clparams.MainnetBeaconConfig @@ -196,6 +195,53 @@ func withdrawalsFromList(l *solid.ListSSZ[*cltypes.Withdrawal]) []*types.Withdra return out } +// optionalSSZString is Optional[String] = List[List[byte, MAX_ERROR_BYTES], 1]. +type optionalSSZString struct { + value *string +} + +func (o *optionalSSZString) Static() bool { return false } + +func (o *optionalSSZString) EncodeSSZ(dst []byte) ([]byte, error) { + if o.value == nil { + return dst, nil + } + msg := []byte(*o.value) + if len(msg) > sszMaxValidationError { + msg = msg[:sszMaxValidationError] + } + dst = append(dst, 4, 0, 0, 0) + return append(dst, msg...), nil +} + +func (o *optionalSSZString) DecodeSSZ(buf []byte, _ int) error { + o.value = nil + if len(buf) == 0 { + return nil + } + if len(buf) < 4 { + return ssz.ErrLowBufferSize + } + if binary.LittleEndian.Uint32(buf) != 4 { + return ssz.ErrBadOffset + } + if len(buf)-4 > sszMaxValidationError { + return ssz.ErrTooBigList + } + msg := string(buf[4:]) + o.value = &msg + return nil +} + +func (o *optionalSSZString) EncodingSizeSSZ() int { + if o.value == nil { + return 0 + } + return 4 + min(len(*o.value), sszMaxValidationError) +} + +func (*optionalSSZString) Clone() clonable.Clonable { return &optionalSSZString{} } + func (s *PayloadStatus) Static() bool { return false } func (s *PayloadStatus) EncodeSSZ(dst []byte) ([]byte, error) { @@ -203,30 +249,23 @@ func (s *PayloadStatus) EncodeSSZ(dst []byte) ([]byte, error) { if err != nil { return nil, err } - var latest []common.Hash - if s.LatestValidHash != nil { - latest = []common.Hash{*s.LatestValidHash} - } latestHashes := solid.NewHashList(1) - for _, hash := range latest { - latestHashes.Append(hash) + if s.LatestValidHash != nil { + latestHashes.Append(*s.LatestValidHash) } - errBytes := solid.NewByteListSSZ(sszMaxValidationError) + validationErr := &optionalSSZString{} if s.ValidationError != nil && s.ValidationError.Error() != nil { - msg := []byte(s.ValidationError.Error().Error()) - if len(msg) > sszMaxValidationError { - msg = msg[:sszMaxValidationError] - } - _ = errBytes.SetBytes(msg) + msg := s.ValidationError.Error().Error() + validationErr.value = &msg } - return ssz2.MarshalSSZ(dst, []byte{status}, latestHashes, errBytes) + return ssz2.MarshalSSZ(dst, []byte{status}, latestHashes, validationErr) } func (s *PayloadStatus) DecodeSSZ(buf []byte, version int) error { latest := solid.NewHashList(1) - errBytes := solid.NewByteListSSZ(sszMaxValidationError) + validationErr := &optionalSSZString{} status := []byte{0} - if err := ssz2.UnmarshalSSZ(buf, version, status, latest, errBytes); err != nil { + if err := ssz2.UnmarshalSSZ(buf, version, status, latest, validationErr); err != nil { return err } engineStatus, err := payloadStatusFromByte(status[0]) @@ -234,12 +273,14 @@ func (s *PayloadStatus) DecodeSSZ(buf []byte, version int) error { return err } s.Status = engineStatus + s.LatestValidHash = nil if latest.Length() > 0 { hash := latest.Get(0) s.LatestValidHash = &hash } - if msg := string(errBytes.Bytes()); msg != "" { - s.ValidationError = NewStringifiedErrorFromString(msg) + s.ValidationError = nil + if validationErr.value != nil { + s.ValidationError = NewStringifiedErrorFromString(*validationErr.value) } return nil } @@ -315,7 +356,7 @@ func (a *PayloadAttributes) EncodeSSZ(dst []byte) ([]byte, error) { return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:]) case clparams.CapellaVersion: return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals) - case clparams.DenebVersion: + case clparams.DenebVersion, clparams.ElectraVersion, clparams.FuluVersion: return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:]) default: // GloasVersion+ return ssz2.MarshalSSZ(dst, uint64(a.Timestamp), a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:], slot, targetGasLimit) @@ -338,7 +379,7 @@ func (a *PayloadAttributes) DecodeSSZ(buf []byte, version int) error { return err } a.Withdrawals = withdrawalsFromList(withdrawals) - case clparams.DenebVersion: + case clparams.DenebVersion, clparams.ElectraVersion, clparams.FuluVersion: if err := ssz2.UnmarshalSSZ(buf, version, ×tamp, a.PrevRandao[:], a.SuggestedFeeRecipient[:], withdrawals, root[:]); err != nil { return err } @@ -373,44 +414,6 @@ func (a *PayloadAttributes) payloadAttributesSSZVersion() clparams.StateVersion return clparams.BellatrixVersion } -func (v *ClientVersionV1) Static() bool { return false } - -func (v *ClientVersionV1) EncodeSSZ(dst []byte) ([]byte, error) { - code := solid.NewByteListSSZ(2) - name := solid.NewByteListSSZ(64) - version := solid.NewByteListSSZ(64) - _ = code.SetBytes([]byte(v.Code)) - _ = name.SetBytes([]byte(v.Name)) - _ = version.SetBytes([]byte(v.Version)) - var commit [4]byte - ch := strings.TrimPrefix(v.Commit, "0x") - if b, err := hex.DecodeString(ch); err == nil && len(b) >= 4 { - copy(commit[:], b[:4]) - } - return ssz2.MarshalSSZ(dst, code, name, version, commit[:]) -} - -func (v *ClientVersionV1) DecodeSSZ(buf []byte, version int) error { - code := solid.NewByteListSSZ(2) - name := solid.NewByteListSSZ(64) - ver := solid.NewByteListSSZ(64) - var commit [4]byte - if err := ssz2.UnmarshalSSZ(buf, version, code, name, ver, commit[:]); err != nil { - return err - } - v.Code = string(code.Bytes()) - v.Name = string(name.Bytes()) - v.Version = string(ver.Bytes()) - v.Commit = "0x" + hex.EncodeToString(commit[:]) - return nil -} - -func (v *ClientVersionV1) EncodingSizeSSZ() int { out, _ := v.EncodeSSZ(nil); return len(out) } -func (v *ClientVersionV1) HashSSZ() ([32]byte, error) { - return [32]byte{}, nil -} -func (*ClientVersionV1) Clone() clonable.Clonable { return &ClientVersionV1{} } - func newKZGCommitment(in []byte) *cltypes.KZGCommitment { var out cltypes.KZGCommitment copy(out[:], in) @@ -436,13 +439,13 @@ func NewBlobsBundleSSZ(version clparams.StateVersion) *BlobsBundle { func (b *BlobsBundle) Static() bool { return false } func (b *BlobsBundle) EncodeSSZ(dst []byte) ([]byte, error) { - proofsLimit := sszMaxBlobHashes + proofsLimit := sszMaxBlobCommitmentsPerBlock if b.blobsBundleSSZVersion() >= clparams.FuluVersion { proofsLimit = sszMaxCellProofs } - commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobHashes, sszKZGBytes) + commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobCommitmentsPerBlock, sszKZGBytes) proofs := solid.NewStaticListSSZ[*cltypes.KZGProof](proofsLimit, sszKZGBytes) - blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobHashes, sszBlobBytes) + blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobCommitmentsPerBlock, sszBlobBytes) for _, commitment := range b.Commitments { commitments.Append(newKZGCommitment(commitment)) } @@ -457,13 +460,13 @@ func (b *BlobsBundle) EncodeSSZ(dst []byte) ([]byte, error) { func (b *BlobsBundle) DecodeSSZ(buf []byte, version int) error { b.SSZVersion = clparams.StateVersion(version) - proofsLimit := sszMaxBlobHashes + proofsLimit := sszMaxBlobCommitmentsPerBlock if b.SSZVersion >= clparams.FuluVersion { proofsLimit = sszMaxCellProofs } - commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobHashes, sszKZGBytes) + commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](sszMaxBlobCommitmentsPerBlock, sszKZGBytes) proofs := solid.NewStaticListSSZ[*cltypes.KZGProof](proofsLimit, sszKZGBytes) - blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobHashes, sszBlobBytes) + blobs := solid.NewStaticListSSZ[*cltypes.Blob](sszMaxBlobCommitmentsPerBlock, sszBlobBytes) if err := ssz2.UnmarshalSSZ(buf, version, commitments, proofs, blobs); err != nil { return err } @@ -554,41 +557,3 @@ func (b *BlobAndProofV2) DecodeSSZ(buf []byte, version int) error { func (b *BlobAndProofV2) EncodingSizeSSZ() int { out, _ := b.EncodeSSZ(nil); return len(out) } func (b *BlobAndProofV2) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } func (*BlobAndProofV2) Clone() clonable.Clonable { return &BlobAndProofV2{} } - -type NullableBlobAndProofV2 struct { - BlobAndProof *BlobAndProofV2 -} - -func NewNullableBlobAndProofV2(blob *BlobAndProofV2) *NullableBlobAndProofV2 { - return &NullableBlobAndProofV2{BlobAndProof: blob} -} - -func (n *NullableBlobAndProofV2) Static() bool { return false } - -func (n *NullableBlobAndProofV2) EncodeSSZ(dst []byte) ([]byte, error) { - blobAndProof := solid.NewDynamicListSSZ[*BlobAndProofV2](1) - if n != nil && n.BlobAndProof != nil { - blobAndProof.Append(n.BlobAndProof) - } - return ssz2.MarshalSSZ(dst, blobAndProof) -} - -func (n *NullableBlobAndProofV2) DecodeSSZ(buf []byte, version int) error { - blobAndProof := solid.NewDynamicListSSZ[*BlobAndProofV2](1) - if err := ssz2.UnmarshalSSZ(buf, version, blobAndProof); err != nil { - return err - } - n.BlobAndProof = nil - if blobAndProof.Len() != 0 { - n.BlobAndProof = blobAndProof.Get(0) - } - return nil -} - -func (n *NullableBlobAndProofV2) EncodingSizeSSZ() int { out, _ := n.EncodeSSZ(nil); return len(out) } -func (n *NullableBlobAndProofV2) HashSSZ() ([32]byte, error) { - return [32]byte{}, nil -} -func (*NullableBlobAndProofV2) Clone() clonable.Clonable { - return &NullableBlobAndProofV2{} -} diff --git a/execution/engineapi/sszrest_bytecompat_test.go b/execution/engineapi/sszrest_bytecompat_test.go new file mode 100644 index 00000000000..221217dab0a --- /dev/null +++ b/execution/engineapi/sszrest_bytecompat_test.go @@ -0,0 +1,180 @@ +// Copyright 2026 The go-ethereum Authors +// (golden test vectors) +// Copyright 2026 The Erigon Authors +// (test harness) +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package engineapi + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/types" +) + +// Golden SSZ vectors lifted from go-ethereum's REST-SSZ engine API +// (beacon/engine/ssz/bytecompat_test.go): encoding the same sample data on the +// same fork must reproduce these bytes — cross-client wire parity. +const ( + goldenPayloadParis = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f1536500000000fc01000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000ff010000657874080000000a000000020304" + goldenPayloadShanghai = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000000002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000030200000e020000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenPayloadCancun = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001002000000863ba1010000000000000000000000000000000000000000000000000000006600000000000000000000000000000000000000000000000000000000000000130200001e02000000000200000000000000000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenPayloadAmsterdam = "1100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000033000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005500000000000000000000000000000000000000000000000000000000000000640000000000000080c3c90100000000085200000000000000f15365000000001c02000000863ba10100000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000001f0200002a0200000000020000000000000000000000000056020000c800000000000000657874080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" + + goldenAttrsParis = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000" + goldenAttrsShanghai = "00f153650000000055000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000400000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenAttrsCancun = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000006000000077000000000000000000000000000000000000000000000000000000000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenAttrsAmsterdam = "00f1536500000000550000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000007000000077000000000000000000000000000000000000000000000000000000000000002a0000000000000080c3c901000000000100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + + goldenBodyParis = "04000000080000000a000000020304" + goldenBodyCancun = "0800000013000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000" + goldenBodyAmsterdam = "0c0000001700000043000000080000000a0000000203040100000000000000020000000000000009000000000000000000000000000000000000000300000000000000deadbeef" +) + +func goldenSampleWithdrawals() []*types.Withdrawal { + return []*types.Withdrawal{{Index: 1, Validator: 2, Address: common.Address{0x09}, Amount: 3}} +} + +func goldenSamplePayload(version clparams.StateVersion) *engine_types.ExecutionPayload { + p := engine_types.NewExecutionPayloadSSZ(version) + p.ParentHash = common.Hash{0x11} + p.FeeRecipient = common.Address{0x22} + p.StateRoot = common.Hash{0x33} + p.ReceiptsRoot = common.Hash{0x44} + p.LogsBloom = make(hexutil.Bytes, 256) + p.PrevRandao = common.Hash{0x55} + p.BlockNumber = 100 + p.GasLimit = 30000000 + p.GasUsed = 21000 + p.Timestamp = 1700000000 + p.ExtraData = hexutil.Bytes("ext") + p.BaseFeePerGas = (*hexutil.Big)(big.NewInt(7_000_000_000)) + p.BlockHash = common.Hash{0x66} + p.Transactions = []hexutil.Bytes{{0x02, 0x03}, {0x04}} + if version >= clparams.CapellaVersion { + p.Withdrawals = goldenSampleWithdrawals() + } + if version >= clparams.DenebVersion { + blobGasUsed, excessBlobGas := hexutil.Uint64(131072), hexutil.Uint64(0) + p.BlobGasUsed, p.ExcessBlobGas = &blobGasUsed, &excessBlobGas + } + if version >= clparams.GloasVersion { + slot := hexutil.Uint64(200) + bal := hexutil.Bytes{0xde, 0xad, 0xbe, 0xef} + p.SlotNumber, p.BlockAccessList = &slot, &bal + } + return p +} + +func goldenSampleAttrs(version clparams.StateVersion) *engine_types.PayloadAttributes { + a := &engine_types.PayloadAttributes{ + Timestamp: 1700000000, + PrevRandao: common.Hash{0x55}, + SuggestedFeeRecipient: common.Address{0x22}, + SSZVersion: version, + } + if version >= clparams.CapellaVersion { + a.Withdrawals = goldenSampleWithdrawals() + } + if version >= clparams.DenebVersion { + root := common.Hash{0x77} + a.ParentBeaconBlockRoot = &root + } + if version >= clparams.GloasVersion { + slot, targetGasLimit := hexutil.Uint64(42), hexutil.Uint64(30000000) + a.SlotNumber, a.TargetGasLimit = &slot, &targetGasLimit + } + return a +} + +func goldenSampleBody(t *testing.T, version clparams.StateVersion) *sszPayloadBody { + t.Helper() + b := newSSZPayloadBody(version) + b.transactions = newTransactionsSSZ([]hexutil.Bytes{{0x02, 0x03}, {0x04}}) + if version >= clparams.CapellaVersion { + b.withdrawals = newWithdrawalListSSZ(goldenSampleWithdrawals()) + } + if version >= clparams.GloasVersion { + require.NoError(t, b.blockAccessList.SetBytes([]byte{0xde, 0xad, 0xbe, 0xef})) + } + return b +} + +func TestSSZRESTByteCompatWithGolden(t *testing.T) { + t.Run("payload", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, goldenPayloadParis}, + {"shanghai", clparams.CapellaVersion, goldenPayloadShanghai}, + {"cancun", clparams.DenebVersion, goldenPayloadCancun}, + {"amsterdam", clparams.GloasVersion, goldenPayloadAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := goldenSamplePayload(tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) + + t.Run("attributes", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, goldenAttrsParis}, + {"shanghai", clparams.CapellaVersion, goldenAttrsShanghai}, + {"cancun", clparams.DenebVersion, goldenAttrsCancun}, + {"amsterdam", clparams.GloasVersion, goldenAttrsAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := goldenSampleAttrs(tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) + + t.Run("body", func(t *testing.T) { + for _, tc := range []struct { + name string + version clparams.StateVersion + want string + }{ + {"paris", clparams.BellatrixVersion, goldenBodyParis}, + {"cancun", clparams.DenebVersion, goldenBodyCancun}, + {"amsterdam", clparams.GloasVersion, goldenBodyAmsterdam}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := goldenSampleBody(t, tc.version).EncodeSSZ(nil) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(enc)) + }) + } + }) +} diff --git a/execution/engineapi/sszrest_handler.go b/execution/engineapi/sszrest_handler.go index 7b6ce3792a4..7e9001ee3fa 100644 --- a/execution/engineapi/sszrest_handler.go +++ b/execution/engineapi/sszrest_handler.go @@ -1,27 +1,51 @@ // Copyright 2026 The Erigon Authors // This file is part of Erigon. +// HTTP routing for the Engine API v2 REST transport +// (https://github.com/ethereum/execution-apis/pull/793). + package engineapi import ( - "context" "encoding/hex" + "encoding/json" "errors" + "fmt" "io" + "mime" "net/http" "strconv" "strings" "github.com/erigontech/erigon/cl/clparams" - "github.com/erigontech/erigon/cl/cltypes/solid" - "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/txnprovider/txpool" +) + +const ( + sszRestContentType = "application/octet-stream" + jsonContentType = "application/json" + problemContentType = "application/problem+json" ) -const sszRestContentType = "application/octet-stream" +// RFC 7807 problem types, written as relative URIs per the spec. +const ( + problemInvalidRequest = "/engine-api/errors/invalid-request" + problemSSZDecodeError = "/engine-api/errors/ssz-decode-error" + problemUnsupportedFork = "/engine-api/errors/unsupported-fork" + problemMethodNotFound = "/engine-api/errors/method-not-found" + problemUnknownPayload = "/engine-api/errors/unknown-payload" + problemInvalidForkchoice = "/engine-api/errors/invalid-forkchoice" + problemReorgTooDeep = "/engine-api/errors/reorg-too-deep" + problemRequestTooLarge = "/engine-api/errors/request-too-large" + problemUnsupportedMediaType = "/engine-api/errors/unsupported-media-type" + problemInvalidBody = "/engine-api/errors/invalid-body" + problemInvalidAttributes = "/engine-api/errors/invalid-attributes" + problemInternal = "/engine-api/errors/internal" +) func (e *EngineServer) SSZRESTHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -30,90 +54,125 @@ func (e *EngineServer) SSZRESTHandler() http.Handler { } func (e *EngineServer) handleSSZREST(w http.ResponseWriter, r *http.Request) { - path := strings.Trim(r.URL.Path, "/") - parts := strings.Split(path, "/") - if len(parts) < 3 || parts[0] != "engine" || !strings.HasPrefix(parts[1], "v") { - http.NotFound(w, nil) + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + // an empty last segment (e.g. a trailing slash) matches no route + if len(parts) < 3 || parts[0] != "engine" || parts[1] != "v2" || parts[len(parts)-1] == "" { + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") return } - version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v")) - if err != nil { - writeSSZError(w, http.StatusNotFound, "invalid engine version") + rest := parts[2:] + switch { + case rest[0] == "capabilities": + if len(rest) == 1 && r.Method == http.MethodGet { + e.handleSSZCapabilities(w) + return + } + case rest[0] == "identity": + if len(rest) == 1 && r.Method == http.MethodGet { + e.handleSSZIdentity(w, r) + return + } + case rest[0] == "blobs": + if len(rest) == 2 && r.Method == http.MethodPost { + e.handleSSZGetBlobs(w, r, rest[1]) + return + } + default: + e.handleSSZForkScoped(w, r, rest) return } + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") +} +func (e *EngineServer) handleSSZForkScoped(w http.ResponseWriter, r *http.Request, rest []string) { + forkName := rest[0] + version, ok := engineForkVersion(forkName) + if !ok || !forkScheduled(e.config, forkName) { + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, fmt.Sprintf("unsupported fork %q", forkName)) + return + } + sub := rest[1:] switch { - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "payloads": - e.handleSSZNewPayload(w, r, version) - case r.Method == http.MethodGet && len(parts) == 4 && parts[2] == "payloads": - e.handleSSZGetPayload(w, r, version, parts[3]) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "forkchoice": - e.handleSSZForkchoice(w, r, version) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "blobs": - e.handleSSZGetBlobs(w, r, version) - case r.Method == http.MethodPost && len(parts) == 4 && parts[2] == "client" && parts[3] == "version" && version == 1: - e.handleSSZClientVersion(w, r) - case r.Method == http.MethodPost && len(parts) == 3 && parts[2] == "capabilities" && version == 1: - e.handleSSZCapabilities(w, r) + case len(sub) == 1 && sub[0] == "payloads" && r.Method == http.MethodPost: + e.handleSSZNewPayload(w, r, forkName, version) + case len(sub) == 2 && sub[0] == "payloads" && r.Method == http.MethodGet: + e.handleSSZGetPayload(w, r, forkName, version, sub[1]) + case len(sub) == 1 && sub[0] == "forkchoice" && r.Method == http.MethodPost: + e.handleSSZForkchoice(w, r, forkName, version) + case len(sub) == 2 && sub[0] == "bodies" && sub[1] == "hash" && r.Method == http.MethodPost: + e.handleSSZBodiesByHash(w, r, forkName, version) + case len(sub) == 1 && sub[0] == "bodies" && r.Method == http.MethodGet: + e.handleSSZBodiesByRange(w, r, forkName, version) default: - writeSSZError(w, http.StatusNotFound, "unknown SSZ-REST Engine API endpoint") + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") } } -func sszNewPayloadVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.ElectraVersion, true - case 5: - return clparams.GloasVersion, true - default: - return 0, false - } +func writeProblem(w http.ResponseWriter, status int, problemType, detail string) { + w.Header().Set("Content-Type", problemContentType) + w.WriteHeader(status) + problem := struct { + Type string `json:"type"` + Detail string `json:"detail,omitempty"` + }{Type: problemType, Detail: detail} + _ = json.NewEncoder(w).Encode(problem) } -func sszGetPayloadVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.ElectraVersion, true - case 5: - return clparams.FuluVersion, true - case 6: - return clparams.GloasVersion, true - default: - return 0, false +func writeEngineProblem(w http.ResponseWriter, err error) { + if err == nil { + return } + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + switch rpcErr.ErrorCode() { + case engine_helpers.UnknownPayloadErr.Code: + writeProblem(w, http.StatusNotFound, problemUnknownPayload, err.Error()) + return + case engine_helpers.InvalidForkchoiceStateErr.Code: + writeProblem(w, http.StatusConflict, problemInvalidForkchoice, err.Error()) + return + case engine_helpers.InvalidPayloadAttributesErr.Code: + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidAttributes, err.Error()) + return + case engine_helpers.TooLargeRequestErr.Code: + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + return + case engine_helpers.ReorgTooDeepErr.Code: + writeProblem(w, http.StatusConflict, problemReorgTooDeep, err.Error()) + return + case (&rpc.UnsupportedForkError{}).ErrorCode(): + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, err.Error()) + return + case (&rpc.InvalidParamsError{}).ErrorCode(): + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidBody, err.Error()) + return + } + } + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) } -func sszForkchoiceVersion(version int) (clparams.StateVersion, bool) { - switch version { - case 1: - return clparams.BellatrixVersion, true - case 2: - return clparams.CapellaVersion, true - case 3: - return clparams.DenebVersion, true - case 4: - return clparams.GloasVersion, true - default: - return 0, false +func checkSSZContentType(w http.ResponseWriter, r *http.Request) bool { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != sszRestContentType { + writeProblem(w, http.StatusUnsupportedMediaType, problemUnsupportedMediaType, "want "+sszRestContentType) + return false } + return true } -func readSSZBody(r *http.Request) ([]byte, error) { +func readSSZBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { defer r.Body.Close() - return io.ReadAll(http.MaxBytesReader(nil, r.Body, 128<<20)) + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, sszMaxRequestBody)) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, err.Error()) + } else { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) + } + return nil, false + } + return body, true } func writeSSZ(w http.ResponseWriter, obj interface { @@ -121,11 +180,10 @@ func writeSSZ(w http.ResponseWriter, obj interface { }) { out, err := obj.EncodeSSZ(nil) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - w.Header().Set("Content-Type", sszRestContentType) - _, _ = w.Write(out) + writeSSZBytes(w, out) } func writeSSZBytes(w http.ResponseWriter, out []byte) { @@ -133,123 +191,69 @@ func writeSSZBytes(w http.ResponseWriter, out []byte) { _, _ = w.Write(out) } -func writeSSZError(w http.ResponseWriter, code int, msg string) { - http.Error(w, msg, code) -} - -func writeEngineError(w http.ResponseWriter, err error) { - if err == nil { - return - } - var rpcErr rpc.Error - if errors.As(err, &rpcErr) { - switch rpcErr.ErrorCode() { - case engine_helpers.UnknownPayloadErr.Code: - writeSSZError(w, http.StatusNotFound, err.Error()) - return - case engine_helpers.InvalidForkchoiceStateErr.Code: - writeSSZError(w, http.StatusConflict, err.Error()) - return - case engine_helpers.InvalidPayloadAttributesErr.Code: - writeSSZError(w, http.StatusUnprocessableEntity, err.Error()) - return - case engine_helpers.TooLargeRequestErr.Code: - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) - return - } - } - writeSSZError(w, http.StatusInternalServerError, err.Error()) +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", jsonContentType) + _ = json.NewEncoder(w).Encode(v) } -func (e *EngineServer) handleSSZNewPayload(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 5 { - writeSSZError(w, http.StatusNotFound, "unsupported new payload version") +func (e *EngineServer) handleSSZNewPayload(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - sv, _ := sszNewPayloadVersion(version) - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - payload, blobHashes, parentRoot, executionRequests, err := decodeNewPayloadRequest(body, sv) + payload, parentRoot, executionRequests, err := decodeNewPayloadEnvelope(body, version) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } var status *engine_types.PayloadStatus switch version { - case 1: + case clparams.BellatrixVersion: status, err = e.NewPayloadV1(r.Context(), payload) - case 2: + case clparams.CapellaVersion: status, err = e.NewPayloadV2(r.Context(), payload) - case 3: - status, err = e.NewPayloadV3(r.Context(), payload, hashListValues(blobHashes), &parentRoot) - case 4: - status, err = e.NewPayloadV4(r.Context(), payload, hashListValues(blobHashes), &parentRoot, transactionsBytes(executionRequests)) - case 5: - status, err = e.NewPayloadV5(r.Context(), payload, hashListValues(blobHashes), &parentRoot, transactionsBytes(executionRequests)) + case clparams.DenebVersion: + status, err = e.NewPayloadV3(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot) + case clparams.ElectraVersion, clparams.FuluVersion: + status, err = e.NewPayloadV4(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot, executionRequests) + default: + status, err = e.NewPayloadV5(r.Context(), payload, blobVersionedHashesFromTxs(payload.Transactions), &parentRoot, executionRequests) } if err != nil { - writeEngineError(w, err) + writeEngineProblem(w, err) return } - e.logger.Info("[SSZ-REST] handled new payload", "path", r.URL.Path) + e.logger.Debug("[SSZ-REST] handled new payload", "fork", forkName) writeSSZ(w, status) } -func (e *EngineServer) handleSSZGetPayload(w http.ResponseWriter, r *http.Request, version int, payloadID string) { - if version < 1 || version > 6 { - writeSSZError(w, http.StatusNotFound, "unsupported get payload version") +func (e *EngineServer) handleSSZGetPayload(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion, payloadID string) { + id, err := parsePayloadIDPath(payloadID) + if err != nil { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) return } - id, err := parsePayloadIDPath(payloadID) + decodedID, err := decodePayloadID(id) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, err.Error()) return } - switch version { - case 1: - resp, err := e.GetPayloadV1(r.Context(), id) - if err != nil { - writeEngineError(w, err) - return - } - resp.SSZVersion = clparams.BellatrixVersion - e.logger.Info("[SSZ-REST] handled get payload", "path", r.URL.Path) - writeSSZ(w, resp) - default: - resp, err := callGetPayload(r.Context(), e, version, id) - if err != nil { - writeEngineError(w, err) - return - } - sv, _ := sszGetPayloadVersion(version) - out, err := encodeGetPayloadResponse(resp, sv) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return - } - e.logger.Info("[SSZ-REST] handled get payload", "path", r.URL.Path) - writeSSZBytes(w, out) + resp, err := e.getPayload(r.Context(), decodedID, version) + if err != nil { + writeEngineProblem(w, err) + return } -} - -func callGetPayload(ctx context.Context, e *EngineServer, version int, id hexutil.Bytes) (*engine_types.GetPayloadResponse, error) { - switch version { - case 2: - return e.GetPayloadV2(ctx, id) - case 3: - return e.GetPayloadV3(ctx, id) - case 4: - return e.GetPayloadV4(ctx, id) - case 5: - return e.GetPayloadV5(ctx, id) - case 6: - return e.GetPayloadV6(ctx, id) - default: - return nil, &rpc.UnsupportedForkError{Message: "unsupported get payload version"} + out, err := encodeBuiltPayload(resp, version) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return } + e.logger.Debug("[SSZ-REST] handled get payload", "fork", forkName, "payloadId", decodedID) + w.Header().Set("Cache-Control", "no-store") + writeSSZBytes(w, out) } func parsePayloadIDPath(s string) (hexutil.Bytes, error) { @@ -264,157 +268,239 @@ func parsePayloadIDPath(s string) (hexutil.Bytes, error) { return hexutil.Bytes(b), nil } -func (e *EngineServer) handleSSZForkchoice(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 4 { - writeSSZError(w, http.StatusNotFound, "unsupported forkchoice version") +func (e *EngineServer) handleSSZForkchoice(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - sv, _ := sszForkchoiceVersion(version) - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - state, attrs, err := decodeForkchoiceRequest(body, sv) + state, attrs, custodyColumns, err := decodeForkchoiceUpdate(body, version) if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - var resp *engine_types.ForkChoiceUpdatedResponse - switch version { - case 1: - resp, err = e.ForkchoiceUpdatedV1(r.Context(), &state, attrs) - case 2: - resp, err = e.ForkchoiceUpdatedV2(r.Context(), &state, attrs) - case 3: - resp, err = e.ForkchoiceUpdatedV3(r.Context(), &state, attrs) - case 4: - resp, err = e.ForkchoiceUpdatedV4(r.Context(), &state, attrs) + if custodyColumns != nil { + e.logger.Debug("[SSZ-REST] ignoring custody columns update", "fork", forkName) + } + if attrs != nil { + // the URL fork is load-bearing for payload builds only: it must match + // the fork the new payload would belong to + if attrsFork := forkNameAtTime(e.config, uint64(attrs.Timestamp)); attrsFork != forkName { + writeProblem(w, http.StatusBadRequest, problemUnsupportedFork, + fmt.Sprintf("payload attributes timestamp belongs to fork %q, not URL fork %q", attrsFork, forkName)) + return + } } + resp, err := e.forkchoiceUpdated(r.Context(), &state, attrs, version) if err != nil { - writeEngineError(w, err) + writeEngineProblem(w, err) return } out, err := encodeForkchoiceResponse(resp) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } - e.logger.Info("[SSZ-REST] handled forkchoice", "path", r.URL.Path) + e.logger.Debug("[SSZ-REST] handled forkchoice", "fork", forkName) writeSSZBytes(w, out) } -func (e *EngineServer) handleSSZGetBlobs(w http.ResponseWriter, r *http.Request, version int) { - if version < 1 || version > 3 { - writeSSZError(w, http.StatusNotFound, "unsupported get blobs version") +func (e *EngineServer) handleSSZBodiesByHash(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + if !checkSSZContentType(w, r) { return } - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) + body, ok := readSSZBody(w, r) + if !ok { return } - hashes := solid.NewHashList(sszMaxGetBlobHashes) - if err := hashes.DecodeSSZ(body, 0); err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + if len(body) > 4+sszMaxBodiesRequest*32 { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many block hashes") return } - if hashes.Length() > sszMaxGetBlobHashes { - writeSSZError(w, http.StatusRequestEntityTooLarge, "too many blob hashes") + blockHashes, err := decodeHashListRequest(body, sszMaxBodiesRequest) + if err != nil { + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } - switch version { - case 1: - resp, err := e.GetBlobsV1(r.Context(), hashListValues(hashes)) - if err != nil { - writeEngineError(w, err) - return - } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV1Response(resp) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return - } - writeSSZBytes(w, out) - return - case 2: - resp, err := e.GetBlobsV2(r.Context(), hashListValues(hashes)) - if err != nil { - writeEngineError(w, err) - return - } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV2Response(resp) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return - } - writeSSZBytes(w, out) + bodies, err := e.chainRW.GetPayloadBodiesByHash(r.Context(), blockHashes) + if err != nil { + writeEngineProblem(w, err) return - case 3: - resp, err := e.GetBlobsV3(r.Context(), hashListValues(hashes)) - if err != nil { - writeEngineError(w, err) - return + } + // blocks outside the URL fork's time range come back as available=false + entries := make([]*engine_types.ExecutionPayloadBodyV2, len(blockHashes)) + for i := range blockHashes { + if i >= len(bodies) || bodies[i] == nil { + continue } - e.logger.Info("[SSZ-REST] handled get blobs", "path", r.URL.Path) - out, err := encodeGetBlobsV3Response(resp) - if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) - return + header := e.chainRW.GetHeaderByHash(r.Context(), blockHashes[i]) + if header == nil || forkNameAtTime(e.config, header.Time) != forkName { + continue } - writeSSZBytes(w, out) + entries[i] = bodies[i] + } + out, err := encodeBodiesResponse(entries, version) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } + e.logger.Debug("[SSZ-REST] handled bodies by hash", "fork", forkName, "count", len(blockHashes)) + writeSSZBytes(w, out) } -func (e *EngineServer) handleSSZClientVersion(w http.ResponseWriter, r *http.Request) { - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) +func (e *EngineServer) handleSSZBodiesByRange(w http.ResponseWriter, r *http.Request, forkName string, version clparams.StateVersion) { + query := r.URL.Query() + from, errFrom := strconv.ParseUint(query.Get("from"), 10, 64) + count, errCount := strconv.ParseUint(query.Get("count"), 10, 64) + if errFrom != nil || errCount != nil { + writeProblem(w, http.StatusBadRequest, problemInvalidRequest, "from and count query parameters are required") return } - clientVersion, err := decodeClientVersionRequest(body) - if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + if count > sszMaxBodiesRequest { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, + fmt.Sprintf("count %d exceeds the limit of %d", count, sszMaxBodiesRequest)) + return + } + if from == 0 || count == 0 { + writeProblem(w, http.StatusUnprocessableEntity, problemInvalidBody, + fmt.Sprintf("invalid from or count, from: %d count: %d", from, count)) return } - resp, err := e.GetClientVersionV1(r.Context(), clientVersion) + bodies, err := e.chainRW.GetPayloadBodiesByRange(r.Context(), from, count) if err != nil { - writeEngineError(w, err) + writeEngineProblem(w, err) return } - e.logger.Info("[SSZ-REST] handled client version", "path", r.URL.Path) - out, err := encodeClientVersionResponse(resp) + // The response is truncated at the latest known block: GetPayloadBodiesByRange + // stops at head and trims trailing nils, so past-head blocks are omitted rather + // than padded with available=false. In-range blocks outside the URL fork's time + // range still come back as available=false. + entries := make([]*engine_types.ExecutionPayloadBodyV2, len(bodies)) + for i := range bodies { + if bodies[i] == nil { + continue + } + header := e.chainRW.GetHeaderByNumber(r.Context(), from+uint64(i)) + if header == nil || forkNameAtTime(e.config, header.Time) != forkName { + continue + } + entries[i] = bodies[i] + } + out, err := encodeBodiesResponse(entries, version) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) return } + e.logger.Debug("[SSZ-REST] handled bodies by range", "fork", forkName, "from", from, "count", count) writeSSZBytes(w, out) } -func ptr[T any](v T) *T { return &v } - -func (e *EngineServer) handleSSZCapabilities(w http.ResponseWriter, r *http.Request) { - body, err := readSSZBody(r) - if err != nil { - writeSSZError(w, http.StatusRequestEntityTooLarge, err.Error()) +func (e *EngineServer) handleSSZGetBlobs(w http.ResponseWriter, r *http.Request, revision string) { + if revision != "v1" && revision != "v2" && revision != "v3" { + writeProblem(w, http.StatusNotFound, problemMethodNotFound, "") return } - capabilities, err := decodeCapabilities(body, 0) - if err != nil { - writeSSZError(w, http.StatusBadRequest, err.Error()) + if !checkSSZContentType(w, r) { + return + } + body, ok := readSSZBody(w, r) + if !ok { return } - resp := e.ExchangeCapabilities(capabilities) - e.logger.Info("[SSZ-REST] handled capabilities", "path", r.URL.Path) - out, err := encodeCapabilities(resp) + if len(body) > 4+sszMaxGetBlobHashes*32 { + writeProblem(w, http.StatusRequestEntityTooLarge, problemRequestTooLarge, "too many blob hashes") + return + } + blobHashes, err := decodeHashListRequest(body, sszMaxGetBlobHashes) if err != nil { - writeSSZError(w, http.StatusInternalServerError, err.Error()) + writeProblem(w, http.StatusBadRequest, problemSSZDecodeError, "") return } + + var out []byte + switch revision { + case "v1": + resp, err := e.GetBlobsV1(r.Context(), blobHashes) + if err != nil { + writeBlobsProblem(w, err) + return + } + if resp == nil { + w.WriteHeader(http.StatusNoContent) + return + } + out, err = encodeBlobsV1Response(resp) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return + } + case "v2", "v3": + var resp engine_types.BlobsBundleV2 + var err error + if revision == "v2" { + resp, err = e.GetBlobsV2(r.Context(), blobHashes) + } else { + resp, err = e.GetBlobsV3(r.Context(), blobHashes) + } + if err != nil { + writeBlobsProblem(w, err) + return + } + // nil signals "cannot serve" (and the all-or-nothing miss on v2) + if resp == nil { + w.WriteHeader(http.StatusNoContent) + return + } + out, err = encodeBlobsV2Response(resp) + if err != nil { + writeProblem(w, http.StatusInternalServerError, problemInternal, err.Error()) + return + } + } + e.logger.Debug("[SSZ-REST] handled get blobs", "revision", revision, "count", len(blobHashes)) writeSSZBytes(w, out) } -func hashesToCommon(in []common.Hash) []common.Hash { return in } +func writeBlobsProblem(w http.ResponseWriter, err error) { + if errors.Is(err, txpool.ErrPoolDisabled) { + w.WriteHeader(http.StatusNoContent) + return + } + writeEngineProblem(w, err) +} + +type sszRestCapabilities 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"` +} + +func (e *EngineServer) handleSSZCapabilities(w http.ResponseWriter) { + writeJSON(w, sszRestCapabilities{ + SupportedForks: supportedForkNames(e.config), + ForkScopedEndpoints: []string{"payloads", "forkchoice", "bodies"}, + IndependentlyVersioned: map[string][]string{"blobs": {"v1", "v2", "v3"}}, + UnscopedEndpoints: []string{"capabilities", "identity"}, + Limits: map[string]uint64{ + "bodies.max_count": sszMaxBodiesRequest, + "blobs.max_versioned_hashes": sszMaxGetBlobHashes, + "payload.max_bytes": sszMaxRequestBody, + }, + }) +} + +func (e *EngineServer) handleSSZIdentity(w http.ResponseWriter, r *http.Request) { + if clientVersion := r.Header.Get("X-Engine-Client-Version"); clientVersion != "" { + e.logger.Debug("[SSZ-REST] client version", "version", clientVersion) + } + versions, err := e.GetClientVersionV1(r.Context(), nil) + if err != nil { + writeEngineProblem(w, err) + return + } + writeJSON(w, versions) +} diff --git a/execution/engineapi/sszrest_test.go b/execution/engineapi/sszrest_test.go index bcbdaceb6ce..c999b912448 100644 --- a/execution/engineapi/sszrest_test.go +++ b/execution/engineapi/sszrest_test.go @@ -5,32 +5,277 @@ package engineapi import ( "bytes" + "encoding/binary" + "encoding/json" "errors" "net/http" "net/http/httptest" - "strings" "testing" + "testing/iotest" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/erigontech/erigon/cl/clparams" - "github.com/erigontech/erigon/cl/cltypes/solid" - ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc" ) -func TestSSZRESTCapabilitiesCodecRoundTrip(t *testing.T) { - enc, err := encodeCapabilities([]string{"engine_newPayloadV1", "POST /engine/v1/payloads"}) +func allForksConfig() *chain.Config { + return &chain.Config{ + ShanghaiTime: common.NewUint64(100), + CancunTime: common.NewUint64(200), + PragueTime: common.NewUint64(300), + OsakaTime: common.NewUint64(400), + AmsterdamTime: common.NewUint64(500), + } +} + +func newTestEngineServer(cfg *chain.Config, consuming bool) *EngineServer { + return NewEngineServer(log.New(), cfg, nil, nil, false, true, false, consuming, nil, nil, 0, 0) +} + +func newSSZRequest(method, path string, body []byte) *http.Request { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", sszRestContentType) + return req +} + +func problemType(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + require.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + var problem struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &problem)) + return problem.Type +} + +func TestSSZRESTForkRegistry(t *testing.T) { + for name, want := range map[string]clparams.StateVersion{ + "paris": clparams.BellatrixVersion, + "shanghai": clparams.CapellaVersion, + "cancun": clparams.DenebVersion, + "prague": clparams.ElectraVersion, + "osaka": clparams.FuluVersion, + "amsterdam": clparams.GloasVersion, + } { + got, ok := engineForkVersion(name) + require.True(t, ok, name) + require.Equal(t, want, got, name) + } + _, ok := engineForkVersion("bellatrix") + require.False(t, ok) +} + +func TestSSZRESTForkNameAtTime(t *testing.T) { + cfg := allForksConfig() + for ts, want := range map[uint64]string{ + 0: "paris", + 99: "paris", + 100: "shanghai", + 250: "cancun", + 350: "prague", + 450: "osaka", + 500: "amsterdam", + 999: "amsterdam", + } { + require.Equal(t, want, forkNameAtTime(cfg, ts), "ts=%d", ts) + } +} + +func TestSSZRESTSupportedForkNames(t *testing.T) { + require.Equal(t, []string{"paris"}, supportedForkNames(&chain.Config{})) + require.Equal(t, + []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"}, + supportedForkNames(allForksConfig())) + require.Equal(t, + []string{"paris", "shanghai", "cancun"}, + supportedForkNames(&chain.Config{ShanghaiTime: common.NewUint64(0), CancunTime: common.NewUint64(0)})) +} + +func TestSSZRESTNewPayloadEnvelopeRoundTrip(t *testing.T) { + for _, version := range []clparams.StateVersion{ + clparams.BellatrixVersion, + clparams.CapellaVersion, + clparams.DenebVersion, + clparams.ElectraVersion, + clparams.FuluVersion, + clparams.GloasVersion, + } { + t.Run(version.String(), func(t *testing.T) { + payload := engine_types.NewExecutionPayloadSSZ(version) + if version >= clparams.GloasVersion { + slot := hexutil.Uint64(123) + payload.SlotNumber = &slot + bal := hexutil.Bytes{0x01, 0x02, 0x03} + payload.BlockAccessList = &bal + } + root := common.HexToHash("0xaa") + requests := []hexutil.Bytes{{0x00, 0x01}, {0x01, 0x02}} + + enc, err := encodeNewPayloadEnvelope(version, payload, root, requests) + require.NoError(t, err) + + outPayload, outRoot, outRequests, err := decodeNewPayloadEnvelope(enc, version) + require.NoError(t, err) + + payloadBytes, err := payload.EncodeSSZ(nil) + require.NoError(t, err) + switch { + case version < clparams.DenebVersion: + // {payload} — just an offset plus the payload bytes + require.Len(t, enc, 4+len(payloadBytes)) + require.Equal(t, common.Hash{}, outRoot) + require.Empty(t, outRequests) + case version < clparams.ElectraVersion: + // {payload, parent_beacon_block_root} + require.Len(t, enc, 4+32+len(payloadBytes)) + require.Equal(t, root, outRoot) + require.Empty(t, outRequests) + default: + // {payload, parent_beacon_block_root, execution_requests} + require.Equal(t, root, outRoot) + require.Equal(t, requests, outRequests) + } + if version >= clparams.GloasVersion { + require.NotNil(t, outPayload.SlotNumber) + require.Equal(t, hexutil.Uint64(123), *outPayload.SlotNumber) + require.NotNil(t, outPayload.BlockAccessList) + require.Equal(t, hexutil.Bytes{0x01, 0x02, 0x03}, *outPayload.BlockAccessList) + } + require.Equal(t, payload.BlockHash, outPayload.BlockHash) + }) + } +} + +func TestSSZRESTBlobVersionedHashesFromTxs(t *testing.T) { + hashes := blobVersionedHashesFromTxs(nil) + require.NotNil(t, hashes) + require.Empty(t, hashes) + + // undecodable transactions yield an empty (non-nil) list and no panic; + // newPayload re-decodes and reports INVALID itself + hashes = blobVersionedHashesFromTxs([]hexutil.Bytes{{0xff, 0xfe}}) + require.NotNil(t, hashes) + require.Empty(t, hashes) +} + +func TestSSZRESTForkchoiceUpdateRoundTrip(t *testing.T) { + state := engine_types.ForkChoiceState{ + HeadHash: common.HexToHash("0x01"), + SafeBlockHash: common.HexToHash("0x02"), + FinalizedBlockHash: common.HexToHash("0x03"), + } + + t.Run("paris no attributes", func(t *testing.T) { + enc, err := encodeForkchoiceUpdate(clparams.BellatrixVersion, &state, nil, nil) + require.NoError(t, err) + outState, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.BellatrixVersion) + require.NoError(t, err) + require.Equal(t, state, outState) + require.Nil(t, outAttrs) + require.Nil(t, outCustody) + }) + + t.Run("prague attributes use the cancun schema", func(t *testing.T) { + root := common.HexToHash("0x04") + attrs := &engine_types.PayloadAttributes{ + Timestamp: 350, + PrevRandao: common.HexToHash("0x05"), + SuggestedFeeRecipient: common.HexToAddress("0x06"), + Withdrawals: []*types.Withdrawal{{Index: 1, Validator: 2, Address: common.HexToAddress("0x07"), Amount: 3}}, + ParentBeaconBlockRoot: &root, + } + attrs.SSZVersion = clparams.ElectraVersion + // timestamp(8) + prev_randao(32) + fee_recipient(20) + withdrawals offset(4) + root(32) + one withdrawal(44) + attrBytes, err := attrs.EncodeSSZ(nil) + require.NoError(t, err) + require.Len(t, attrBytes, 140) + + enc, err := encodeForkchoiceUpdate(clparams.ElectraVersion, &state, attrs, nil) + require.NoError(t, err) + _, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.ElectraVersion) + require.NoError(t, err) + require.Nil(t, outCustody) + require.NotNil(t, outAttrs) + require.NotNil(t, outAttrs.ParentBeaconBlockRoot) + require.Equal(t, root, *outAttrs.ParentBeaconBlockRoot) + require.Nil(t, outAttrs.SlotNumber) + require.Nil(t, outAttrs.TargetGasLimit) + require.Len(t, outAttrs.Withdrawals, 1) + }) + + t.Run("amsterdam attributes and custody columns", func(t *testing.T) { + root := common.HexToHash("0x04") + slot := hexutil.Uint64(456) + targetGasLimit := hexutil.Uint64(36000000) + attrs := &engine_types.PayloadAttributes{ + Timestamp: 500, + SuggestedFeeRecipient: common.HexToAddress("0x06"), + ParentBeaconBlockRoot: &root, + SlotNumber: &slot, + TargetGasLimit: &targetGasLimit, + } + attrs.SSZVersion = clparams.GloasVersion + custody := bytes.Repeat([]byte{0xab}, 16) + + enc, err := encodeForkchoiceUpdate(clparams.GloasVersion, &state, attrs, custody) + require.NoError(t, err) + outState, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.GloasVersion) + require.NoError(t, err) + require.Equal(t, state, outState) + require.NotNil(t, outAttrs) + require.NotNil(t, outAttrs.SlotNumber) + require.Equal(t, slot, *outAttrs.SlotNumber) + require.NotNil(t, outAttrs.TargetGasLimit) + require.Equal(t, targetGasLimit, *outAttrs.TargetGasLimit) + require.Equal(t, custody, outCustody) + }) + + t.Run("amsterdam without custody columns", func(t *testing.T) { + enc, err := encodeForkchoiceUpdate(clparams.GloasVersion, &state, nil, nil) + require.NoError(t, err) + _, outAttrs, outCustody, err := decodeForkchoiceUpdate(enc, clparams.GloasVersion) + require.NoError(t, err) + require.Nil(t, outAttrs) + require.Nil(t, outCustody) + }) +} + +// Pin the exact wire examples from execution-apis#793. Example A: a VALID +// PayloadStatus with latest_valid_hash present and no validation_error is 41 bytes. +func TestSSZRESTPayloadStatusSpecExample(t *testing.T) { + latest := common.HexToHash("0xcd") + status := &engine_types.PayloadStatus{Status: engine_types.ValidStatus, LatestValidHash: &latest} + enc, err := status.EncodeSSZ(nil) require.NoError(t, err) + require.Len(t, enc, 41) + require.Equal(t, byte(0), enc[0]) + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(enc[1:5])) + require.Equal(t, uint32(41), binary.LittleEndian.Uint32(enc[5:9])) + require.Equal(t, latest[:], enc[9:41]) - out, err := decodeCapabilities(enc, 0) + // Example B: INVALID with validation_error "bad state root" and no + // latest_valid_hash is 27 bytes; note the inner 4-byte offset before the text. + invalid := &engine_types.PayloadStatus{ + Status: engine_types.InvalidStatus, + ValidationError: engine_types.NewStringifiedErrorFromString("bad state root"), + } + encB, err := invalid.EncodeSSZ(nil) require.NoError(t, err) - require.Equal(t, []string{"engine_newPayloadV1", "POST /engine/v1/payloads"}, out) + require.Len(t, encB, 27) + require.Equal(t, byte(1), encB[0]) + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(encB[1:5])) // offset[latest_valid_hash] + require.Equal(t, uint32(9), binary.LittleEndian.Uint32(encB[5:9])) // offset[validation_error] + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(encB[9:13])) // inner String offset + require.Equal(t, []byte("bad state root"), encB[13:27]) } func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { @@ -42,6 +287,9 @@ func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { } enc, err := wire.EncodeSSZ(nil) require.NoError(t, err) + // validation_error is Optional[String] = List[List[byte, 1024], 1]: + // present means a 4-byte inner offset precedes the message bytes + require.Equal(t, append([]byte{4, 0, 0, 0}, []byte("no")...), enc[41:]) var out engine_types.PayloadStatus require.NoError(t, out.DecodeSSZ(enc, 0)) @@ -49,50 +297,155 @@ func TestSSZRESTPayloadStatusEnumRoundTrip(t *testing.T) { require.NotNil(t, out.LatestValidHash) require.Equal(t, latest, *out.LatestValidHash) require.Equal(t, "no", out.ValidationError.Error().Error()) + + // absent validation_error stays distinguishable from an empty message + noErr := &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + enc, err = noErr.EncodeSSZ(nil) + require.NoError(t, err) + var out2 engine_types.PayloadStatus + require.NoError(t, out2.DecodeSSZ(enc, 0)) + require.Equal(t, engine_types.SyncingStatus, out2.Status) + require.Nil(t, out2.LatestValidHash) + require.Nil(t, out2.ValidationError) } -func TestSSZRESTRequestCodecsRoundTrip(t *testing.T) { +func TestSSZRESTForkchoiceResponseRoundTrip(t *testing.T) { + latest := common.HexToHash("0x11") + payloadID := hexutil.Bytes{1, 2, 3, 4, 5, 6, 7, 8} + resp := &engine_types.ForkChoiceUpdatedResponse{ + PayloadStatus: &engine_types.PayloadStatus{Status: engine_types.ValidStatus, LatestValidHash: &latest}, + PayloadId: &payloadID, + } + enc, err := encodeForkchoiceResponse(resp) + require.NoError(t, err) + out, err := decodeForkchoiceResponse(enc) + require.NoError(t, err) + require.Equal(t, engine_types.ValidStatus, out.PayloadStatus.Status) + require.Equal(t, latest, *out.PayloadStatus.LatestValidHash) + require.Equal(t, payloadID, *out.PayloadId) + + resp.PayloadId = nil + resp.PayloadStatus = &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + enc, err = encodeForkchoiceResponse(resp) + require.NoError(t, err) + out, err = decodeForkchoiceResponse(enc) + require.NoError(t, err) + require.Equal(t, engine_types.SyncingStatus, out.PayloadStatus.Status) + require.Nil(t, out.PayloadId) +} + +func TestSSZRESTBuiltPayloadRoundTrip(t *testing.T) { + blockValue := uint256.NewInt(112233).ToBig() + bundle := &engine_types.BlobsBundle{ + Commitments: []hexutil.Bytes{bytes.Repeat([]byte{0x01}, sszKZGBytes)}, + Proofs: []hexutil.Bytes{bytes.Repeat([]byte{0x02}, sszKZGBytes)}, + Blobs: []hexutil.Bytes{bytes.Repeat([]byte{0x03}, sszBlobBytes)}, + } + requests := []hexutil.Bytes{{0x00, 0xaa}, {0x01, 0xbb}} + for _, tc := range []struct { - name string - version clparams.StateVersion - encode func(clparams.StateVersion) ([]byte, error) - decode func([]byte, clparams.StateVersion) error + version clparams.StateVersion + wantSOB bool + wantBundle bool + wantRequests bool }{ - {"newPayloadV1", clparams.BellatrixVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV2", clparams.CapellaVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV3", clparams.DenebVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"newPayloadV5", clparams.GloasVersion, encodeEmptyNewPayloadRequest, decodeEmptyNewPayloadRequest}, - {"forkchoiceV1", clparams.BellatrixVersion, encodeEmptyForkchoiceRequest, decodeEmptyForkchoiceRequest}, - {"forkchoiceV4", clparams.GloasVersion, encodeEmptyForkchoiceRequest, decodeEmptyForkchoiceRequest}, + {clparams.BellatrixVersion, false, false, false}, + {clparams.CapellaVersion, false, false, false}, + {clparams.DenebVersion, true, true, false}, + {clparams.ElectraVersion, true, true, true}, + {clparams.FuluVersion, true, true, true}, + {clparams.GloasVersion, true, true, true}, } { - t.Run(tc.name, func(t *testing.T) { - enc, err := tc.encode(tc.version) + t.Run(tc.version.String(), func(t *testing.T) { + payload := engine_types.NewExecutionPayloadSSZ(tc.version) + if tc.version >= clparams.GloasVersion { + slot := hexutil.Uint64(7) + payload.SlotNumber = &slot + bal := hexutil.Bytes{0x0a} + payload.BlockAccessList = &bal + } + resp := &engine_types.GetPayloadResponse{ + ExecutionPayload: payload, + BlockValue: (*hexutil.Big)(blockValue), + ShouldOverrideBuilder: true, + } + if tc.wantBundle { + resp.BlobsBundle = bundle + } + if tc.wantRequests { + resp.ExecutionRequests = requests + } + enc, err := encodeBuiltPayload(resp, tc.version) require.NoError(t, err) - require.NoError(t, tc.decode(enc, tc.version)) + out, err := decodeBuiltPayload(enc, tc.version) + require.NoError(t, err) + require.Equal(t, blockValue, out.BlockValue.ToInt()) + require.Equal(t, payload.BlockHash, out.ExecutionPayload.BlockHash) + require.Equal(t, tc.wantSOB, out.ShouldOverrideBuilder) + if tc.wantBundle { + require.NotNil(t, out.BlobsBundle) + require.Equal(t, bundle.Commitments, out.BlobsBundle.Commitments) + } + if tc.wantRequests { + require.Equal(t, requests, out.ExecutionRequests) + } + if tc.version >= clparams.GloasVersion { + require.NotNil(t, out.ExecutionPayload.BlockAccessList) + require.Equal(t, hexutil.Bytes{0x0a}, *out.ExecutionPayload.BlockAccessList) + } }) } } -func encodeEmptyNewPayloadRequest(version clparams.StateVersion) ([]byte, error) { - return encodeNewPayloadRequest(version, engine_types.NewExecutionPayloadSSZ(version), solid.NewHashList(sszMaxBlobHashes), common.Hash{}, &solid.TransactionsSSZ{}) -} +func TestSSZRESTBodiesResponseRoundTrip(t *testing.T) { + withdrawal := &types.Withdrawal{Index: 1, Validator: 2, Address: common.HexToAddress("0x03"), Amount: 4} + body := &engine_types.ExecutionPayloadBodyV2{ + Transactions: []hexutil.Bytes{{0x01, 0x02}}, + Withdrawals: []*types.Withdrawal{withdrawal}, + BlockAccessList: hexutil.Bytes{0xba, 0x11}, + } -func decodeEmptyNewPayloadRequest(buf []byte, version clparams.StateVersion) error { - _, _, _, _, err := decodeNewPayloadRequest(buf, version) - return err -} + for _, tc := range []struct { + version clparams.StateVersion + wantWithdrawals bool + wantBAL bool + }{ + {clparams.BellatrixVersion, false, false}, + {clparams.DenebVersion, true, false}, + {clparams.FuluVersion, true, false}, + {clparams.GloasVersion, true, true}, + } { + t.Run(tc.version.String(), func(t *testing.T) { + enc, err := encodeBodiesResponse([]*engine_types.ExecutionPayloadBodyV2{body, nil}, tc.version) + require.NoError(t, err) + // BodiesResponse is a single-field container: the body opens with a + // 4-byte offset (=4) to the wrapped entries list. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) + entries, err := decodeBodiesResponse(enc, tc.version) + require.NoError(t, err) + require.Len(t, entries, 2) -func encodeEmptyForkchoiceRequest(version clparams.StateVersion) ([]byte, error) { - state := engine_types.ForkChoiceState{} - return encodeForkchoiceRequest(version, &state, nil) -} + require.True(t, entries[0].Available) + require.Equal(t, body.Transactions, entries[0].Transactions) + if tc.wantWithdrawals { + require.Len(t, entries[0].Withdrawals, 1) + require.Equal(t, *withdrawal, *entries[0].Withdrawals[0]) + } else { + require.Empty(t, entries[0].Withdrawals) + } + if tc.wantBAL { + require.Equal(t, body.BlockAccessList, entries[0].BlockAccessList) + } else { + require.Empty(t, entries[0].BlockAccessList) + } -func decodeEmptyForkchoiceRequest(buf []byte, version clparams.StateVersion) error { - _, _, err := decodeForkchoiceRequest(buf, version) - return err + require.False(t, entries[1].Available) + require.Empty(t, entries[1].Transactions) + }) + } } -func TestSSZRESTGetBlobsCodecsRoundTrip(t *testing.T) { +func TestSSZRESTBlobsResponseRoundTrip(t *testing.T) { blob := bytes.Repeat([]byte{0x11}, sszBlobBytes) proof := bytes.Repeat([]byte{0x22}, sszKZGBytes) proofs := make([]hexutil.Bytes, sszCellsPerExtBlob) @@ -100,188 +453,257 @@ func TestSSZRESTGetBlobsCodecsRoundTrip(t *testing.T) { proofs[i] = proof } - for _, tc := range []struct { - name string - enc func() ([]byte, error) - dec func([]byte) error - }{ - { - name: "v1", - enc: func() ([]byte, error) { - return encodeGetBlobsV1Response([]*engine_types.BlobAndProofV1{{Blob: blob, Proof: proof}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewStaticListSSZ[*engine_types.BlobAndProofV1](sszMaxGetBlobHashes, sszBlobBytes+sszKZGBytes)) - }, - }, - { - name: "v2", - enc: func() ([]byte, error) { - return encodeGetBlobsV2Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewDynamicListSSZ[*engine_types.BlobAndProofV2](sszMaxGetBlobHashes)) - }, - }, - { - name: "v3", - enc: func() ([]byte, error) { - return encodeGetBlobsV3Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) - }, - dec: func(buf []byte) error { - return ssz2.UnmarshalSSZ(buf, 0, solid.NewDynamicListSSZ[*engine_types.NullableBlobAndProofV2](sszMaxGetBlobHashes)) - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - enc, err := tc.enc() - require.NoError(t, err) - require.NoError(t, tc.dec(enc)) - }) + t.Run("v1", func(t *testing.T) { + enc, err := encodeBlobsV1Response([]*engine_types.BlobAndProofV1{{Blob: blob, Proof: proof}, nil}) + require.NoError(t, err) + // BlobsV1Response is a single-field container wrapping the entries list. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) + out, err := decodeBlobsV1Response(enc) + require.NoError(t, err) + require.Len(t, out, 2) + require.NotNil(t, out[0]) + require.Equal(t, hexutil.Bytes(blob), out[0].Blob) + require.Equal(t, hexutil.Bytes(proof), out[0].Proof) + require.Nil(t, out[1]) + }) + + t.Run("v2", func(t *testing.T) { + enc, err := encodeBlobsV2Response([]*engine_types.BlobAndProofV2{{Blob: blob, CellProofs: proofs}, nil}) + require.NoError(t, err) + // BlobsV2Response (shared by /v3) is a single-field container. + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) + out, err := decodeBlobsV2Response(enc) + require.NoError(t, err) + require.Len(t, out, 2) + require.NotNil(t, out[0]) + require.Equal(t, hexutil.Bytes(blob), out[0].Blob) + require.Len(t, out[0].CellProofs, sszCellsPerExtBlob) + require.Nil(t, out[1]) + }) +} + +func TestSSZRESTHashListRequestRoundTrip(t *testing.T) { + hashes := []common.Hash{common.HexToHash("0xaa"), common.HexToHash("0xbb")} + for _, limit := range []int{sszMaxBodiesRequest, sszMaxGetBlobHashes} { + enc, err := encodeHashListRequest(hashes, limit) + require.NoError(t, err) + // single-field container: a 4-byte offset (=4) precedes the hash list body + require.Equal(t, uint32(4), binary.LittleEndian.Uint32(enc[:4])) + require.Len(t, enc, 4+len(hashes)*32) + out, err := decodeHashListRequest(enc, limit) + require.NoError(t, err) + require.Equal(t, hashes, out) } + + // an empty request is just the 4-byte offset, no hashes + enc, err := encodeHashListRequest(nil, sszMaxBodiesRequest) + require.NoError(t, err) + require.Len(t, enc, 4) + out, err := decodeHashListRequest(enc, sszMaxBodiesRequest) + require.NoError(t, err) + require.Empty(t, out) + + // decode enforces the limit itself, independent of any caller-side size guard + oversized, err := encodeHashListRequest(make([]common.Hash, sszMaxBodiesRequest+1), sszMaxBodiesRequest+1) + require.NoError(t, err) + _, err = decodeHashListRequest(oversized, sszMaxBodiesRequest) + require.Error(t, err) } -func TestSSZRESTCapabilitiesRoute(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - body, err := encodeCapabilities([]string{"engine_newPayloadV1"}) +// A reused PayloadStatus must not leak optional fields from a previous decode. +func TestSSZRESTPayloadStatusDecodeResetsOptionals(t *testing.T) { + latest := common.HexToHash("0xab") + full := &engine_types.PayloadStatus{ + Status: engine_types.InvalidStatus, + LatestValidHash: &latest, + ValidationError: engine_types.NewStringifiedErrorFromString("bad"), + } + encFull, err := full.EncodeSSZ(nil) + require.NoError(t, err) + bare := &engine_types.PayloadStatus{Status: engine_types.SyncingStatus} + encBare, err := bare.EncodeSSZ(nil) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/engine/v1/capabilities", bytes.NewReader(body)) + var reused engine_types.PayloadStatus + require.NoError(t, reused.DecodeSSZ(encFull, 0)) + require.NotNil(t, reused.LatestValidHash) + require.NotNil(t, reused.ValidationError) + + require.NoError(t, reused.DecodeSSZ(encBare, 0)) + require.Equal(t, engine_types.SyncingStatus, reused.Status) + require.Nil(t, reused.LatestValidHash) + require.Nil(t, reused.ValidationError) +} + +func TestSSZRESTCapabilitiesRoute(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), false) + req := httptest.NewRequest(http.MethodGet, "/engine/v2/capabilities", nil) rec := httptest.NewRecorder() srv.SSZRESTHandler().ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - require.Equal(t, sszRestContentType, rec.Header().Get("Content-Type")) - resp, err := decodeCapabilities(rec.Body.Bytes(), 0) - require.NoError(t, err) - require.Contains(t, resp, "engine_newPayloadV1") - require.Contains(t, resp, "POST /engine/v4/payloads") - require.NotContains(t, resp, "engine_exchangeCapabilities") + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var caps 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"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &caps)) + require.Equal(t, []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"}, caps.SupportedForks) + require.Equal(t, []string{"payloads", "forkchoice", "bodies"}, caps.ForkScopedEndpoints) + require.Equal(t, []string{"v1", "v2", "v3"}, caps.IndependentlyVersioned["blobs"]) + require.Equal(t, []string{"capabilities", "identity"}, caps.UnscopedEndpoints) + // pin the spec's MAX_* values: MAX_BODIES_REQUEST, MAX_VERSIONED_HASHES_PER_REQUEST, + // MAX_REQUEST_BODY_SIZE (2**26 = 64 MiB). + require.Equal(t, uint64(32), caps.Limits["bodies.max_count"]) + require.Equal(t, uint64(128), caps.Limits["blobs.max_versioned_hashes"]) + require.Equal(t, uint64(67108864), caps.Limits["payload.max_bytes"]) } -func TestSSZRESTAdvertisedRoutes(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - for _, route := range []struct { - method string - path string - code int - }{ - {http.MethodPost, "/engine/v1/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v4/payloads", http.StatusBadRequest}, - {http.MethodPost, "/engine/v5/payloads", http.StatusBadRequest}, - {http.MethodGet, "/engine/v1/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v2/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v3/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v4/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v5/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodGet, "/engine/v6/payloads/not-a-payload-id", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v4/forkchoice", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v2/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v3/blobs", http.StatusBadRequest}, - {http.MethodPost, "/engine/v1/client/version", http.StatusBadRequest}, - } { - t.Run(route.method+" "+route.path, func(t *testing.T) { - req := httptest.NewRequest(route.method, route.path, strings.NewReader("bad-ssz")) - rec := httptest.NewRecorder() - srv.SSZRESTHandler().ServeHTTP(rec, req) - require.Equal(t, route.code, rec.Code) - }) - } +func TestSSZRESTIdentityRoute(t *testing.T) { + srv := newTestEngineServer(&chain.Config{}, false) + req := httptest.NewRequest(http.MethodGet, "/engine/v2/identity", nil) + req.Header.Set("X-Engine-Client-Version", "LH/v9.9.9") + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var versions []engine_types.ClientVersionV1 + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &versions)) + require.Len(t, versions, 1) + require.Equal(t, "EG", versions[0].Code) } -func TestSSZRESTEndpointVersionMapping(t *testing.T) { +func TestSSZRESTRoutingErrors(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), false) for _, tc := range []struct { - name string - fn func(int) (clparams.StateVersion, bool) - in int - want clparams.StateVersion + name string + method string + path string + contentType string + code int + problem string }{ - {"newPayloadV1", sszNewPayloadVersion, 1, clparams.BellatrixVersion}, - {"newPayloadV2", sszNewPayloadVersion, 2, clparams.CapellaVersion}, - {"newPayloadV3", sszNewPayloadVersion, 3, clparams.DenebVersion}, - {"newPayloadV4", sszNewPayloadVersion, 4, clparams.ElectraVersion}, - {"newPayloadV5", sszNewPayloadVersion, 5, clparams.GloasVersion}, - {"getPayloadV1", sszGetPayloadVersion, 1, clparams.BellatrixVersion}, - {"getPayloadV2", sszGetPayloadVersion, 2, clparams.CapellaVersion}, - {"getPayloadV3", sszGetPayloadVersion, 3, clparams.DenebVersion}, - {"getPayloadV4", sszGetPayloadVersion, 4, clparams.ElectraVersion}, - {"getPayloadV5", sszGetPayloadVersion, 5, clparams.FuluVersion}, - {"getPayloadV6", sszGetPayloadVersion, 6, clparams.GloasVersion}, - {"forkchoiceV1", sszForkchoiceVersion, 1, clparams.BellatrixVersion}, - {"forkchoiceV2", sszForkchoiceVersion, 2, clparams.CapellaVersion}, - {"forkchoiceV3", sszForkchoiceVersion, 3, clparams.DenebVersion}, - {"forkchoiceV4", sszForkchoiceVersion, 4, clparams.GloasVersion}, + {"legacy 764 payloads endpoint", http.MethodPost, "/engine/v1/payloads", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"legacy 764 forkchoice endpoint", http.MethodPost, "/engine/v1/forkchoice", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"legacy 764 capabilities endpoint", http.MethodPost, "/engine/v1/capabilities", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"unknown fork", http.MethodPost, "/engine/v2/foobar/payloads", sszRestContentType, http.StatusBadRequest, problemUnsupportedFork}, + {"trailing slash", http.MethodPost, "/engine/v2/cancun/payloads/", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"method mismatch on capabilities", http.MethodPost, "/engine/v2/capabilities", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"wrong content type", http.MethodPost, "/engine/v2/cancun/payloads", "application/json", http.StatusUnsupportedMediaType, problemUnsupportedMediaType}, + {"bad ssz body", http.MethodPost, "/engine/v2/cancun/payloads", sszRestContentType, http.StatusBadRequest, problemSSZDecodeError}, + {"bad payload id", http.MethodGet, "/engine/v2/cancun/payloads/not-an-id", "", http.StatusBadRequest, problemInvalidRequest}, + {"bodies range missing params", http.MethodGet, "/engine/v2/cancun/bodies", "", http.StatusBadRequest, problemInvalidRequest}, + {"bodies range zero count", http.MethodGet, "/engine/v2/cancun/bodies?from=1&count=0", "", http.StatusUnprocessableEntity, problemInvalidBody}, + {"bodies range too large", http.MethodGet, "/engine/v2/cancun/bodies?from=1&count=33", "", http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {"bodies hash too many", http.MethodPost, "/engine/v2/cancun/bodies/hash", sszRestContentType, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {"blobs unknown revision", http.MethodPost, "/engine/v2/blobs/v9", sszRestContentType, http.StatusNotFound, problemMethodNotFound}, + {"blobs too many hashes", http.MethodPost, "/engine/v2/blobs/v1", sszRestContentType, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, } { t.Run(tc.name, func(t *testing.T) { - got, ok := tc.fn(tc.in) - require.True(t, ok) - require.Equal(t, tc.want, got) + var body []byte + switch tc.name { + case "bad ssz body": + body = []byte("bad-ssz") + case "bodies hash too many": + body = bytes.Repeat([]byte{0x01}, (sszMaxBodiesRequest+1)*32) + case "blobs too many hashes": + body = bytes.Repeat([]byte{0x01}, (sszMaxGetBlobHashes+1)*32) + } + req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader(body)) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + require.Equal(t, tc.code, rec.Code) + require.Equal(t, tc.problem, problemType(t, rec)) }) } } -func TestSSZRESTNewPayloadV5UsesGloasPayloadSchema(t *testing.T) { - payload := engine_types.NewExecutionPayloadSSZ(clparams.GloasVersion) - slot := hexutil.Uint64(123) - payload.SlotNumber = &slot - bal := hexutil.Bytes{0x01, 0x02, 0x03} - payload.BlockAccessList = &bal +// A fork that is valid but not scheduled on this chain must be rejected +// with 400 unsupported-fork. +func TestSSZRESTUnscheduledFork(t *testing.T) { + srv := newTestEngineServer(&chain.Config{ShanghaiTime: common.NewUint64(0), CancunTime: common.NewUint64(0)}, false) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/amsterdam/payloads", nil)) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemUnsupportedFork, problemType(t, rec)) +} - enc, err := encodeNewPayloadRequest(clparams.GloasVersion, payload, solid.NewHashList(sszMaxBlobHashes), common.Hash{}, &solid.TransactionsSSZ{}) +// An empty (but well-formed) Paris payload travels through decode and dispatch +// and comes back as an SSZ INVALID status ("invalid block hash") over HTTP 200. +func TestSSZRESTNewPayloadDispatch(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + payload := engine_types.NewExecutionPayloadSSZ(clparams.BellatrixVersion) + body, err := encodeNewPayloadEnvelope(clparams.BellatrixVersion, payload, common.Hash{}, nil) require.NoError(t, err) - wireVersion, ok := sszNewPayloadVersion(5) - require.True(t, ok) - out, _, _, _, err := decodeNewPayloadRequest(enc, wireVersion) - require.NoError(t, err) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/paris/payloads", body)) - require.NotNil(t, out.SlotNumber) - require.Equal(t, hexutil.Uint64(123), *out.SlotNumber) - require.NotNil(t, out.BlockAccessList) - require.Equal(t, hexutil.Bytes{0x01, 0x02, 0x03}, *out.BlockAccessList) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, sszRestContentType, rec.Header().Get("Content-Type")) + var status engine_types.PayloadStatus + require.NoError(t, status.DecodeSSZ(rec.Body.Bytes(), 0)) + require.Equal(t, engine_types.InvalidStatus, status.Status) + require.Contains(t, status.ValidationError.Error().Error(), "invalid block hash") } -func TestSSZRESTForkchoiceV4UsesGloasPayloadAttributesSchema(t *testing.T) { - slotNumber := hexutil.Uint64(456) - targetGasLimit := hexutil.Uint64(36000000) +// A forkchoice with payload attributes whose timestamp belongs to a different +// fork than the URL must be rejected with 400 unsupported-fork. +func TestSSZRESTForkchoiceURLForkMustMatchAttributes(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + root := common.HexToHash("0x04") attrs := &engine_types.PayloadAttributes{ - Timestamp: 1, - SuggestedFeeRecipient: common.HexToAddress("0x1234"), - Withdrawals: nil, - SlotNumber: &slotNumber, - TargetGasLimit: &targetGasLimit, - SSZVersion: clparams.GloasVersion, + Timestamp: 150, // shanghai era + SuggestedFeeRecipient: common.HexToAddress("0x06"), + ParentBeaconBlockRoot: &root, } - state := engine_types.ForkChoiceState{} - enc, err := encodeForkchoiceRequest(clparams.GloasVersion, &state, attrs) + attrs.SSZVersion = clparams.DenebVersion + body, err := encodeForkchoiceUpdate(clparams.DenebVersion, &engine_types.ForkChoiceState{}, attrs, nil) require.NoError(t, err) - wireVersion, ok := sszForkchoiceVersion(4) - require.True(t, ok) - _, engineAttrs, err := decodeForkchoiceRequest(enc, wireVersion) - require.NoError(t, err) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/cancun/forkchoice", body)) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemUnsupportedFork, problemType(t, rec)) +} - require.NotNil(t, engineAttrs) - require.NotNil(t, engineAttrs.SlotNumber) - require.Equal(t, hexutil.Uint64(456), *engineAttrs.SlotNumber) - require.NotNil(t, engineAttrs.TargetGasLimit) - require.Equal(t, hexutil.Uint64(36000000), *engineAttrs.TargetGasLimit) +func TestSSZRESTBlobsPoolDisabled(t *testing.T) { + srv := newTestEngineServer(allForksConfig(), true) + body, err := encodeHashListRequest([]common.Hash{common.HexToHash("0x01")}, sszMaxGetBlobHashes) + require.NoError(t, err) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, newSSZRequest(http.MethodPost, "/engine/v2/blobs/v1", body)) + require.Equal(t, http.StatusNoContent, rec.Code) + require.Empty(t, rec.Body.Bytes()) } -func TestExchangeCapabilitiesAdvertisesJSONRPCAndSSZREST(t *testing.T) { - srv := NewEngineServer(log.New(), &chain.Config{}, nil, nil, false, true, false, false, nil, nil, 0, 0) - caps := srv.ExchangeCapabilities([]string{"engine_newPayloadV1"}) - require.Contains(t, caps, "engine_newPayloadV1") - require.Contains(t, caps, "engine_getPayloadV6") - require.Contains(t, caps, "POST /engine/v1/capabilities") - require.Contains(t, caps, "GET /engine/v6/payloads/{payload_id}") - require.NotContains(t, caps, "engine_exchangeCapabilities") +func TestSSZRESTErrorMapping(t *testing.T) { + for _, tc := range []struct { + err error + code int + problem string + }{ + {&engine_helpers.UnknownPayloadErr, http.StatusNotFound, problemUnknownPayload}, + {&engine_helpers.InvalidForkchoiceStateErr, http.StatusConflict, problemInvalidForkchoice}, + {&engine_helpers.InvalidPayloadAttributesErr, http.StatusUnprocessableEntity, problemInvalidAttributes}, + {&engine_helpers.TooLargeRequestErr, http.StatusRequestEntityTooLarge, problemRequestTooLarge}, + {&engine_helpers.ReorgTooDeepErr, http.StatusConflict, problemReorgTooDeep}, + {&rpc.UnsupportedForkError{Message: "no"}, http.StatusBadRequest, problemUnsupportedFork}, + {&rpc.InvalidParamsError{Message: "no"}, http.StatusUnprocessableEntity, problemInvalidBody}, + {errors.New("boom"), http.StatusInternalServerError, problemInternal}, + } { + rec := httptest.NewRecorder() + writeEngineProblem(rec, tc.err) + require.Equal(t, tc.code, rec.Code) + require.Equal(t, tc.problem, problemType(t, rec)) + } } func TestSSZRESTGetPayloadIDPathParsing(t *testing.T) { @@ -293,19 +715,48 @@ func TestSSZRESTGetPayloadIDPathParsing(t *testing.T) { require.Error(t, err) } -func TestSSZRESTErrorMapping(t *testing.T) { - for _, tc := range []struct { - err error - code int - }{ - {&engine_helpers.UnknownPayloadErr, http.StatusNotFound}, - {&engine_helpers.InvalidForkchoiceStateErr, http.StatusConflict}, - {&engine_helpers.InvalidPayloadAttributesErr, http.StatusUnprocessableEntity}, - {&engine_helpers.TooLargeRequestErr, http.StatusRequestEntityTooLarge}, - {errors.New("boom"), http.StatusInternalServerError}, - } { - rec := httptest.NewRecorder() - writeEngineError(rec, tc.err) - require.Equal(t, tc.code, rec.Code) +func TestExchangeCapabilitiesDropsLegacySSZEndpoints(t *testing.T) { + srv := newTestEngineServer(&chain.Config{}, false) + caps := srv.ExchangeCapabilities([]string{"engine_newPayloadV1"}) + require.Contains(t, caps, "engine_newPayloadV1") + require.Contains(t, caps, "engine_getPayloadV6") + require.NotContains(t, caps, "engine_exchangeCapabilities") + for _, capability := range caps { + require.NotContains(t, capability, "/engine/v") } } + +func TestSSZRESTExecutionRequestsBound(t *testing.T) { + // execution_requests is bounded at MAX_EXECUTION_REQUESTS_PER_PAYLOAD (256); + // a longer list must be rejected on decode. + mkRequests := func(n int) []hexutil.Bytes { + reqs := make([]hexutil.Bytes, n) + for i := range reqs { + reqs[i] = hexutil.Bytes{byte(i)} + } + return reqs + } + payload := engine_types.NewExecutionPayloadSSZ(clparams.ElectraVersion) + + enc, err := encodeNewPayloadEnvelope(clparams.ElectraVersion, payload, common.Hash{}, mkRequests(256)) + require.NoError(t, err) + _, _, out, err := decodeNewPayloadEnvelope(enc, clparams.ElectraVersion) + require.NoError(t, err) + require.Len(t, out, 256) + + enc, err = encodeNewPayloadEnvelope(clparams.ElectraVersion, payload, common.Hash{}, mkRequests(257)) + require.NoError(t, err) + _, _, _, err = decodeNewPayloadEnvelope(enc, clparams.ElectraVersion) + require.Error(t, err) +} + +func TestSSZRESTReadBodyNonSizeError(t *testing.T) { + // a non-size read error (truncated body / client disconnect) is 400, not 413 + srv := newTestEngineServer(allForksConfig(), false) + req := httptest.NewRequest(http.MethodPost, "/engine/v2/cancun/forkchoice", iotest.ErrReader(errors.New("boom"))) + req.Header.Set("Content-Type", sszRestContentType) + rec := httptest.NewRecorder() + srv.SSZRESTHandler().ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, problemInvalidRequest, problemType(t, rec)) +} diff --git a/execution/engineapi/sszrest_wire.go b/execution/engineapi/sszrest_wire.go index 0907a25e1f1..572323b07c4 100644 --- a/execution/engineapi/sszrest_wire.go +++ b/execution/engineapi/sszrest_wire.go @@ -6,10 +6,12 @@ // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. +// SSZ wire codecs for the Engine API v2 REST transport +// (https://github.com/ethereum/execution-apis/pull/793). + package engineapi import ( - "encoding/binary" "fmt" "math/big" @@ -20,22 +22,92 @@ import ( "github.com/erigontech/erigon/cl/cltypes/solid" ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/clonable" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/execution/types" ) const ( - sszMaxBlobHashes = 4096 - sszMaxGetBlobHashes = 128 - sszMaxCapabilityNameBytes = 64 - sszMaxCapabilities = 64 - sszBlobBytes = 0x20000 - sszKZGBytes = 48 - sszCellsPerExtBlob = 128 + sszMaxGetBlobHashes = 128 + sszMaxBodiesRequest = 32 + sszMaxRequestBody = 64 << 20 // MAX_REQUEST_BODY_SIZE = 2**26 + sszBlobBytes = 0x20000 + sszKZGBytes = 48 + sszCellsPerExtBlob = 128 + sszCustodyBitvectorBytes = sszCellsPerExtBlob / 8 + sszMaxBALBytes = 0x40000000 + sszMaxWithdrawals = 16 + sszWithdrawalBytes = 44 ) -var mainnetBeaconCfg = &clparams.MainnetBeaconConfig +var engineForkOrder = []string{"paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"} + +func engineForkVersion(name string) (clparams.StateVersion, bool) { + switch name { + case "paris": + return clparams.BellatrixVersion, true + case "shanghai": + return clparams.CapellaVersion, true + case "cancun": + return clparams.DenebVersion, true + case "prague": + return clparams.ElectraVersion, true + case "osaka": + return clparams.FuluVersion, true + case "amsterdam": + return clparams.GloasVersion, true + default: + return 0, false + } +} + +func forkScheduled(cfg *chain.Config, name string) bool { + switch name { + case "paris": + return true + case "shanghai": + return cfg.ShanghaiTime != nil + case "cancun": + return cfg.CancunTime != nil + case "prague": + return cfg.PragueTime != nil + case "osaka": + return cfg.OsakaTime != nil + case "amsterdam": + return cfg.AmsterdamTime != nil + default: + return false + } +} + +func supportedForkNames(cfg *chain.Config) []string { + out := make([]string, 0, len(engineForkOrder)) + for _, name := range engineForkOrder { + if forkScheduled(cfg, name) { + out = append(out, name) + } + } + return out +} + +func forkNameAtTime(cfg *chain.Config, time uint64) string { + switch { + case cfg.IsAmsterdam(time): + return "amsterdam" + case cfg.IsOsaka(time): + return "osaka" + case cfg.IsPrague(time): + return "prague" + case cfg.IsCancun(time): + return "cancun" + case cfg.IsShanghai(time): + return "shanghai" + default: + return "paris" + } +} func hashListValues(l solid.HashListSSZ) []common.Hash { if l == nil { @@ -49,6 +121,27 @@ func hashListValues(l solid.HashListSSZ) []common.Hash { return out } +// BodiesByHashRequest {block_hashes} and BlobsVNRequest {versioned_hashes} are +// single-field containers wrapping a List[Hash32, limit]; they share this codec. +func encodeHashListRequest(hashes []common.Hash, limit int) ([]byte, error) { + list := solid.NewHashList(limit) + for _, hash := range hashes { + list.Append(hash) + } + return ssz2.MarshalSSZ(nil, list) +} + +func decodeHashListRequest(buf []byte, limit int) ([]common.Hash, error) { + list := solid.NewHashList(limit) + if err := ssz2.UnmarshalSSZ(buf, 0, list); err != nil { + return nil, err + } + if list.Length() > limit { + return nil, fmt.Errorf("hash list length %d exceeds limit %d", list.Length(), limit) + } + return hashListValues(list), nil +} + func transactionsBytes(txs *solid.TransactionsSSZ) []hexutil.Bytes { if txs == nil { return nil @@ -61,164 +154,142 @@ func transactionsBytes(txs *solid.TransactionsSSZ) []hexutil.Bytes { return out } -func validatePayloadIDList(id *solid.ByteListSSZ) error { - if id == nil { - return nil - } - if l := id.Len(); l != 0 && l != 8 { - return fmt.Errorf("payload ID length %d, want 0 or 8", l) +func newTransactionsSSZ(txs []hexutil.Bytes) *solid.TransactionsSSZ { + binaryTxs := make([][]byte, len(txs)) + for i, tx := range txs { + binaryTxs[i] = tx } - return nil + return solid.NewTransactionsSSZFromTransactions(binaryTxs) } -func newPayloadRequestSchema(version clparams.StateVersion, payload *engine_types.ExecutionPayload, blobHashes solid.HashListSSZ, parentRoot *common.Hash, requests *solid.TransactionsSSZ) []any { - switch version { - case clparams.BellatrixVersion, clparams.CapellaVersion: +// execution_requests list bounds; enforced on decode (TransactionsSSZ.EncodeSSZ ignores limits). +const ( + sszMaxExecutionRequests = 1 << 8 // MAX_EXECUTION_REQUESTS_PER_PAYLOAD + sszMaxBytesPerExecutionRequest = 1 << 30 // MAX_BYTES_PER_EXECUTION_REQUEST +) + +// ExecutionPayloadEnvelope is the request body of POST /{fork}/payloads. +// parent_beacon_block_root exists since Cancun, execution_requests since +// Prague; expected blob versioned hashes are recomputed from the transactions. +func newPayloadEnvelopeSchema(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot *common.Hash, requests *solid.TransactionsSSZ) []any { + switch { + case version < clparams.DenebVersion: return []any{payload} - case clparams.DenebVersion: - return []any{payload, blobHashes, parentRoot[:]} + case version < clparams.ElectraVersion: + return []any{payload, parentRoot[:]} default: - return []any{payload, blobHashes, parentRoot[:], requests} + return []any{payload, parentRoot[:], requests} } } -func decodeNewPayloadRequest(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, solid.HashListSSZ, common.Hash, *solid.TransactionsSSZ, error) { +func decodeNewPayloadEnvelope(buf []byte, version clparams.StateVersion) (*engine_types.ExecutionPayload, common.Hash, []hexutil.Bytes, error) { payload := engine_types.NewExecutionPayloadSSZ(version) - blobHashes := solid.NewHashList(sszMaxBlobHashes) parentRoot := common.Hash{} - requests := &solid.TransactionsSSZ{} - err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadRequestSchema(version, payload, blobHashes, &parentRoot, requests)...) - return payload, blobHashes, parentRoot, requests, err + requests := solid.NewTransactionsSSZWithLimits(sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) + if err := ssz2.UnmarshalSSZ(buf, int(version), newPayloadEnvelopeSchema(version, payload, &parentRoot, requests)...); err != nil { + return nil, common.Hash{}, nil, err + } + return payload, parentRoot, transactionsBytes(requests), nil +} + +func encodeNewPayloadEnvelope(version clparams.StateVersion, payload *engine_types.ExecutionPayload, parentRoot common.Hash, requests []hexutil.Bytes) ([]byte, error) { + return ssz2.MarshalSSZ(nil, newPayloadEnvelopeSchema(version, payload, &parentRoot, newTransactionsSSZ(requests))...) +} + +// blobVersionedHashesFromTxs recomputes the versioned hashes the legacy API +// received as expectedBlobVersionedHashes. Undecodable transactions yield an +// empty list; newPayload re-decodes them and reports INVALID itself. +func blobVersionedHashesFromTxs(txs []hexutil.Bytes) []common.Hash { + hashes := []common.Hash{} + binaryTxs := make([][]byte, len(txs)) + for i, tx := range txs { + binaryTxs[i] = tx + } + transactions, err := types.DecodeTransactions(binaryTxs) + if err != nil { + return hashes + } + for _, txn := range transactions { + if txn.Type() == types.BlobTxType { + hashes = append(hashes, txn.GetBlobHashes()...) + } + } + return hashes } -func encodeNewPayloadRequest(version clparams.StateVersion, payload *engine_types.ExecutionPayload, blobHashes solid.HashListSSZ, parentRoot common.Hash, requests *solid.TransactionsSSZ) ([]byte, error) { - return ssz2.MarshalSSZ(nil, newPayloadRequestSchema(version, payload, blobHashes, &parentRoot, requests)...) +// ForkchoiceUpdate is the request body of POST /{fork}/forkchoice. +// custody_columns (Optional[Bitvector[CELLS_PER_EXT_BLOB]]) exists since Amsterdam. +func forkchoiceUpdateSchema(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *solid.ListSSZ[*engine_types.PayloadAttributes], custody *solid.ByteListSSZ) []any { + if version < clparams.GloasVersion { + return []any{state, attrs} + } + return []any{state, attrs, custody} } -func decodeForkchoiceRequest(buf []byte, version clparams.StateVersion) (engine_types.ForkChoiceState, *engine_types.PayloadAttributes, error) { +func decodeForkchoiceUpdate(buf []byte, version clparams.StateVersion) (engine_types.ForkChoiceState, *engine_types.PayloadAttributes, []byte, error) { state := engine_types.ForkChoiceState{} attrsList := solid.NewDynamicListSSZ[*engine_types.PayloadAttributes](1) - if err := ssz2.UnmarshalSSZ(buf, int(version), &state, attrsList); err != nil { - return state, nil, err + custodyList := solid.NewByteListSSZ(sszCustodyBitvectorBytes) + if err := ssz2.UnmarshalSSZ(buf, int(version), forkchoiceUpdateSchema(version, &state, attrsList, custodyList)...); err != nil { + return state, nil, nil, err } - if attrsList.Len() == 0 { - return state, nil, nil + var custody []byte + if l := len(custodyList.Bytes()); l != 0 { + if l != sszCustodyBitvectorBytes { + return state, nil, nil, fmt.Errorf("custody columns bitvector length %d, want %d", l, sszCustodyBitvectorBytes) + } + custody = custodyList.Bytes() } - attrs := attrsList.Get(0) - attrs.SSZVersion = version - return state, attrs, nil + var attrs *engine_types.PayloadAttributes + if attrsList.Len() != 0 { + attrs = attrsList.Get(0) + attrs.SSZVersion = version + } + return state, attrs, custody, nil } -func encodeForkchoiceRequest(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *engine_types.PayloadAttributes) ([]byte, error) { +func encodeForkchoiceUpdate(version clparams.StateVersion, state *engine_types.ForkChoiceState, attrs *engine_types.PayloadAttributes, custody []byte) ([]byte, error) { attrsList := solid.NewDynamicListSSZ[*engine_types.PayloadAttributes](1) if attrs != nil { attrs.SSZVersion = version attrsList.Append(attrs) } - return ssz2.MarshalSSZ(nil, state, attrsList) + custodyList := solid.NewByteListSSZ(sszCustodyBitvectorBytes) + if err := custodyList.SetBytes(custody); err != nil { + return nil, err + } + return ssz2.MarshalSSZ(nil, forkchoiceUpdateSchema(version, state, attrsList, custodyList)...) } +// ForkchoiceUpdateResponse is {payload_status: PayloadStatus, payload_id: Optional[Bytes8]}. func encodeForkchoiceResponse(resp *engine_types.ForkChoiceUpdatedResponse) ([]byte, error) { payloadID := solid.NewByteListSSZ(8) - if resp.PayloadId != nil && len(*resp.PayloadId) == 8 { + if resp.PayloadId != nil { + if len(*resp.PayloadId) != 8 { + return nil, fmt.Errorf("payload ID length %d, want 8", len(*resp.PayloadId)) + } if err := payloadID.SetBytes(*resp.PayloadId); err != nil { return nil, err } } - if err := validatePayloadIDList(payloadID); err != nil { - return nil, err - } return ssz2.MarshalSSZ(nil, resp.PayloadStatus, payloadID) } -func encodeCapabilities(names []string) ([]byte, error) { - capabilities := make([]*solid.ByteListSSZ, 0, len(names)) - for _, name := range names { - capabilities = append(capabilities, capabilityName(name)) - } - if len(capabilities) > sszMaxCapabilities { - return nil, fmt.Errorf("too many capabilities: %d", len(capabilities)) - } - out := make([]byte, 0) - offset := len(capabilities) * 4 - for _, capability := range capabilities { - if capability == nil { - capability = solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - } - out = append(out, 0, 0, 0, 0) - binary.LittleEndian.PutUint32(out[len(out)-4:], uint32(offset)) - offset += capability.EncodingSizeSSZ() - } - for _, capability := range capabilities { - if capability == nil { - capability = solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - } - var err error - out, err = capability.EncodeSSZ(out) - if err != nil { - return nil, err - } - } - return out, nil -} - -func decodeCapabilities(buf []byte, version int) ([]string, error) { - if len(buf) == 0 { - return nil, nil - } - if len(buf) < 4 { - return nil, fmt.Errorf("capabilities: short offset table") - } - firstOffset := binary.LittleEndian.Uint32(buf[:4]) - if firstOffset%4 != 0 || firstOffset > uint32(len(buf)) { - return nil, fmt.Errorf("capabilities: bad first offset %d", firstOffset) - } - count := int(firstOffset / 4) - if count > sszMaxCapabilities { - return nil, fmt.Errorf("too many capabilities: %d", count) +func decodeForkchoiceResponse(buf []byte) (*engine_types.ForkChoiceUpdatedResponse, error) { + status := &engine_types.PayloadStatus{} + payloadID := solid.NewByteListSSZ(8) + if err := ssz2.UnmarshalSSZ(buf, 0, status, payloadID); err != nil { + return nil, err } - offsets := make([]uint32, count+1) - offsets[count] = uint32(len(buf)) - for i := 0; i < count; i++ { - offsets[i] = binary.LittleEndian.Uint32(buf[i*4:]) - if i > 0 && offsets[i] < offsets[i-1] { - return nil, fmt.Errorf("capabilities: non-monotonic offset %d", i) - } - if offsets[i] > uint32(len(buf)) { - return nil, fmt.Errorf("capabilities: offset %d out of bounds", i) + resp := &engine_types.ForkChoiceUpdatedResponse{PayloadStatus: status} + if l := len(payloadID.Bytes()); l != 0 { + if l != 8 { + return nil, fmt.Errorf("payload ID length %d, want 0 or 8", l) } + id := hexutil.Bytes(payloadID.Bytes()) + resp.PayloadId = &id } - out := make([]string, 0, count) - for i := 0; i < count; i++ { - capability := solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - if err := capability.DecodeSSZ(buf[offsets[i]:offsets[i+1]], version); err != nil { - return nil, err - } - out = append(out, string(capability.Bytes())) - } - return out, nil -} - -func capabilityName(s string) *solid.ByteListSSZ { - b := solid.NewByteListSSZ(sszMaxCapabilityNameBytes) - _ = b.SetBytes([]byte(s)) - return b -} - -func encodeClientVersionResponse(versions []engine_types.ClientVersionV1) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.ClientVersionV1](4) - for i := range versions { - list.Append(&versions[i]) - } - return ssz2.MarshalSSZ(nil, list) -} - -func decodeClientVersionRequest(buf []byte) (*engine_types.ClientVersionV1, error) { - version := &engine_types.ClientVersionV1{} - if err := ssz2.UnmarshalSSZ(buf, 0, version); err != nil { - return nil, err - } - return version, nil + return resp, nil } func blockValueHash(v *hexutil.Big) common.Hash { @@ -234,6 +305,15 @@ func blockValueHash(v *hexutil.Big) common.Hash { return out } +func blockValueFromHash(v common.Hash) *hexutil.Big { + var be [32]byte + for i := range v { + be[i] = v[31-i] + } + u := new(uint256.Int).SetBytes(be[:]) + return (*hexutil.Big)(u.ToBig()) +} + func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersion) *engine_types.BlobsBundle { if b == nil { return engine_types.NewBlobsBundleSSZ(version) @@ -242,76 +322,343 @@ func newBlobsBundleSSZ(b *engine_types.BlobsBundle, version clparams.StateVersio return b } -func encodeGetPayloadResponse(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { +// BuiltPayload is the response of GET /{fork}/payloads/{payloadId}: +// {payload, block_value, blobs_bundle, execution_requests, should_override_builder}, +// with blobs_bundle and should_override_builder since Cancun and +// execution_requests since Prague. +func encodeBuiltPayload(resp *engine_types.GetPayloadResponse, version clparams.StateVersion) ([]byte, error) { payload := resp.ExecutionPayload payload.SSZVersion = version - executionRequests, err := executionRequestsFromList(resp.ExecutionRequests, version) - if err != nil { - return nil, err - } blockValue := blockValueHash(resp.BlockValue) blobsBundle := newBlobsBundleSSZ(resp.BlobsBundle, version) - switch version { - case clparams.CapellaVersion: + requests := newTransactionsSSZ(resp.ExecutionRequests) + switch { + case version < clparams.DenebVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:]) - case clparams.DenebVersion: + case version < clparams.ElectraVersion: return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder) default: - return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, resp.ShouldOverrideBuilder, executionRequests) + return ssz2.MarshalSSZ(nil, payload, blockValue[:], blobsBundle, requests, resp.ShouldOverrideBuilder) } } -func executionRequestsFromList(requests []hexutil.Bytes, version clparams.StateVersion) (*cltypes.ExecutionRequests, error) { - out := cltypes.NewExecutionRequests(mainnetBeaconCfg) - for _, request := range requests { - if len(request) == 0 { - continue - } - data := request[1:] - switch request[0] { - case types.DepositRequestType: - if err := out.Deposits.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - case types.WithdrawalRequestType: - if err := out.Withdrawals.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - case types.ConsolidationRequestType: - if err := out.Consolidations.DecodeSSZ(data, int(version)); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unknown execution request type %d", request[0]) +func decodeBuiltPayload(buf []byte, version clparams.StateVersion) (*engine_types.GetPayloadResponse, error) { + payload := engine_types.NewExecutionPayloadSSZ(version) + blockValue := common.Hash{} + blobsBundle := engine_types.NewBlobsBundleSSZ(version) + requests := solid.NewTransactionsSSZWithLimits(sszMaxExecutionRequests, sszMaxBytesPerExecutionRequest) + shouldOverride := false + var err error + switch { + case version < clparams.DenebVersion: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:]) + case version < clparams.ElectraVersion: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, &shouldOverride) + default: + err = ssz2.UnmarshalSSZ(buf, int(version), payload, blockValue[:], blobsBundle, requests, &shouldOverride) + } + if err != nil { + return nil, err + } + resp := &engine_types.GetPayloadResponse{ + ExecutionPayload: payload, + BlockValue: blockValueFromHash(blockValue), + ShouldOverrideBuilder: shouldOverride, + } + if version >= clparams.DenebVersion { + resp.BlobsBundle = blobsBundle + } + if version >= clparams.ElectraVersion { + resp.ExecutionRequests = transactionsBytes(requests) + } + return resp, nil +} + +func newWithdrawalListSSZ(withdrawals []*types.Withdrawal) *solid.ListSSZ[*cltypes.Withdrawal] { + l := solid.NewStaticListSSZ[*cltypes.Withdrawal](sszMaxWithdrawals, sszWithdrawalBytes) + for _, w := range withdrawals { + l.Append(&cltypes.Withdrawal{Index: w.Index, Validator: w.Validator, Address: w.Address, Amount: w.Amount}) + } + return l +} + +func withdrawalsFromSSZList(l *solid.ListSSZ[*cltypes.Withdrawal]) []*types.Withdrawal { + out := make([]*types.Withdrawal, 0, l.Len()) + l.Range(func(_ int, w *cltypes.Withdrawal, _ int) bool { + out = append(out, &types.Withdrawal{Index: w.Index, Validator: w.Validator, Address: w.Address, Amount: w.Amount}) + return true + }) + return out +} + +// sszPayloadBody is the fork-scoped ExecutionPayloadBody: withdrawals exist +// since Shanghai and block_access_list since Amsterdam. +type sszPayloadBody struct { + version clparams.StateVersion + transactions *solid.TransactionsSSZ + withdrawals *solid.ListSSZ[*cltypes.Withdrawal] + blockAccessList *solid.ByteListSSZ +} + +func newSSZPayloadBody(version clparams.StateVersion) *sszPayloadBody { + return &sszPayloadBody{ + version: version, + transactions: &solid.TransactionsSSZ{}, + withdrawals: solid.NewStaticListSSZ[*cltypes.Withdrawal](sszMaxWithdrawals, sszWithdrawalBytes), + blockAccessList: solid.NewByteListSSZ(sszMaxBALBytes), + } +} + +func (b *sszPayloadBody) schema() []any { + switch { + case b.version < clparams.CapellaVersion: + return []any{b.transactions} + case b.version < clparams.GloasVersion: + return []any{b.transactions, b.withdrawals} + default: + return []any{b.transactions, b.withdrawals, b.blockAccessList} + } +} + +func (b *sszPayloadBody) Static() bool { return false } + +func (b *sszPayloadBody) EncodeSSZ(dst []byte) ([]byte, error) { + return ssz2.MarshalSSZ(dst, b.schema()...) +} + +func (b *sszPayloadBody) DecodeSSZ(buf []byte, version int) error { + b.version = clparams.StateVersion(version) + return ssz2.UnmarshalSSZ(buf, version, b.schema()...) +} + +func (b *sszPayloadBody) EncodingSizeSSZ() int { + size := 4 + b.transactions.EncodingSizeSSZ() + if b.version >= clparams.CapellaVersion { + size += 4 + b.withdrawals.EncodingSizeSSZ() + } + if b.version >= clparams.GloasVersion { + size += 4 + b.blockAccessList.EncodingSizeSSZ() + } + return size +} + +func (b *sszPayloadBody) Clone() clonable.Clonable { return newSSZPayloadBody(b.version) } + +// sszBodyEntry is BodyEntry {available: boolean, body: ExecutionPayloadBody}. +// When Available is false the body is zero-valued. +type sszBodyEntry struct { + Available bool + Transactions []hexutil.Bytes + Withdrawals []*types.Withdrawal + BlockAccessList hexutil.Bytes + version clparams.StateVersion +} + +func (e *sszBodyEntry) Static() bool { return false } + +func (e *sszBodyEntry) body() (*sszPayloadBody, error) { + body := newSSZPayloadBody(e.version) + body.transactions = newTransactionsSSZ(e.Transactions) + body.withdrawals = newWithdrawalListSSZ(e.Withdrawals) + if err := body.blockAccessList.SetBytes(e.BlockAccessList); err != nil { + return nil, err + } + return body, nil +} + +func (e *sszBodyEntry) EncodeSSZ(dst []byte) ([]byte, error) { + body, err := e.body() + if err != nil { + return nil, err + } + return ssz2.MarshalSSZ(dst, e.Available, body) +} + +func (e *sszBodyEntry) DecodeSSZ(buf []byte, version int) error { + e.version = clparams.StateVersion(version) + body := newSSZPayloadBody(e.version) + if err := ssz2.UnmarshalSSZ(buf, version, &e.Available, body); err != nil { + return err + } + e.Transactions = transactionsBytes(body.transactions) + e.Withdrawals = withdrawalsFromSSZList(body.withdrawals) + e.BlockAccessList = body.blockAccessList.Bytes() + return nil +} + +func (e *sszBodyEntry) EncodingSizeSSZ() int { + size := 1 + 4 + 4 + newTransactionsSSZ(e.Transactions).EncodingSizeSSZ() + if e.version >= clparams.CapellaVersion { + size += 4 + len(e.Withdrawals)*sszWithdrawalBytes + } + if e.version >= clparams.GloasVersion { + size += 4 + len(e.BlockAccessList) + } + return size +} + +func (e *sszBodyEntry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBodyEntry) Clone() clonable.Clonable { return &sszBodyEntry{} } + +// The response of POST /{fork}/bodies/hash and GET /{fork}/bodies is +// BodiesResponse {entries: List[BodyEntry, MAX_BODIES_REQUEST]}; nil bodies +// become available=false. +func encodeBodiesResponse(bodies []*engine_types.ExecutionPayloadBodyV2, version clparams.StateVersion) ([]byte, error) { + entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) + for _, body := range bodies { + entry := &sszBodyEntry{version: version} + if body != nil { + entry.Available = true + entry.Transactions = body.Transactions + entry.Withdrawals = body.Withdrawals + entry.BlockAccessList = body.BlockAccessList } + entries.Append(entry) } + return ssz2.MarshalSSZ(nil, entries) +} + +func decodeBodiesResponse(buf []byte, version clparams.StateVersion) ([]*sszBodyEntry, error) { + entries := solid.NewDynamicListSSZ[*sszBodyEntry](sszMaxBodiesRequest) + if err := ssz2.UnmarshalSSZ(buf, int(version), entries); err != nil { + return nil, err + } + out := make([]*sszBodyEntry, 0, entries.Len()) + entries.Range(func(_ int, e *sszBodyEntry, _ int) bool { + out = append(out, e) + return true + }) return out, nil } -func encodeGetBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) { - list := solid.NewStaticListSSZ[*engine_types.BlobAndProofV1](sszMaxGetBlobHashes, sszBlobBytes+sszKZGBytes) - for _, blob := range blobs { - if blob != nil { - list.Append(blob) - } +// Shared read-only zero buffers for unavailable blob entries; never mutated. +var ( + zeroSSZBlob = make([]byte, sszBlobBytes) + zeroSSZProof = make([]byte, sszKZGBytes) +) + +// sszBlobV1Entry is BlobEntry {available: boolean, contents: BlobAndProofV1}. +type sszBlobV1Entry struct { + Available bool + Blob hexutil.Bytes + Proof hexutil.Bytes +} + +func (*sszBlobV1Entry) Static() bool { return true } + +func (*sszBlobV1Entry) EncodingSizeSSZ() int { return 1 + sszBlobBytes + sszKZGBytes } + +func (e *sszBlobV1Entry) EncodeSSZ(dst []byte) ([]byte, error) { + blob, proof := []byte(e.Blob), []byte(e.Proof) + if !e.Available { + blob, proof = zeroSSZBlob, zeroSSZProof } - return ssz2.MarshalSSZ(nil, list) + if len(blob) != sszBlobBytes || len(proof) != sszKZGBytes { + return nil, fmt.Errorf("bad blob/proof length %d/%d", len(blob), len(proof)) + } + return ssz2.MarshalSSZ(dst, e.Available, blob, proof) +} + +func (e *sszBlobV1Entry) DecodeSSZ(buf []byte, version int) error { + e.Blob = make(hexutil.Bytes, sszBlobBytes) + e.Proof = make(hexutil.Bytes, sszKZGBytes) + return ssz2.UnmarshalSSZ(buf, version, &e.Available, []byte(e.Blob), []byte(e.Proof)) +} + +func (e *sszBlobV1Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBlobV1Entry) Clone() clonable.Clonable { return &sszBlobV1Entry{} } + +// sszBlobV2Entry is BlobEntry {available: boolean, contents: BlobAndProofV2}, +// shared by /blobs/v2 and /blobs/v3. +type sszBlobV2Entry struct { + Available bool + Contents *engine_types.BlobAndProofV2 } -func encodeGetBlobsV2Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.BlobAndProofV2](sszMaxGetBlobHashes) +func (*sszBlobV2Entry) Static() bool { return false } + +func (e *sszBlobV2Entry) EncodeSSZ(dst []byte) ([]byte, error) { + contents := e.Contents + if contents == nil { + contents = &engine_types.BlobAndProofV2{} + } + return ssz2.MarshalSSZ(dst, e.Available, contents) +} + +func (e *sszBlobV2Entry) DecodeSSZ(buf []byte, version int) error { + e.Contents = &engine_types.BlobAndProofV2{} + if err := ssz2.UnmarshalSSZ(buf, version, &e.Available, e.Contents); err != nil { + return err + } + if !e.Available { + e.Contents = nil + } + return nil +} + +func (e *sszBlobV2Entry) EncodingSizeSSZ() int { + proofs := 0 + if e.Contents != nil { + proofs = len(e.Contents.CellProofs) + } + return 1 + 4 + sszBlobBytes + 4 + proofs*sszKZGBytes +} + +func (e *sszBlobV2Entry) HashSSZ() ([32]byte, error) { return [32]byte{}, nil } + +func (*sszBlobV2Entry) Clone() clonable.Clonable { return &sszBlobV2Entry{} } + +// The response of POST /blobs/vN is BlobsVNResponse {entries: +// List[BlobVNEntry, MAX_BLOBS_REQUEST]}; nil contents become available=false +// with zero-valued contents. +func encodeBlobsV1Response(blobs []*engine_types.BlobAndProofV1) ([]byte, error) { + entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) for _, blob := range blobs { + entry := &sszBlobV1Entry{} if blob != nil { - list.Append(blob) + entry.Available = true + entry.Blob = blob.Blob + entry.Proof = blob.Proof } + entries.Append(entry) } - return ssz2.MarshalSSZ(nil, list) + return ssz2.MarshalSSZ(nil, entries) } -func encodeGetBlobsV3Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { - list := solid.NewDynamicListSSZ[*engine_types.NullableBlobAndProofV2](sszMaxGetBlobHashes) +func decodeBlobsV1Response(buf []byte) ([]*engine_types.BlobAndProofV1, error) { + entries := solid.NewStaticListSSZ[*sszBlobV1Entry](sszMaxGetBlobHashes, 1+sszBlobBytes+sszKZGBytes) + if err := ssz2.UnmarshalSSZ(buf, 0, entries); err != nil { + return nil, err + } + out := make([]*engine_types.BlobAndProofV1, entries.Len()) + entries.Range(func(i int, e *sszBlobV1Entry, _ int) bool { + if e.Available { + out[i] = &engine_types.BlobAndProofV1{Blob: e.Blob, Proof: e.Proof} + } + return true + }) + return out, nil +} + +func encodeBlobsV2Response(blobs []*engine_types.BlobAndProofV2) ([]byte, error) { + entries := solid.NewDynamicListSSZ[*sszBlobV2Entry](sszMaxGetBlobHashes) for _, blob := range blobs { - list.Append(engine_types.NewNullableBlobAndProofV2(blob)) + entries.Append(&sszBlobV2Entry{Available: blob != nil, Contents: blob}) } - return ssz2.MarshalSSZ(nil, list) + return ssz2.MarshalSSZ(nil, entries) +} + +func decodeBlobsV2Response(buf []byte) ([]*engine_types.BlobAndProofV2, error) { + entries := solid.NewDynamicListSSZ[*sszBlobV2Entry](sszMaxGetBlobHashes) + if err := ssz2.UnmarshalSSZ(buf, 0, entries); err != nil { + return nil, err + } + out := make([]*engine_types.BlobAndProofV2, entries.Len()) + entries.Range(func(i int, e *sszBlobV2Entry, _ int) bool { + out[i] = e.Contents + return true + }) + return out, nil }