Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cl/beacon/handler/epbs.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
if err := signedEnvelope.DecodeSSZ(octect, int(clparams.GloasVersion)); err != nil {
if err := signedEnvelope.DecodeSSZStrict(octect, int(clparams.GloasVersion)); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
Expand Down
111 changes: 103 additions & 8 deletions cl/cltypes/epbs_payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"

"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes/solid"
Expand Down Expand Up @@ -495,20 +496,25 @@ func (e *ExecutionPayloadEnvelope) EncodeSSZ(buf []byte) ([]byte, error) {
}

func (e *ExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error {
return e.decodeSSZ(buf, version, false)
}

func (e *ExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error {
return e.decodeSSZ(buf, version, true)
}

func (e *ExecutionPayloadEnvelope) decodeSSZ(buf []byte, version int, strict bool) error {
if e.Payload == nil {
e.Payload = NewEth1Block(clparams.StateVersion(version), e.beaconCfg)
}
if e.ExecutionRequests == nil {
e.ExecutionRequests = NewExecutionRequestsWithVersion(e.beaconCfg, clparams.StateVersion(version))
}
return ssz2.UnmarshalSSZ(
buf, version,
e.Payload,
e.ExecutionRequests,
&e.BuilderIndex,
e.BeaconBlockRoot[:],
e.ParentBeaconBlockRoot[:],
)
schema := []any{e.Payload, e.ExecutionRequests, &e.BuilderIndex, e.BeaconBlockRoot[:], e.ParentBeaconBlockRoot[:]}
if strict {
return ssz2.UnmarshalSSZStrict(buf, version, schema...)
}
return ssz2.UnmarshalSSZ(buf, version, schema...)
}

func (e *ExecutionPayloadEnvelope) EncodingSizeSSZ() int {
Expand Down Expand Up @@ -546,6 +552,84 @@ type SignedExecutionPayloadEnvelope struct {
beaconCfg *clparams.BeaconChainConfig
}

// ValidateForConfig checks structural and protocol constraints before hashing an envelope.
func (s *SignedExecutionPayloadEnvelope) ValidateForConfig(cfg *clparams.BeaconChainConfig) error {
if s == nil {
return errors.New("nil execution payload envelope")
}
if cfg == nil {
return errors.New("nil beacon chain config")
}
if s.Message == nil {
return errors.New("nil execution payload envelope message")
}
payload := s.Message.Payload
if payload == nil {
return errors.New("execution payload envelope has nil payload")
}
if payload.Extra == nil {
return errors.New("execution payload envelope has nil extra data")
}
if err := payload.Extra.ValidateBounds(); err != nil {
return fmt.Errorf("invalid execution payload extra data: %w", err)
}
if payload.Transactions == nil {
return errors.New("execution payload envelope has nil transactions")
}
if payload.Withdrawals == nil {
return errors.New("execution payload envelope has nil withdrawals")
}
if err := payload.Withdrawals.ValidateBounds(int(cfg.MaxWithdrawalsPerPayload)); err != nil {
return fmt.Errorf("invalid execution payload withdrawals: %w", err)
}
if err := solid.RangeErr(payload.Withdrawals, func(i int, withdrawal *Withdrawal, _ int) error {
if withdrawal == nil {
return fmt.Errorf("nil withdrawal at index %d", i)
}
return nil
}); err != nil {
return err
}
if payload.BlockAccessList == nil {
return errors.New("execution payload envelope has nil block access list")
}
requests := s.Message.ExecutionRequests
if requests == nil {
return errors.New("execution payload envelope has nil execution requests")
}
if payload.Version() < clparams.GloasVersion {
return fmt.Errorf("execution payload version %d predates Gloas", payload.Version())
}
if requests.Version() < clparams.GloasVersion {
return fmt.Errorf("execution requests version %d predates Gloas", requests.Version())
}
if payload.Version() != requests.Version() {
return fmt.Errorf("payload and execution requests versions differ: %d != %d", payload.Version(), requests.Version())
}
if err := requests.validateForConfig(cfg); err != nil {
return fmt.Errorf("invalid execution requests: %w", err)
}
return nil
}

// ValidateForPersistence checks that the configured decoder can read the encoded envelope.
func (s *SignedExecutionPayloadEnvelope) ValidateForPersistence(cfg *clparams.BeaconChainConfig) error {
if err := s.ValidateForConfig(cfg); err != nil {
return err
}
payload := s.Message.Payload
if err := payload.Transactions.ValidateBounds(cfg.MaxTransactionsPerPayload, cfg.MaxBytesPerTransaction); err != nil {
return fmt.Errorf("transactions exceed decoder resource limit: %w", err)
}
if err := payload.BlockAccessList.ValidateBounds(cfg.MaxBytesPerTransaction); err != nil {
return fmt.Errorf("block access list exceeds decoder resource limit: %w", err)
}
if err := s.Message.ExecutionRequests.validateForPersistence(cfg); err != nil {
return fmt.Errorf("execution requests exceed decoder resource limit: %w", err)
}
return nil
}

func (s *SignedExecutionPayloadEnvelope) HashSSZ() ([32]byte, error) {
return merkle_tree.HashTreeRoot(s.Message, s.Signature[:])
}
Expand All @@ -559,9 +643,20 @@ func (s *SignedExecutionPayloadEnvelope) EncodeSSZ(buf []byte) ([]byte, error) {
}

func (s *SignedExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error {
return s.decodeSSZ(buf, version, false)
}

func (s *SignedExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error {
return s.decodeSSZ(buf, version, true)
}

func (s *SignedExecutionPayloadEnvelope) decodeSSZ(buf []byte, version int, strict bool) error {
if s.Message == nil {
s.Message = NewExecutionPayloadEnvelope(s.beaconCfg)
}
if strict {
return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:])
}
return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:])
}

Expand Down
92 changes: 92 additions & 0 deletions cl/cltypes/epbs_payload_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
package cltypes

import (
"encoding/binary"
"encoding/json"
"errors"
"testing"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes/solid"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/ssz"
)

func TestExecutionRequestsStrictDecodeRejectsNonCanonicalOffset(t *testing.T) {
requests := NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
encoded, err := requests.EncodeSSZ(nil)
require.NoError(t, err)
firstOffset := binary.LittleEndian.Uint32(encoded)
binary.LittleEndian.PutUint32(encoded, firstOffset+1)
encoded = append(encoded[:firstOffset], append([]byte{0}, encoded[firstOffset:]...)...)

decoded := NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
require.Error(t, decoded.DecodeSSZStrict(encoded, int(clparams.GloasVersion)))
}

func TestSignedExecutionPayloadEnvelopeCloneNilMessage(t *testing.T) {
envelope := &SignedExecutionPayloadEnvelope{
Signature: common.Bytes96{1, 2, 3},
Expand All @@ -22,6 +36,84 @@ func TestSignedExecutionPayloadEnvelopeCloneNilMessage(t *testing.T) {
require.Equal(t, envelope.Signature, cloned.Signature)
}

func TestExecutionPayloadEnvelopeValidationSeparatesProtocolAndPersistenceBounds(t *testing.T) {
cfg := clparams.MainnetBeaconConfig
cfg.MaxWithdrawalsPerPayload = 1
cfg.MaxWithdrawalRequestsPerPayload = 1
cfg.MaxConsolidationRequestsPerPayload = 1
cfg.MaxBuilderDepositRequestsPerPayload = 1
cfg.MaxBuilderExitRequestsPerPayload = 1
cfg.MaxTransactionsPerPayload = 1
cfg.MaxBytesPerTransaction = 1

for _, test := range []struct {
name string
mutate func(*SignedExecutionPayloadEnvelope)
}{
{"payload withdrawals", func(e *SignedExecutionPayloadEnvelope) {
e.Message.Payload.Withdrawals.Append(&Withdrawal{})
e.Message.Payload.Withdrawals.Append(&Withdrawal{})
}},
{"withdrawal requests", func(e *SignedExecutionPayloadEnvelope) {
e.Message.ExecutionRequests.Withdrawals.Append(&solid.WithdrawalRequest{})
e.Message.ExecutionRequests.Withdrawals.Append(&solid.WithdrawalRequest{})
}},
{"consolidation requests", func(e *SignedExecutionPayloadEnvelope) {
e.Message.ExecutionRequests.Consolidations.Append(&solid.ConsolidationRequest{})
e.Message.ExecutionRequests.Consolidations.Append(&solid.ConsolidationRequest{})
}},
{"builder deposit requests", func(e *SignedExecutionPayloadEnvelope) {
e.Message.ExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{})
e.Message.ExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{})
}},
{"builder exit requests", func(e *SignedExecutionPayloadEnvelope) {
e.Message.ExecutionRequests.BuilderExits.Append(&solid.BuilderExitRequest{})
e.Message.ExecutionRequests.BuilderExits.Append(&solid.BuilderExitRequest{})
}},
} {
t.Run(test.name, func(t *testing.T) {
envelope := validTestExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)
test.mutate(envelope)
require.Error(t, envelope.ValidateForConfig(&cfg))
})
}

envelope := validTestExecutionPayloadEnvelope(&cfg)
for range 16_385 {
envelope.Message.ExecutionRequests.Deposits.Append(&solid.DepositRequest{})
}
require.NoError(t, envelope.ValidateForConfig(&cfg))
require.Error(t, envelope.ValidateForPersistence(&cfg))

for _, test := range []struct {
name string
mutate func(*SignedExecutionPayloadEnvelope)
}{
{"transactions", func(e *SignedExecutionPayloadEnvelope) {
e.Message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions([][]byte{{1}, {2}})
}},
{"block access list", func(e *SignedExecutionPayloadEnvelope) {
require.NoError(t, e.Message.Payload.BlockAccessList.SetBytes([]byte{1, 2}))
}},
} {
t.Run(test.name+" are resource bounded only", func(t *testing.T) {
envelope := validTestExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)
test.mutate(envelope)
require.NoError(t, envelope.ValidateForConfig(&cfg))
require.Error(t, envelope.ValidateForPersistence(&cfg))
})
}
}

