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
24 changes: 24 additions & 0 deletions api/beaconcommitteeselectionsopts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright © 2025 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 api

import apiv1 "github.com/attestantio/go-eth2-client/api/v1"

// BeaconCommitteeSelectionsOpts are the options for obtaining beacon committee selections.
type BeaconCommitteeSelectionsOpts struct {
Common CommonOpts

// Beacon Committee Selections are the selections which the DV should resolve.
Selections []*apiv1.BeaconCommitteeSelection
}
100 changes: 100 additions & 0 deletions api/v1/beaconcommitteeselection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright © 2025 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 v1

import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
)

// BeaconCommitteeSelection is the data required for a beacon committee selection.
type BeaconCommitteeSelection struct {
// ValidatorIndex is the index of the validator making the selection request.
ValidatorIndex phase0.ValidatorIndex
// Slot is the slot for which the validator is attesting.
Slot phase0.Slot
// SelectionProof is the proof of the validator being selected for beacon committee aggregation.
SelectionProof phase0.BLSSignature
}

// beaconCommitteeSelectionJSON is the spec representation of the struct.
type beaconCommitteeSelectionJSON struct {
ValidatorIndex string `json:"validator_index"`
Slot string `json:"slot"`
SelectionProof string `json:"selection_proof"`
}

// MarshalJSON implements json.Marshaler.
func (b *BeaconCommitteeSelection) MarshalJSON() ([]byte, error) {
return json.Marshal(&beaconCommitteeSelectionJSON{
ValidatorIndex: fmt.Sprintf("%d", b.ValidatorIndex),
Slot: fmt.Sprintf("%d", b.Slot),
SelectionProof: fmt.Sprintf("%#x", b.SelectionProof),
})
}

// UnmarshalJSON implements json.Unmarshaler.
func (b *BeaconCommitteeSelection) UnmarshalJSON(input []byte) error {
var err error

var beaconCommitteeSelectionJSON beaconCommitteeSelectionJSON
if err = json.Unmarshal(input, &beaconCommitteeSelectionJSON); err != nil {
return errors.Wrap(err, "invalid JSON")
}
if beaconCommitteeSelectionJSON.ValidatorIndex == "" {
return errors.New("validator index missing")
}
validatorIndex, err := strconv.ParseUint(beaconCommitteeSelectionJSON.ValidatorIndex, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid value for validator index")
}
b.ValidatorIndex = phase0.ValidatorIndex(validatorIndex)
if beaconCommitteeSelectionJSON.Slot == "" {
return errors.New("slot missing")
}
slot, err := strconv.ParseUint(beaconCommitteeSelectionJSON.Slot, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid value for slot")
}
b.Slot = phase0.Slot(slot)
if beaconCommitteeSelectionJSON.SelectionProof == "" {
return errors.New("selection proof missing")
}
selectionProof, err := hex.DecodeString(strings.TrimPrefix(beaconCommitteeSelectionJSON.SelectionProof, "0x"))
if err != nil {
return errors.Wrap(err, "invalid value for selection proof")
}
if len(selectionProof) != phase0.SignatureLength {
return errors.New("incorrect length for selection proof")
}
copy(b.SelectionProof[:], selectionProof)

return nil
}

// String returns a string version of the structure.
func (b *BeaconCommitteeSelection) String() string {
data, err := json.Marshal(b)
if err != nil {
return fmt.Sprintf("ERR: %v", err)
}

return string(data)
}
116 changes: 116 additions & 0 deletions api/v1/beaconcommitteeselection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright © 2025 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 v1_test

import (
"encoding/json"
"testing"

api "github.com/attestantio/go-eth2-client/api/v1"
"github.com/stretchr/testify/assert"
require "github.com/stretchr/testify/require"
)

