diff --git a/spec/altair/syncaggregatorselectiondata.go b/spec/altair/syncaggregatorselectiondata.go index a0a8f5a6d..8e719cb62 100644 --- a/spec/altair/syncaggregatorselectiondata.go +++ b/spec/altair/syncaggregatorselectiondata.go @@ -14,7 +14,14 @@ package altair import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" ) // SyncAggregatorSelectionData is an internal struct for @@ -23,3 +30,92 @@ type SyncAggregatorSelectionData struct { Slot phase0.Slot SubcommitteeIndex uint64 } + +// syncAggregatorSelectionDataJSON is the spec representation of the struct. +type syncAggregatorSelectionDataJSON struct { + Slot string `json:"slot"` + SubcommitteeIndex string `json:"subcommittee_index"` +} + +// syncAggregatorSelectionDataYAML is the spec representation of the struct. +type syncAggregatorSelectionDataYAML struct { + Slot uint64 `yaml:"slot"` + SubcommitteeIndex uint64 `yaml:"subcommittee_index"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SyncAggregatorSelectionData) MarshalJSON() ([]byte, error) { + return json.Marshal(&syncAggregatorSelectionDataJSON{ + Slot: fmt.Sprintf("%d", s.Slot), + SubcommitteeIndex: fmt.Sprintf("%d", s.SubcommitteeIndex), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SyncAggregatorSelectionData) UnmarshalJSON(input []byte) error { + var syncAggregatorSelectionDataJSON syncAggregatorSelectionDataJSON + if err := json.Unmarshal(input, &syncAggregatorSelectionDataJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return s.unpack(&syncAggregatorSelectionDataJSON) +} + +func (s *SyncAggregatorSelectionData) unpack(syncAggregatorSelectionDataJSON *syncAggregatorSelectionDataJSON) error { + if syncAggregatorSelectionDataJSON.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(syncAggregatorSelectionDataJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + s.Slot = phase0.Slot(slot) + + if syncAggregatorSelectionDataJSON.SubcommitteeIndex == "" { + return errors.New("subcommittee index missing") + } + + subcommitteeIndex, err := strconv.ParseUint(syncAggregatorSelectionDataJSON.SubcommitteeIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for subcommittee index") + } + + s.SubcommitteeIndex = subcommitteeIndex + + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (s *SyncAggregatorSelectionData) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&syncAggregatorSelectionDataYAML{ + Slot: uint64(s.Slot), + SubcommitteeIndex: s.SubcommitteeIndex, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SyncAggregatorSelectionData) UnmarshalYAML(input []byte) error { + var syncAggregatorSelectionDataJSON syncAggregatorSelectionDataJSON + if err := yaml.Unmarshal(input, &syncAggregatorSelectionDataJSON); err != nil { + return err + } + + return s.unpack(&syncAggregatorSelectionDataJSON) +} + +// String returns a string version of the structure. +func (s *SyncAggregatorSelectionData) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/altair/syncaggregatorselectiondata_test.go b/spec/altair/syncaggregatorselectiondata_test.go new file mode 100644 index 000000000..e0249d7b3 --- /dev/null +++ b/spec/altair/syncaggregatorselectiondata_test.go @@ -0,0 +1,160 @@ +// 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 altair_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSyncAggregatorSelectionDataJSON(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 altair.syncAggregatorSelectionDataJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"subcommittee_index":"3"}`), + err: "slot missing", + }, + { + name: "SlotWrongType", + input: []byte(`{"slot":true,"subcommittee_index":"3"}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field syncAggregatorSelectionDataJSON.slot of type string", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","subcommittee_index":"3"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "SubcommitteeIndexMissing", + input: []byte(`{"slot":"1"}`), + err: "subcommittee index missing", + }, + { + name: "SubcommitteeIndexWrongType", + input: []byte(`{"slot":"1","subcommittee_index":true}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field syncAggregatorSelectionDataJSON.subcommittee_index of type string", + }, + { + name: "SubcommitteeIndexInvalid", + input: []byte(`{"slot":"1","subcommittee_index":"-1"}`), + err: "invalid value for subcommittee index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Valid", + input: []byte(`{"slot":"1","subcommittee_index":"3"}`), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res altair.SyncAggregatorSelectionData + 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)) + } + }) + } +} + +func TestSyncAggregatorSelectionDataYAML(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Good", + input: []byte(`{slot: 1, subcommittee_index: 3}`), + }, + { + name: "YAMLBad", + input: []byte("[]"), + err: "[1:1] sequence was used where mapping is expected\n> 1 | []\n ^\n", + }, + { + name: "SlotMissing", + input: []byte(`{"subcommittee_index":"3"}`), + err: "slot missing", + }, + { + name: "SlotWrongType", + input: []byte(`{"slot":true,"subcommittee_index":"3"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"true\": invalid syntax", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","subcommittee_index":"3"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "SubcommitteeIndexMissing", + input: []byte(`{"slot":"1"}`), + err: "subcommittee index missing", + }, + { + name: "SubcommitteeIndexWrongType", + input: []byte(`{"slot":"1","subcommittee_index":true}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field syncAggregatorSelectionDataJSON.subcommittee_index of type string", + }, + { + name: "SubcommitteeIndexInvalid", + input: []byte(`{"slot":"1","subcommittee_index":"-1"}`), + err: "invalid value for subcommittee index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Valid", + input: []byte(`{"slot":"1","subcommittee_index":"3"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res altair.SyncAggregatorSelectionData + err := yaml.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := yaml.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(rt), res.String()) + rt = bytes.TrimSuffix(rt, []byte("\n")) + assert.Equal(t, string(test.input), string(rt)) + } + }) + } +}