func validTestExecutionPayloadEnvelope(cfg *clparams.BeaconChainConfig) *SignedExecutionPayloadEnvelope {
message := NewExecutionPayloadEnvelope(cfg)
message.Payload.Extra = solid.NewExtraData()
message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions(nil)
message.Payload.Withdrawals = solid.NewStaticListSSZ[*Withdrawal](int(cfg.MaxWithdrawalsPerPayload), 44)
message.Payload.BlockAccessList = solid.NewByteListSSZ(cfg.MaxBytesPerTransaction)
return &SignedExecutionPayloadEnvelope{Message: message}
}

func TestBuilderPendingPaymentSSZIncludesProposerIndex(t *testing.T) {
payment := &BuilderPendingPayment{
Weight: 123,
Expand Down
84 changes: 81 additions & 3 deletions cl/cltypes/execution_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ func (e *ExecutionRequests) effectiveVersion() clparams.StateVersion {
return e.version
}

func (e *ExecutionRequests) Version() clparams.StateVersion {
return e.effectiveVersion()
}

func (e *ExecutionRequests) ensureLists() {
if e.cfg == nil {
panic("execution requests beacon config is nil")
Expand Down Expand Up @@ -111,6 +115,14 @@ func (e *ExecutionRequests) EncodeSSZ(buf []byte) ([]byte, error) {
}

func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error {
return e.decodeSSZ(buf, version, false)
}

func (e *ExecutionRequests) DecodeSSZStrict(buf []byte, version int) error {
return e.decodeSSZ(buf, version, true)
}

func (e *ExecutionRequests) decodeSSZ(buf []byte, version int, strict bool) error {
decodedVersion := clparams.StateVersion(version)
if (e.effectiveVersion() >= clparams.GloasVersion) != (decodedVersion >= clparams.GloasVersion) {
e.Deposits = nil
Expand All @@ -121,10 +133,14 @@ func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error {
}
e.version = decodedVersion
e.ensureLists()
if e.effectiveVersion() < clparams.GloasVersion {
return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations)
schema := []any{e.Deposits, e.Withdrawals, e.Consolidations}
if e.effectiveVersion() >= clparams.GloasVersion {
schema = append(schema, e.BuilderDeposits, e.BuilderExits)
}
return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits)
if strict {
return ssz2.UnmarshalSSZStrict(buf, version, schema...)
}
return ssz2.UnmarshalSSZ(buf, version, schema...)
}

func (e *ExecutionRequests) Clone() clonable.Clonable {
Expand Down Expand Up @@ -205,6 +221,68 @@ func (e *ExecutionRequests) Static() bool {
return false
}

func (e *ExecutionRequests) validateForConfig(cfg *clparams.BeaconChainConfig) error {
if e.Deposits == nil {
return fmt.Errorf("nil deposit requests")
}
if e.Withdrawals == nil {
return fmt.Errorf("nil withdrawal requests")
}
if e.Consolidations == nil {
return fmt.Errorf("nil consolidation requests")
}
if e.BuilderDeposits == nil {
return fmt.Errorf("nil builder deposit requests")
}
if e.BuilderExits == nil {
return fmt.Errorf("nil builder exit requests")
}
if err := e.Withdrawals.ValidateBounds(int(cfg.MaxWithdrawalRequestsPerPayload)); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four lists are progressive in Gloas, so a hard len <= configured max check rejects what the decoder accepts.

For Gloas, ensureLists builds all five via NewStaticProgressiveListSSZ, whose limit is progressiveDecodeLimit(configLimit) = 2 * configLimit. progressiveDecodeLimit's own comment says: "Progressive lists are semantically unbounded, so decode limits are resource guards rather than protocol maxima." validateForPersistence follows that for Deposits (ValidateProgressiveDecodeBounds, i.e. 2x), but lines 240-251 apply the raw 1x protocol maximum to the other four.

Two consequences:

  • Inconsistent: an envelope with MaxDepositRequestsPerPayload + 1 deposits is accepted, one with MaxWithdrawalRequestsPerPayload + 1 withdrawals is rejected.
  • Read-side amplification: ReadEnvelopeFromDisk also runs this, and a failure sets invalidEnvelopes permanently. Any config tightening of these four maxima turns already-persisted envelopes into permanently unreadable ones and kills HasEnvelope for those roots.

Per cl/CLAUDE.md ("Review all changes against the upstream Ethereum consensus specifications"), either use ValidateProgressiveDecodeBounds for all five or drop the four 1x checks.

return fmt.Errorf("withdrawals: %w", err)
}
if err := e.Consolidations.ValidateBounds(int(cfg.MaxConsolidationRequestsPerPayload)); err != nil {
return fmt.Errorf("consolidations: %w", err)
}
if err := e.BuilderDeposits.ValidateBounds(int(cfg.MaxBuilderDepositRequestsPerPayload)); err != nil {
return fmt.Errorf("builder deposits: %w", err)
}
if err := e.BuilderExits.ValidateBounds(int(cfg.MaxBuilderExitRequestsPerPayload)); err != nil {
return fmt.Errorf("builder exits: %w", err)
}
if err := solid.RangeErr(e.Deposits, rejectNilRequest("deposit", func(request *solid.DepositRequest) bool { return request == nil })); err != nil {
return err
}
if err := solid.RangeErr(e.Withdrawals, rejectNilRequest("withdrawal", func(request *solid.WithdrawalRequest) bool { return request == nil })); err != nil {
return err
}
if err := solid.RangeErr(e.Consolidations, rejectNilRequest("consolidation", func(request *solid.ConsolidationRequest) bool { return request == nil })); err != nil {
return err
}
if err := solid.RangeErr(e.BuilderDeposits, rejectNilRequest("builder deposit", func(request *solid.BuilderDepositRequest) bool { return request == nil })); err != nil {
return err
}
return solid.RangeErr(e.BuilderExits, rejectNilRequest("builder exit", func(request *solid.BuilderExitRequest) bool { return request == nil }))
}

func (e *ExecutionRequests) validateForPersistence(cfg *clparams.BeaconChainConfig) error {
if err := e.validateForConfig(cfg); err != nil {
return err
}
if err := e.Deposits.ValidateProgressiveDecodeBounds(int(cfg.MaxDepositRequestsPerPayload)); err != nil {
return fmt.Errorf("deposits exceed decoder resource limit: %w", err)
}
return nil
}

func rejectNilRequest[T solid.EncodableHashableSSZ](name string, isNil func(T) bool) func(int, T, int) error {
return func(i int, request T, _ int) error {
if isNil(request) {
return fmt.Errorf("nil %s request at index %d", name, i)
}
return nil
}
}

func (e *ExecutionRequests) UnmarshalJSON(b []byte) error {
e.ensureLists()
newDeposits := solid.NewStaticListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest)
Expand Down
7 changes: 7 additions & 0 deletions cl/cltypes/solid/byte_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,10 @@ func (b *ByteListSSZ) SetBytes(buf []byte) error {
func (b *ByteListSSZ) Len() int {
return len(b.data)
}

func (b *ByteListSSZ) ValidateBounds(limit uint64) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bound is taken as a parameter while the instance already carries b.limit.

ByteListSSZ is constructed with its own limit (and DecodeSSZ/SetBytes enforce it). Taking a second, unrelated limit here means a caller that passes a different value than the one used at construction validates against the wrong maximum with no signal — and ValidateForPersistence does exactly that, passing cfg.MaxBytesPerTransaction to a list that may have been built from another config.

Either validate against b.limit and drop the parameter, or assert the two agree.

if uint64(len(b.data)) > limit {
return fmt.Errorf("data length %d exceeds limit %d", len(b.data), limit)
}
return nil
}
Loading
Loading