Skip to content

Commit b23a5e9

Browse files
authored
Merge pull request #178 from attestantio/electra_submit_attestations
Add versioned attestations submission
2 parents 1691823 + ea83322 commit b23a5e9

15 files changed

+483
-27
lines changed

api/submitattestationsopts.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright © 2025 Attestant Limited.
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package api
15+
16+
import "github.com/attestantio/go-eth2-client/spec"
17+
18+
// SubmitAttestationsOpts are the options for submitting attestations.
19+
type SubmitAttestationsOpts struct {
20+
Common CommonOpts
21+
22+
// Attestations are the attestations to submit.
23+
Attestations []*spec.VersionedAttestation
24+
}

http/submitattestations.go

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,92 @@ import (
1818
"context"
1919
"encoding/json"
2020
"errors"
21+
"strings"
2122

23+
client "github.com/attestantio/go-eth2-client"
2224
"github.com/attestantio/go-eth2-client/api"
23-
"github.com/attestantio/go-eth2-client/spec/phase0"
25+
"github.com/attestantio/go-eth2-client/spec"
2426
)
2527

26-
// SubmitAttestations submits attestations.
27-
func (s *Service) SubmitAttestations(ctx context.Context, attestations []*phase0.Attestation) error {
28+
// SubmitAttestations submits versioned attestations.
29+
func (s *Service) SubmitAttestations(ctx context.Context, opts *api.SubmitAttestationsOpts) error {
2830
if err := s.assertIsSynced(ctx); err != nil {
2931
return err
3032
}
33+
if opts == nil {
34+
return client.ErrNoOptions
35+
}
36+
if len(opts.Attestations) == 0 {
37+
return errors.Join(errors.New("no attestations supplied"), client.ErrInvalidOptions)
38+
}
39+
attestations := opts.Attestations
40+
unversionedAttestations, err := createUnversionedAttestations(attestations)
41+
if err != nil {
42+
return err
43+
}
3144

32-
specJSON, err := json.Marshal(attestations)
45+
specJSON, err := json.Marshal(unversionedAttestations)
3346
if err != nil {
3447
return errors.Join(errors.New("failed to marshal JSON"), err)
3548
}
3649

37-
endpoint := "/eth/v1/beacon/pool/attestations"
50+
endpoint := "/eth/v2/beacon/pool/attestations"
3851
query := ""
3952

40-
if _, err := s.post(ctx,
53+
headers := make(map[string]string)
54+
headers["Eth-Consensus-Version"] = strings.ToLower(attestations[0].Version.String())
55+
if _, err = s.post(ctx,
4156
endpoint,
4257
query,
43-
&api.CommonOpts{},
58+
&opts.Common,
4459
bytes.NewReader(specJSON),
4560
ContentTypeJSON,
46-
map[string]string{},
61+
headers,
4762
); err != nil {
48-
return errors.Join(errors.New("failed to submit beacon attestations"), err)
63+
return errors.Join(errors.New("failed to submit versioned beacon attestations"), err)
4964
}
5065

5166
return nil
5267
}
68+
69+
func createUnversionedAttestations(attestations []*spec.VersionedAttestation) ([]any, error) {
70+
var version spec.DataVersion
71+
var unversionedAttestations []any
72+
73+
for i := range attestations {
74+
if attestations[i] == nil {
75+
return nil, errors.Join(errors.New("nil attestation version supplied"), client.ErrInvalidOptions)
76+
}
77+
78+
// Ensure consistent versioning.
79+
if version == spec.DataVersionUnknown {
80+
version = attestations[i].Version
81+
} else if version != attestations[i].Version {
82+
return nil, errors.Join(errors.New("attestations must all be of the same version"), client.ErrInvalidOptions)
83+
}
84+
85+
// Append to unversionedAttestations.
86+
switch attestations[i].Version {
87+
case spec.DataVersionPhase0:
88+
unversionedAttestations = append(unversionedAttestations, attestations[i].Phase0)
89+
case spec.DataVersionAltair:
90+
unversionedAttestations = append(unversionedAttestations, attestations[i].Altair)
91+
case spec.DataVersionBellatrix:
92+
unversionedAttestations = append(unversionedAttestations, attestations[i].Bellatrix)
93+
case spec.DataVersionCapella:
94+
unversionedAttestations = append(unversionedAttestations, attestations[i].Capella)
95+
case spec.DataVersionDeneb:
96+
unversionedAttestations = append(unversionedAttestations, attestations[i].Deneb)
97+
case spec.DataVersionElectra:
98+
singleAttestation, err := attestations[i].Electra.ToSingleAttestation()
99+
if err != nil {
100+
return nil, errors.Join(errors.New("failed to convert attestation to single attestation"), err)
101+
}
102+
unversionedAttestations = append(unversionedAttestations, singleAttestation)
103+
default:
104+
return nil, errors.Join(errors.New("unknown attestation version"), client.ErrInvalidOptions)
105+
}
106+
}
107+
108+
return unversionedAttestations, nil
109+
}

http/submitattestations_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package http_test
1515

1616
import (
1717
"context"
18+
"github.com/attestantio/go-eth2-client/spec"
1819
"os"
1920
"strings"
2021
"testing"
@@ -79,7 +80,13 @@ func TestSubmitAttestations(t *testing.T) {
7980
}),
8081
}
8182

82-
err = service.(client.AttestationsSubmitter).SubmitAttestations(ctx, []*phase0.Attestation{attestation})
83+
versionedAttestations := []*spec.VersionedAttestation{
84+
{Version: spec.DataVersionPhase0, Phase0: attestation},
85+
}
86+
opts := &api.SubmitAttestationsOpts{
87+
Attestations: versionedAttestations,
88+
}
89+
err = service.(client.AttestationsSubmitter).SubmitAttestations(ctx, opts)
8390
switch {
8491
case test.err != "":
8592
require.ErrorContains(t, err, test.err)

mock/submitattestations.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ package mock
1616
import (
1717
"context"
1818

19-
spec "github.com/attestantio/go-eth2-client/spec/phase0"
19+
"github.com/attestantio/go-eth2-client/api"
2020
)
2121

2222
// SubmitAttestations submits attestations.
23-
func (*Service) SubmitAttestations(_ context.Context, _ []*spec.Attestation) error {
23+
func (*Service) SubmitAttestations(_ context.Context, _ *api.SubmitAttestationsOpts) error {
2424
return nil
2525
}

multi/submitattestations.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ import (
1818
"strings"
1919

2020
consensusclient "github.com/attestantio/go-eth2-client"
21-
"github.com/attestantio/go-eth2-client/spec/phase0"
21+
"github.com/attestantio/go-eth2-client/api"
2222
)
2323

2424
// SubmitAttestations submits attestations.
2525
func (s *Service) SubmitAttestations(ctx context.Context,
26-
attestations []*phase0.Attestation,
26+
opts *api.SubmitAttestationsOpts,
2727
) error {
2828
_, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) {
29-
err := client.(consensusclient.AttestationsSubmitter).SubmitAttestations(ctx, attestations)
29+
err := client.(consensusclient.AttestationsSubmitter).SubmitAttestations(ctx, opts)
3030
if err != nil {
3131
return nil, err
3232
}

multi/submitattestations_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ package multi_test
1515

1616
import (
1717
"context"
18+
"github.com/attestantio/go-eth2-client/api"
19+
"github.com/attestantio/go-eth2-client/spec"
1820
"testing"
1921

2022
consensusclient "github.com/attestantio/go-eth2-client"
@@ -50,8 +52,14 @@ func TestSubmitAttestations(t *testing.T) {
5052
)
5153
require.NoError(t, err)
5254

55+
versionedAttestations := []*spec.VersionedAttestation{
56+
{Version: spec.DataVersionPhase0, Phase0: &phase0.Attestation{}},
57+
}
58+
opts := &api.SubmitAttestationsOpts{
59+
Attestations: versionedAttestations,
60+
}
5361
for i := 0; i < 128; i++ {
54-
err := multiClient.(consensusclient.AttestationsSubmitter).SubmitAttestations(ctx, []*phase0.Attestation{})
62+
err := multiClient.(consensusclient.AttestationsSubmitter).SubmitAttestations(ctx, opts)
5563
require.NoError(t, err)
5664
}
5765
// At this point we expect mock 3 to be in active (unless probability hates us).

service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ type AttestationRewardsProvider interface {
208208
// AttestationsSubmitter is the interface for submitting attestations.
209209
type AttestationsSubmitter interface {
210210
// SubmitAttestations submits attestations.
211-
SubmitAttestations(ctx context.Context, attestations []*phase0.Attestation) error
211+
SubmitAttestations(ctx context.Context, opts *api.SubmitAttestationsOpts) error
212212
}
213213

214214
// AttesterSlashingSubmitter is the interface for submitting attester slashings.

spec/electra/attestation.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ import (
2828
)
2929

3030
// Attestation is the Ethereum 2 attestation structure.
31+
//
32+
//nolint:tagalign
3133
type Attestation struct {
32-
AggregationBits bitfield.Bitlist `ssz-max:"131072"`
34+
AggregationBits bitfield.Bitlist `ssz-max:"131072" dynssz-size:"MAX_VALIDATORS_PER_COMMITTEE*MAX_COMMITTEES_PER_SLOT"`
3335
Data *phase0.AttestationData
34-
Signature phase0.BLSSignature `ssz-size:"96"`
35-
CommitteeBits bitfield.Bitvector64 `dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-size:"8"`
36+
Signature phase0.BLSSignature `ssz-size:"96"`
37+
// bitfield.Bitvector64 is an 8 byte array so dynamic sizing doesn't make sense.
38+
CommitteeBits bitfield.Bitvector64 `ssz-size:"8"`
3639
}
3740

3841
// attestationJSON is a raw representation of the struct.
@@ -140,3 +143,51 @@ func (a *Attestation) String() string {
140143

141144
return string(data)
142145
}
146+
147+
// CommitteeIndex returns the index if only one bit is set, otherwise error.
148+
func (a *Attestation) CommitteeIndex() (phase0.CommitteeIndex, error) {
149+
bits := a.CommitteeBits
150+
if len(bits.BitIndices()) == 0 {
151+
return 0, errors.New("no committee index found in committee bits")
152+
}
153+
if len(bits.BitIndices()) > 1 {
154+
return 0, errors.New("multiple committee indices found in committee bits")
155+
}
156+
foundIndex := phase0.CommitteeIndex(bits.BitIndices()[0])
157+
158+
return foundIndex, nil
159+
}
160+
161+
// AggregateValidatorIndex returns the index if only one bit is set, otherwise error.
162+
func (a *Attestation) AggregateValidatorIndex() (phase0.ValidatorIndex, error) {
163+
bits := a.AggregationBits
164+
if len(bits.BitIndices()) == 0 {
165+
return 0, errors.New("no validator index found in aggregation bits")
166+
}
167+
if len(bits.BitIndices()) > 1 {
168+
return 0, errors.New("multiple validator indices found in aggregation bits")
169+
}
170+
foundIndex := phase0.ValidatorIndex(bits.BitIndices()[0])
171+
172+
return foundIndex, nil
173+
}
174+
175+
// ToSingleAttestation returns a SingleAttestation representation of the Attestation.
176+
func (a *Attestation) ToSingleAttestation() (*SingleAttestation, error) {
177+
committeeIndex, err := a.CommitteeIndex()
178+
if err != nil {
179+
return nil, err
180+
}
181+
validatorIndex, err := a.AggregateValidatorIndex()
182+
if err != nil {
183+
return nil, err
184+
}
185+
singleAttestation := SingleAttestation{
186+
CommitteeIndex: committeeIndex,
187+
AttesterIndex: validatorIndex,
188+
Data: a.Data,
189+
Signature: a.Signature,
190+
}
191+
192+
return &singleAttestation, nil
193+
}

spec/electra/attestation_ssz.go

Lines changed: 3 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)