func TestBeaconCommitteeSelectionJSON(t *testing.T) {
tests := []struct {
name string
input []byte
err string
}{
{
name: "Empty",
err: "unexpected end of JSON input",
},
{
name: "JSONBad",
input: []byte("[]"),
err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.beaconCommitteeSelectionJSON",
},
{
name: "ValidatorIndexMissing",
input: []byte(`{"slot":"1","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "validator index missing",
},
{
name: "ValidatorIndexWrongType",
input: []byte(`{"validator_index":true,"slot":"1","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "invalid JSON: json: cannot unmarshal bool into Go struct field beaconCommitteeSelectionJSON.validator_index of type string",
},
{
name: "ValidatorIndexInvalid",
input: []byte(`{"validator_index":"invalid","slot":"1","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "invalid value for validator index: strconv.ParseUint: parsing \"invalid\": invalid syntax",
},
{
name: "SlotMissing",
input: []byte(`{"validator_index":"10","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "slot missing",
},
{
name: "SlotWrongType",
input: []byte(`{"validator_index":"10","slot":true,"selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "invalid JSON: json: cannot unmarshal bool into Go struct field beaconCommitteeSelectionJSON.slot of type string",
},
{
name: "SlotInvalid",
input: []byte(`{"validator_index":"10","slot":"-1","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax",
},
{
name: "SelectionProofMissing",
input: []byte(`{"validator_index":"10","slot":"1"}`),
err: "selection proof missing",
},
{
name: "SelectionProofWrongType",
input: []byte(`{"validator_index":"10","slot":"1","selection_proof":true}`),
err: "invalid JSON: json: cannot unmarshal bool into Go struct field beaconCommitteeSelectionJSON.selection_proof of type string",
},
{
name: "SelectionProofInvalid",
input: []byte(`{"validator_index":"10","slot":"1","selection_proof":"invalid"}`),
err: "invalid value for selection proof: encoding/hex: invalid byte: U+0069 'i'",
},
{
name: "SelectionProofShort",
input: []byte(`{"validator_index":"10","slot":"1","selection_proof":"0x5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "incorrect length for selection proof",
},
{
name: "SelectionProofLong",
input: []byte(`{"validator_index":"10","slot":"1","selection_proof":"0x8b8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
err: "incorrect length for selection proof",
},
{
name: "Good",
input: []byte(`{"validator_index":"10","slot":"1","selection_proof":"0x8b5f33a895612754103fbaaed74b408e89b948c69740d722b56207c272e001b2ddd445931e40a2938c84afab86c2606f0c1a93a0aaf4962c91d3ddf309de8ef0dbd68f590573e53e5ff7114e9625fae2cfee9e7eb991ad929d351c7701581d9c"}`),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var res api.BeaconCommitteeSelection
err := json.Unmarshal(test.input, &res)
if test.err != "" {
require.EqualError(t, err, test.err)
} else {
require.NoError(t, err)
rt, err := json.Marshal(&res)
require.NoError(t, err)
assert.Equal(t, string(test.input), string(rt))
assert.Equal(t, string(rt), res.String())
}
})
}
}
66 changes: 66 additions & 0 deletions http/beaconcommitteeselections.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright © 2025 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 http

import (
"bytes"
"context"
"encoding/json"
"errors"

"github.com/attestantio/go-eth2-client/api"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
)

// BeaconCommitteeSelections submits beacon committee selections.
func (s *Service) BeaconCommitteeSelections(ctx context.Context,
opts *api.BeaconCommitteeSelectionsOpts,
) (
*api.Response[[]*apiv1.BeaconCommitteeSelection],
error,
) {
if err := s.assertIsSynced(ctx); err != nil {
return nil, err
}

specJSON, err := json.Marshal(opts.Selections)
if err != nil {
return nil, errors.Join(errors.New("failed to encode beacon committee selections"), err)
}

endpoint := "/eth/v1/validator/beacon_committee_selections"
query := ""

httpResponse, err := s.post(ctx,
endpoint,
query,
&api.CommonOpts{},
bytes.NewReader(specJSON),
ContentTypeJSON,
map[string]string{},
)
if err != nil {
return nil, errors.Join(errors.New("failed to request beacon committee selections"), err)
}

data, metadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*apiv1.BeaconCommitteeSelection{})
if err != nil {
return nil, err
}

return &api.Response[[]*apiv1.BeaconCommitteeSelection]{
Metadata: metadata,
Data: data,
}, nil
}
1 change: 1 addition & 0 deletions http/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ func TestInterfaces(t *testing.T) {
assert.Implements(t, (*client.BeaconBlockRootProvider)(nil), s)
assert.Implements(t, (*client.BeaconBlockSubmitter)(nil), s)
assert.Implements(t, (*client.BeaconCommitteeSubscriptionsSubmitter)(nil), s)
assert.Implements(t, (*client.BeaconCommitteeSelectionsProvider)(nil), s)
assert.Implements(t, (*client.BeaconStateProvider)(nil), s)
assert.Implements(t, (*client.BeaconStateRandaoProvider)(nil), s)
assert.Implements(t, (*client.BeaconStateRootProvider)(nil), s)
Expand Down
33 changes: 33 additions & 0 deletions mock/beaconcommitteeselections.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright © 2025 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 mock

import (
"context"

"github.com/attestantio/go-eth2-client/api"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
)

// BeaconCommitteeSelections submits beacon committee selections.
func (*Service) BeaconCommitteeSelections(_ context.Context,
opts *api.BeaconCommitteeSelectionsOpts,
) (
*api.Response[[]*apiv1.BeaconCommitteeSelection], error,
) {
return &api.Response[[]*apiv1.BeaconCommitteeSelection]{
Data: opts.Selections,
Metadata: make(map[string]any),
}, nil
}
Loading