From 35af3ba1e879f3e8128bc2b36ee4b65f7d11566f Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:55:29 +0300 Subject: [PATCH 1/2] Add SSZ list types for gloas API request bodies --- api/v1/gloas/generate.yaml | 4 + api/v1/gloas/lists_test.go | 101 ++++++++++++++++ api/v1/gloas/payloadattestationmessages.go | 38 ++++++ .../gloas/payloadattestationmessages_ssz.go | 111 ++++++++++++++++++ api/v1/gloas/signedproposerpreferenceslist.go | 38 ++++++ .../signedproposerpreferenceslist_ssz.go | 111 ++++++++++++++++++ http/submitproposerpreferences.go | 20 +--- 7 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 api/v1/gloas/lists_test.go create mode 100644 api/v1/gloas/payloadattestationmessages.go create mode 100644 api/v1/gloas/payloadattestationmessages_ssz.go create mode 100644 api/v1/gloas/signedproposerpreferenceslist.go create mode 100644 api/v1/gloas/signedproposerpreferenceslist_ssz.go diff --git a/api/v1/gloas/generate.yaml b/api/v1/gloas/generate.yaml index b37fd9a9..24e3028a 100644 --- a/api/v1/gloas/generate.yaml +++ b/api/v1/gloas/generate.yaml @@ -7,3 +7,7 @@ types: output: blockcontents_ssz.go - name: SignedExecutionPayloadEnvelopeContents output: signedexecutionpayloadenvelopecontents_ssz.go + - name: PayloadAttestationMessages + output: payloadattestationmessages_ssz.go + - name: SignedProposerPreferencesList + output: signedproposerpreferenceslist_ssz.go diff --git a/api/v1/gloas/lists_test.go b/api/v1/gloas/lists_test.go new file mode 100644 index 00000000..74586a9b --- /dev/null +++ b/api/v1/gloas/lists_test.go @@ -0,0 +1,101 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func testPayloadAttestationMessage(i byte) *gloas.PayloadAttestationMessage { + return &gloas.PayloadAttestationMessage{ + ValidatorIndex: phase0.ValidatorIndex(i), + Data: &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{i}, + Slot: phase0.Slot(i), + PayloadPresent: i%2 == 0, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{i}, + } +} + +func testSignedProposerPreferences(i byte) *gloas.SignedProposerPreferences { + return &gloas.SignedProposerPreferences{ + Message: &gloas.ProposerPreferences{ + DependentRoot: phase0.Root{i}, + ProposalSlot: phase0.Slot(i), + ValidatorIndex: phase0.ValidatorIndex(i), + FeeRecipient: [20]byte{i}, + TargetGasLimit: 60_000_000, + }, + Signature: phase0.BLSSignature{i}, + } +} + +// TestPayloadAttestationMessagesSSZ asserts the List[PayloadAttestationMessage, PTC_SIZE] encoding +// used by POST /eth/v1/beacon/pool/payload_attestations. +func TestPayloadAttestationMessagesSSZ(t *testing.T) { + list := apiv1gloas.PayloadAttestationMessages{testPayloadAttestationMessage(1), testPayloadAttestationMessage(2)} + + data, err := list.MarshalSSZ() + require.NoError(t, err) + + // The list of fixed size containers is the concatenation of the elements. + first, err := list[0].MarshalSSZ() + require.NoError(t, err) + require.Equal(t, first, data[:len(first)]) + require.Len(t, data, 2*len(first)) + + var decoded apiv1gloas.PayloadAttestationMessages + require.NoError(t, decoded.UnmarshalSSZ(data)) + require.Equal(t, list, decoded) + + var empty apiv1gloas.PayloadAttestationMessages + require.NoError(t, empty.UnmarshalSSZ(nil)) + require.Empty(t, empty) + + require.Error(t, decoded.UnmarshalSSZ(data[:len(data)-1]), "misaligned body") + + tooMany := make([]byte, (512+1)*len(first)) + require.Error(t, decoded.UnmarshalSSZ(tooMany), "over PTC_SIZE") +} + +// TestSignedProposerPreferencesListSSZ asserts the +// List[SignedProposerPreferences, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH] encoding +// used by POST /eth/v1/validator/proposer_preferences. +func TestSignedProposerPreferencesListSSZ(t *testing.T) { + list := apiv1gloas.SignedProposerPreferencesList{testSignedProposerPreferences(1), testSignedProposerPreferences(2)} + + data, err := list.MarshalSSZ() + require.NoError(t, err) + + first, err := list[0].MarshalSSZ() + require.NoError(t, err) + require.Equal(t, first, data[:len(first)]) + require.Len(t, data, 2*len(first)) + + var decoded apiv1gloas.SignedProposerPreferencesList + require.NoError(t, decoded.UnmarshalSSZ(data)) + require.Equal(t, list, decoded) + + require.Error(t, decoded.UnmarshalSSZ(data[:len(data)-1]), "misaligned body") + + tooMany := make([]byte, (64+1)*len(first)) + require.Error(t, decoded.UnmarshalSSZ(tooMany), "over (MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH") +} diff --git a/api/v1/gloas/payloadattestationmessages.go b/api/v1/gloas/payloadattestationmessages.go new file mode 100644 index 00000000..8eba0d10 --- /dev/null +++ b/api/v1/gloas/payloadattestationmessages.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/pk910/dynamic-ssz/sszutils" +) + +// PayloadAttestationMessages is the List[PayloadAttestationMessage, PTC_SIZE] submitted to +// POST /eth/v1/beacon/pool/payload_attestations. +type PayloadAttestationMessages []*gloas.PayloadAttestationMessage + +var _ = sszutils.Annotate[PayloadAttestationMessages](`ssz-max:"512" dynssz-max:"PTC_SIZE"`) + +// String returns a string version of the structure. +func (p *PayloadAttestationMessages) String() string { + data, err := json.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/payloadattestationmessages_ssz.go b/api/v1/gloas/payloadattestationmessages_ssz.go new file mode 100644 index 00000000..4ae9c716 --- /dev/null +++ b/api/v1/gloas/payloadattestationmessages_ssz.go @@ -0,0 +1,111 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b79ae8cfa942163686679757412dcad98eb2d4d7652e98178d49070b42493362 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestationMessages](`ssz-static:"false"`) + +// MarshalSSZ marshals the *PayloadAttestationMessages to SSZ-encoded bytes. +func (t *PayloadAttestationMessages) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestationMessages to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestationMessages) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestationMessages) + } + vlen := len(*t) + if vlen > 512 { + return nil, sszutils.ErrListLengthFn(vlen, 512) + } + for idx1 := range vlen { + t := (*t)[idx1] + if t == nil { + t = new(gloas.PayloadAttestationMessage) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestationMessages from SSZ-encoded bytes. +func (t *PayloadAttestationMessages) UnmarshalSSZ(buf []byte) (err error) { + itemCount := len(buf) / 146 + if len(buf)%146 != 0 { + return sszutils.ErrListNotAlignedFn(len(buf), 146) + } + if itemCount > 512 { + return sszutils.ErrListLengthFn(itemCount, 512) + } + *t = sszutils.ExpandSlice(*t, itemCount) + for idx1 := range itemCount { + if (*t)[idx1] == nil { + (*t)[idx1] = new(gloas.PayloadAttestationMessage) + } + buf := buf[146*idx1 : 146*(idx1+1)] + if err = (*t)[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestationMessages. +func (t *PayloadAttestationMessages) SizeSSZ() (size int) { + if t == nil { + t = new(PayloadAttestationMessages) + } + size += len(*t) * 146 + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestationMessages. +func (t *PayloadAttestationMessages) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestationMessages using the given hash walker. +func (t *PayloadAttestationMessages) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestationMessages) + } + vlen := uint64(len(*t)) + if vlen > 512 { + return sszutils.ErrListLengthFn(vlen, 512) + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := (*t)[idx1] + if t == nil { + t = new(gloas.PayloadAttestationMessage) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(512, vlen, 32)) + return nil +} diff --git a/api/v1/gloas/signedproposerpreferenceslist.go b/api/v1/gloas/signedproposerpreferenceslist.go new file mode 100644 index 00000000..aab1f84d --- /dev/null +++ b/api/v1/gloas/signedproposerpreferenceslist.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/pk910/dynamic-ssz/sszutils" +) + +// SignedProposerPreferencesList is the List[SignedProposerPreferences, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH] +// submitted to POST /eth/v1/validator/proposer_preferences. +type SignedProposerPreferencesList []*gloas.SignedProposerPreferences + +var _ = sszutils.Annotate[SignedProposerPreferencesList](`ssz-max:"64" dynssz-max:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH"`) + +// String returns a string version of the structure. +func (s *SignedProposerPreferencesList) String() string { + data, err := json.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/signedproposerpreferenceslist_ssz.go b/api/v1/gloas/signedproposerpreferenceslist_ssz.go new file mode 100644 index 00000000..5ec5e0c5 --- /dev/null +++ b/api/v1/gloas/signedproposerpreferenceslist_ssz.go @@ -0,0 +1,111 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: fb4d852249d3d96e0adc4554948977bb95082132ce5dac827fe63e7971261cb4 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedProposerPreferencesList](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedProposerPreferencesList to SSZ-encoded bytes. +func (t *SignedProposerPreferencesList) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedProposerPreferencesList to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedProposerPreferencesList) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedProposerPreferencesList) + } + vlen := len(*t) + if vlen > 64 { + return nil, sszutils.ErrListLengthFn(vlen, 64) + } + for idx1 := range vlen { + t := (*t)[idx1] + if t == nil { + t = new(gloas.SignedProposerPreferences) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedProposerPreferencesList from SSZ-encoded bytes. +func (t *SignedProposerPreferencesList) UnmarshalSSZ(buf []byte) (err error) { + itemCount := len(buf) / 172 + if len(buf)%172 != 0 { + return sszutils.ErrListNotAlignedFn(len(buf), 172) + } + if itemCount > 64 { + return sszutils.ErrListLengthFn(itemCount, 64) + } + *t = sszutils.ExpandSlice(*t, itemCount) + for idx1 := range itemCount { + if (*t)[idx1] == nil { + (*t)[idx1] = new(gloas.SignedProposerPreferences) + } + buf := buf[172*idx1 : 172*(idx1+1)] + if err = (*t)[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedProposerPreferencesList. +func (t *SignedProposerPreferencesList) SizeSSZ() (size int) { + if t == nil { + t = new(SignedProposerPreferencesList) + } + size += len(*t) * 172 + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedProposerPreferencesList. +func (t *SignedProposerPreferencesList) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedProposerPreferencesList using the given hash walker. +func (t *SignedProposerPreferencesList) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedProposerPreferencesList) + } + vlen := uint64(len(*t)) + if vlen > 64 { + return sszutils.ErrListLengthFn(vlen, 64) + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := (*t)[idx1] + if t == nil { + t = new(gloas.SignedProposerPreferences) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(64, vlen, 32)) + return nil +} diff --git a/http/submitproposerpreferences.go b/http/submitproposerpreferences.go index 44ac13cd..b8bdf7d1 100644 --- a/http/submitproposerpreferences.go +++ b/http/submitproposerpreferences.go @@ -23,12 +23,11 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/gloas" ) -type proposerPreferencesList []*gloas.SignedProposerPreferences - const staticProposerPreferencesLimit uint64 = 64 func (s *Service) proposerPreferencesLimit(ctx context.Context) (uint64, error) { @@ -56,19 +55,6 @@ func (s *Service) proposerPreferencesLimit(ctx context.Context) (uint64, error) return (minSeedLookahead + 1) * slotsPerEpoch, nil } -func (p proposerPreferencesList) MarshalSSZ() ([]byte, error) { - var body []byte - for _, preference := range p { - encoded, err := preference.MarshalSSZ() - if err != nil { - return nil, err - } - body = append(body, encoded...) - } - - return body, nil -} - // SubmitProposerPreferences submits signed proposer preferences. func (s *Service) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { if err := s.assertIsSynced(ctx); err != nil { @@ -87,9 +73,9 @@ func (s *Service) SubmitProposerPreferences(ctx context.Context, preferences []* } } - requestPreferences := proposerPreferencesList(preferences) + requestPreferences := apiv1gloas.SignedProposerPreferencesList(preferences) if requestPreferences == nil { - requestPreferences = proposerPreferencesList{} + requestPreferences = apiv1gloas.SignedProposerPreferencesList{} } body, contentType, err := s.marshalRequestBody(ctx, requestPreferences) if err != nil { From 616957c2248a8607da5bfb75a35e4464ca7a6eab Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:16:27 +0300 Subject: [PATCH 2/2] Pass the preferences list by pointer for SSZ marshalling --- http/submitproposerpreferences.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/http/submitproposerpreferences.go b/http/submitproposerpreferences.go index b8bdf7d1..fc37bf11 100644 --- a/http/submitproposerpreferences.go +++ b/http/submitproposerpreferences.go @@ -77,7 +77,8 @@ func (s *Service) SubmitProposerPreferences(ctx context.Context, preferences []* if requestPreferences == nil { requestPreferences = apiv1gloas.SignedProposerPreferencesList{} } - body, contentType, err := s.marshalRequestBody(ctx, requestPreferences) + // The generated list marshaller has a pointer receiver. + body, contentType, err := s.marshalRequestBody(ctx, &requestPreferences) if err != nil { return err }