diff --git a/.golangci.yml b/.golangci.yml index 9356cb7d..b94397e5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -106,6 +106,10 @@ output: # All available settings of specific linters. linters-settings: + gosec: + excludes: + - G115 # This generates a lot of false positives. + lll: line-length: 132 @@ -162,6 +166,7 @@ linters: - execinquery - exhaustive - exhaustruct + - exportloopref - forcetypeassert - funlen - gci diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bb6fde..d4117082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +dev: + - add attester_slashing, block_gossip, bls_to_execution_change and proposer_slashing events + 0.21.10: - better validator state when balance not supplied diff --git a/api/attestationpoolopts.go b/api/attestationpoolopts.go index 57349fd0..9994e109 100644 --- a/api/attestationpoolopts.go +++ b/api/attestationpoolopts.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// Copyright © 2023, 2024 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 @@ -19,6 +19,11 @@ import "github.com/attestantio/go-eth2-client/spec/phase0" type AttestationPoolOpts struct { Common CommonOpts - // Slot is the slot for which the data is obtained. - Slot phase0.Slot + // Slot is the slot for which the data is obtained. If not present then + // data for all slots will be obtained. + Slot *phase0.Slot + + // CommmitteeIndex is the committee index for which the data is obtained. + // If not present then data for all committee indices will be obtained. + CommitteeIndex *phase0.CommitteeIndex } diff --git a/api/v1/blockgossipevent.go b/api/v1/blockgossipevent.go new file mode 100644 index 00000000..1b38ae7a --- /dev/null +++ b/api/v1/blockgossipevent.go @@ -0,0 +1,86 @@ +// Copyright © 2024 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" +) + +// BlockGossipEvent is the data for the block gossip event. +type BlockGossipEvent struct { + Slot phase0.Slot + Block phase0.Root +} + +// blockGossipEventJSON is the spec representation of the struct. +type blockGossipEventJSON struct { + Slot string `json:"slot"` + Block string `json:"block"` +} + +// MarshalJSON implements json.Marshaler. +func (e *BlockGossipEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&blockGossipEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + Block: fmt.Sprintf("%#x", e.Block), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *BlockGossipEvent) UnmarshalJSON(input []byte) error { + var err error + + var data blockGossipEventJSON + if err = json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + if data.Block == "" { + return errors.New("block missing") + } + block, err := hex.DecodeString(strings.TrimPrefix(data.Block, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for block") + } + if len(block) != rootLength { + return fmt.Errorf("incorrect length %d for block", len(block)) + } + copy(e.Block[:], block) + + return nil +} + +// String returns a string version of the structure. +func (e *BlockGossipEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/blockgossipevent_test.go b/api/v1/blockgossipevent_test.go new file mode 100644 index 00000000..b73fae73 --- /dev/null +++ b/api/v1/blockgossipevent_test.go @@ -0,0 +1,101 @@ +// Copyright © 2024 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 TestBlockGossipEventJSON(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.blockGossipEventJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "slot missing", + }, + { + name: "SlotWrongType", + input: []byte(`{"slot":true,"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field blockGossipEventJSON.slot of type string", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockMissing", + input: []byte(`{"slot":"525277"}`), + err: "block missing", + }, + { + name: "BlockWrongType", + input: []byte(`{"slot":"525277","block":true}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field blockGossipEventJSON.block of type string", + }, + { + name: "BlockInvalid", + input: []byte(`{"slot":"525277","block":"invalid"}`), + err: "invalid value for block: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockShort", + input: []byte(`{"slot":"525277","block":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 31 for block", + }, + { + name: "BlockLong", + input: []byte(`{"slot":"525277","block":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 33 for block", + }, + { + name: "Good", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.BlockGossipEvent + 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()) + } + }) + } +} diff --git a/api/v1/event.go b/api/v1/event.go index 8a991d39..977cdb49 100644 --- a/api/v1/event.go +++ b/api/v1/event.go @@ -18,6 +18,7 @@ import ( "fmt" "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -32,15 +33,19 @@ type Event struct { // SupportedEventTopics is a map of supported event topics. var SupportedEventTopics = map[string]bool{ - "attestation": true, - "block": true, - "chain_reorg": true, - "finalized_checkpoint": true, - "head": true, - "voluntary_exit": true, - "contribution_and_proof": true, - "payload_attributes": true, - "blob_sidecar": true, + "attestation": true, + "attester_slashing": true, + "blob_sidecar": true, + "block": true, + "block_gossip": true, + "bls_to_execution_change": true, + "chain_reorg": true, + "contribution_and_proof": true, + "finalized_checkpoint": true, + "head": true, + "payload_attributes": true, + "proposer_slashing": true, + "voluntary_exit": true, } // eventJSON is the spec representation of the struct. @@ -86,22 +91,30 @@ func (e *Event) UnmarshalJSON(input []byte) error { switch eventJSON.Topic { case "attestation": e.Data = &phase0.Attestation{} + case "attester_slashing": + e.Data = &phase0.AttesterSlashing{} + case "blob_sidecar": + e.Data = &BlobSidecarEvent{} case "block": e.Data = &BlockEvent{} + case "block_gossip": + e.Data = &BlockGossipEvent{} + case "bls_to_execution_change": + e.Data = &capella.SignedBLSToExecutionChange{} case "chain_reorg": e.Data = &ChainReorgEvent{} + case "contribution_and_proof": + e.Data = &altair.SignedContributionAndProof{} case "finalized_checkpoint": e.Data = &FinalizedCheckpointEvent{} case "head": e.Data = &HeadEvent{} - case "voluntary_exit": - e.Data = &phase0.SignedVoluntaryExit{} - case "contribution_and_proof": - e.Data = &altair.SignedContributionAndProof{} case "payload_attributes": e.Data = &PayloadAttributesEvent{} - case "blob_sidecar": - e.Data = &BlobSidecarEvent{} + case "proposer_slashing": + e.Data = &phase0.ProposerSlashing{} + case "voluntary_exit": + e.Data = &phase0.SignedVoluntaryExit{} default: return fmt.Errorf("unsupported event topic %s", eventJSON.Topic) } diff --git a/api/v1/forkchoice.go b/api/v1/forkchoice.go index d890b4a9..0de7e006 100644 --- a/api/v1/forkchoice.go +++ b/api/v1/forkchoice.go @@ -147,7 +147,7 @@ func (d *ForkChoiceNodeValidity) UnmarshalJSON(input []byte) error { // String returns a string representation of the ForkChoiceNodeValidity. func (d ForkChoiceNodeValidity) String() string { - if int(d) >= len(ForkChoiceNodeValidityStrings) { + if uint64(d) >= uint64(len(ForkChoiceNodeValidityStrings)) { return "unknown" } diff --git a/http/attestationpool.go b/http/attestationpool.go index 8ec46e90..d9d968ef 100644 --- a/http/attestationpool.go +++ b/http/attestationpool.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "strings" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" @@ -39,8 +40,14 @@ func (s *Service) AttestationPool(ctx context.Context, } endpoint := "/eth/v1/beacon/pool/attestations" - query := fmt.Sprintf("slot=%d", opts.Slot) - httpResponse, err := s.get(ctx, endpoint, query, &opts.Common, false) + queryItems := make([]string, 0) + if opts.Slot != nil { + queryItems = append(queryItems, fmt.Sprintf("slot=%d", *opts.Slot)) + } + if opts.CommitteeIndex != nil { + queryItems = append(queryItems, fmt.Sprintf("committee_index=%d", *opts.CommitteeIndex)) + } + httpResponse, err := s.get(ctx, endpoint, strings.Join(queryItems, "&"), &opts.Common, false) if err != nil { return nil, err } @@ -77,9 +84,12 @@ func (*Service) attestationPoolFromJSON(_ context.Context, func verifyAttestationPool(opts *api.AttestationPoolOpts, data []*phase0.Attestation) error { for _, datum := range data { - if datum.Data.Slot != opts.Slot { + if opts.Slot != nil && datum.Data.Slot != *opts.Slot { return errors.New("attestation data not for requested slot") } + if opts.CommitteeIndex != nil && datum.Data.Index != *opts.CommitteeIndex { + return errors.New("attestation data not for requested committee index") + } } return nil diff --git a/http/attestationpool_test.go b/http/attestationpool_test.go index 57d439dd..46eeb138 100644 --- a/http/attestationpool_test.go +++ b/http/attestationpool_test.go @@ -27,6 +27,11 @@ import ( "github.com/stretchr/testify/require" ) +func slotptr(slot uint64) *phase0.Slot { + res := phase0.Slot(slot) + return &res +} + func TestAttestationPool(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -57,14 +62,14 @@ func TestAttestationPool(t *testing.T) { { name: "Empty", opts: &api.AttestationPoolOpts{ - Slot: 1, + Slot: slotptr(1), }, expected: make([]*phase0.Attestation, 0), }, { name: "Current", opts: &api.AttestationPoolOpts{ - Slot: phase0.Slot(uint64(time.Since(genesisResponse.Data.GenesisTime).Seconds()) / uint64(slotDuration.Seconds())), + Slot: slotptr(uint64(time.Since(genesisResponse.Data.GenesisTime).Seconds()) / uint64(slotDuration.Seconds())), }, }, } diff --git a/http/events.go b/http/events.go index 589859f5..cb3f189d 100644 --- a/http/events.go +++ b/http/events.go @@ -117,114 +117,123 @@ func (*Service) handleEvent(ctx context.Context, msg *sse.Event, handler consens Topic: string(msg.Event), } switch string(msg.Event) { - case "head": - headEvent := &api.HeadEvent{} - err := json.Unmarshal(msg.Data, headEvent) + case "attestation": + data := &phase0.Attestation{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attestation") return } - event.Data = headEvent - case "block": - blockEvent := &api.BlockEvent{} - err := json.Unmarshal(msg.Data, blockEvent) + event.Data = data + case "attester_slashing": + data := &phase0.AttesterSlashing{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse block event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attester slashing event") return } - event.Data = blockEvent - case "attestation": - attestation := &phase0.Attestation{} - err := json.Unmarshal(msg.Data, attestation) + event.Data = data + case "blob_sidecar": + data := &api.BlobSidecarEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attestation") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse blob sidecar event") return } - event.Data = attestation - case "voluntary_exit": - voluntaryExit := &phase0.SignedVoluntaryExit{} - err := json.Unmarshal(msg.Data, voluntaryExit) + event.Data = data + case "block": + data := &api.BlockEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse voluntary exit") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse block event") return } - event.Data = voluntaryExit - case "finalized_checkpoint": - finalizedCheckpointEvent := &api.FinalizedCheckpointEvent{} - err := json.Unmarshal(msg.Data, finalizedCheckpointEvent) + event.Data = data + case "block_gossip": + data := &api.BlockGossipEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse finalized checkpoint event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse block gossip event") + + return + } + event.Data = data + case "bls_to_execution_change": + data := &capella.SignedBLSToExecutionChange{} + err := json.Unmarshal(msg.Data, data) + if err != nil { + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse bls to execution change event") return } - event.Data = finalizedCheckpointEvent + event.Data = data case "chain_reorg": - chainReorgEvent := &api.ChainReorgEvent{} - err := json.Unmarshal(msg.Data, chainReorgEvent) + data := &api.ChainReorgEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse chain reorg event") return } - event.Data = chainReorgEvent + event.Data = data case "contribution_and_proof": - contributionAndProofEvent := &altair.SignedContributionAndProof{} - err := json.Unmarshal(msg.Data, contributionAndProofEvent) + data := &altair.SignedContributionAndProof{} + err := json.Unmarshal(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse contribution and proof event") return } - event.Data = contributionAndProofEvent - case "payload_attributes": - payloadAttributesEvent := &api.PayloadAttributesEvent{} - err := json.Unmarshal(msg.Data, payloadAttributesEvent) + event.Data = data + case "finalized_checkpoint": + data := &api.FinalizedCheckpointEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse payload attributes event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse finalized checkpoint event") return } - event.Data = payloadAttributesEvent - case "proposer_slashing": - proposerSlashingEvent := &phase0.ProposerSlashing{} - err := json.Unmarshal(msg.Data, proposerSlashingEvent) + event.Data = data + case "head": + data := &api.HeadEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse proposer slashing event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head event") return } - event.Data = proposerSlashingEvent - case "attester_slashing": - attesterSlashingEvent := &phase0.AttesterSlashing{} - err := json.Unmarshal(msg.Data, attesterSlashingEvent) + event.Data = data + case "payload_attributes": + data := &api.PayloadAttributesEvent{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attester slashing event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse payload attributes event") return } - event.Data = attesterSlashingEvent - case "bls_to_execution_change": - blsToExecutionChangeEvent := &capella.BLSToExecutionChange{} - err := json.Unmarshal(msg.Data, blsToExecutionChangeEvent) + event.Data = data + case "proposer_slashing": + data := &phase0.ProposerSlashing{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse bls to execution change event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse proposer slashing event") return } - event.Data = blsToExecutionChangeEvent - case "blob_sidecar": - blobSidecar := &api.BlobSidecarEvent{} - err := json.Unmarshal(msg.Data, blobSidecar) + event.Data = data + case "voluntary_exit": + data := &phase0.SignedVoluntaryExit{} + err := json.Unmarshal(msg.Data, data) if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse blob sidecar event") + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse voluntary exit") return } - event.Data = blobSidecar + event.Data = data case "": // Used as keepalive. Ignore. return diff --git a/http/events_internal_test.go b/http/events_internal_test.go index 683460ba..f00e7c95 100644 --- a/http/events_internal_test.go +++ b/http/events_internal_test.go @@ -147,7 +147,7 @@ func TestEventHandler(t *testing.T) { name: "BLSToExecutionChangeGood", message: &sse.Event{ Event: []byte("bls_to_execution_change"), - Data: []byte(`{"validator_index":"63401","from_bls_pubkey":"0xa46ed2574770ec1942d577ef89e0bf7b0d601349dab791740dead3fb5a6e2624cf62b9e58de1074c49f44b986eb39002","to_execution_address":"0xd641D2Cc74C7b6A641861260d07D67eB67bc7403"}`), + Data: []byte(`{"message":{"validator_index":"63401","from_bls_pubkey":"0xa46ed2574770ec1942d577ef89e0bf7b0d601349dab791740dead3fb5a6e2624cf62b9e58de1074c49f44b986eb39002","to_execution_address":"0xd641D2Cc74C7b6A641861260d07D67eB67bc7403"},"signature":"0xb9ce6f10137a8bc73cd5545e5d6c7c61bc1294b853e71e5eb090a7a773d7773c4d7732f4244e9891cd5c1f7a5fbc951c0b368a8bd7dc44b1598a6d1bdb4476f9f364ef521c366e55565792810f0cb8bf8cc20134f32ed8dde54507f622402d6e"}`), }, handler: handler, handled: true, diff --git a/http/http.go b/http/http.go index 109fb801..9a899e2a 100644 --- a/http/http.go +++ b/http/http.go @@ -34,7 +34,7 @@ import ( ) // defaultUserAgent is sent with requests if no other user agent has been supplied. -const defaultUserAgent = "go-eth2-client/0.21.10" +const defaultUserAgent = "go-eth2-client/0.21.11" // post sends an HTTP post request and returns the body. func (s *Service) post(ctx context.Context, endpoint string, body io.Reader) (io.Reader, error) { diff --git a/http/spec.go b/http/spec.go index e3162068..a2a2f5cd 100644 --- a/http/spec.go +++ b/http/spec.go @@ -121,8 +121,8 @@ func (s *Service) Spec(ctx context.Context, // Handle durations. if strings.HasPrefix(k, "SECONDS_PER_") || k == "GENESIS_DELAY" { - intVal, err := strconv.ParseUint(v, 10, 64) - if err == nil && intVal != 0 { + intVal, err := strconv.ParseInt(v, 10, 64) + if err == nil && intVal >= 0 { config[k] = time.Duration(intVal) * time.Second continue diff --git a/spec/builderversion.go b/spec/builderversion.go index c6871432..17a6cc41 100644 --- a/spec/builderversion.go +++ b/spec/builderversion.go @@ -50,7 +50,7 @@ func (d *BuilderVersion) UnmarshalJSON(input []byte) error { // String returns a string representation of the struct. func (d BuilderVersion) String() string { - if int(d) >= len(responseBuilderVersionStrings) { + if uint64(d) >= uint64(len(responseBuilderVersionStrings)) { return "unknown" } diff --git a/spec/dataversion.go b/spec/dataversion.go index 0d3fe4e1..f690b618 100644 --- a/spec/dataversion.go +++ b/spec/dataversion.go @@ -78,7 +78,7 @@ func (d *DataVersion) UnmarshalJSON(input []byte) error { // String returns a string representation of the struct. func (d DataVersion) String() string { - if int(d) >= len(dataVersionStrings) { + if uint64(d) >= uint64(len(dataVersionStrings)) { return "unknown" }