diff --git a/api/v1/attestationrewards.go b/api/v1/attestationrewards.go index 0e2a3cef..e92f94b3 100644 --- a/api/v1/attestationrewards.go +++ b/api/v1/attestationrewards.go @@ -29,13 +29,14 @@ type AttestationRewards struct { } // IdealAttestationRewards are the ideal attestation rewards for an attestation. +// Head, target, source and inactivity are signed Gwei values, as in the beacon API spec. type IdealAttestationRewards struct { EffectiveBalance phase0.Gwei - Head phase0.Gwei - Target phase0.Gwei - Source phase0.Gwei + Head int64 + Target int64 + Source int64 InclusionDelay *phase0.Gwei - Inactivity phase0.Gwei + Inactivity int64 } // idealAttestationRewardsJSON is the spec representation of the struct. @@ -89,35 +90,29 @@ func (i *IdealAttestationRewards) UnmarshalJSON(input []byte) error { return errors.New("head missing") } - head, err := strconv.ParseUint(data.Head, 10, 64) + i.Head, err = strconv.ParseInt(data.Head, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for head") } - i.Head = phase0.Gwei(head) - if data.Target == "" { return errors.New("target missing") } - target, err := strconv.ParseUint(data.Target, 10, 64) + i.Target, err = strconv.ParseInt(data.Target, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for target") } - i.Target = phase0.Gwei(target) - if data.Source == "" { return errors.New("source missing") } - source, err := strconv.ParseInt(data.Source, 10, 64) + i.Source, err = strconv.ParseInt(data.Source, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for source") } - i.Source = phase0.Gwei(source) - if data.InclusionDelay != "" { inclusionDelay, err := strconv.ParseUint(data.InclusionDelay, 10, 64) if err != nil { @@ -132,13 +127,11 @@ func (i *IdealAttestationRewards) UnmarshalJSON(input []byte) error { return errors.New("inactivity missing") } - inactivity, err := strconv.ParseUint(data.Inactivity, 10, 64) + i.Inactivity, err = strconv.ParseInt(data.Inactivity, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for inactivity") } - i.Inactivity = phase0.Gwei(inactivity) - return nil } @@ -152,16 +145,15 @@ func (i *IdealAttestationRewards) String() string { return string(data) } -// ValidatorAttestationRewards are the ideal attestation rewards for a validator. +// ValidatorAttestationRewards are the attestation rewards for a validator. +// Head, target, source and inactivity are signed Gwei values, as in the beacon API spec. type ValidatorAttestationRewards struct { ValidatorIndex phase0.ValidatorIndex - Head phase0.Gwei - // Target can be negative, so it is an int64 (but still a Gwei value). - Target int64 - // Source can be negative, so it is an int64 (but still a Gwei value). + Head int64 + Target int64 Source int64 InclusionDelay *phase0.Gwei - Inactivity phase0.Gwei + Inactivity int64 } // validatorAttestationRewardsJSON is the spec representation of the struct. @@ -215,13 +207,11 @@ func (v *ValidatorAttestationRewards) UnmarshalJSON(input []byte) error { return errors.New("head missing") } - head, err := strconv.ParseUint(data.Head, 10, 64) + v.Head, err = strconv.ParseInt(data.Head, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for head") } - v.Head = phase0.Gwei(head) - if data.Target == "" { return errors.New("target missing") } @@ -254,13 +244,11 @@ func (v *ValidatorAttestationRewards) UnmarshalJSON(input []byte) error { return errors.New("inactivity missing") } - inactivity, err := strconv.ParseUint(data.Inactivity, 10, 64) + v.Inactivity, err = strconv.ParseInt(data.Inactivity, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for inactivity") } - v.Inactivity = phase0.Gwei(inactivity) - return nil } diff --git a/api/v1/attestationrewards_test.go b/api/v1/attestationrewards_test.go new file mode 100644 index 00000000..2c42aae6 --- /dev/null +++ b/api/v1/attestationrewards_test.go @@ -0,0 +1,133 @@ +// 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 v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestValidatorAttestationRewardsJSON(t *testing.T) { + tests := []struct { + name string + input []byte + head int64 + target int64 + source int64 + inactivity int64 + err string + }{ + { + // Mainnet epoch 200759, during the May 2023 inactivity leak. + name: "InactivityLeakPenalty", + input: []byte(`{"validator_index":"9","head":"0","target":"-6162","source":"-3318","inactivity":"-5722"}`), + target: -6162, + source: -3318, + inactivity: -5722, + }, + { + name: "Rewarded", + input: []byte(`{"validator_index":"2","head":"1825","target":"5034","source":"2543","inactivity":"0"}`), + head: 1825, + target: 5034, + source: 2543, + }, + { + name: "HeadPenalty", + input: []byte(`{"validator_index":"2","head":"-1825","target":"5034","source":"2543","inactivity":"0"}`), + head: -1825, + target: 5034, + source: 2543, + }, + { + name: "InactivityInvalid", + input: []byte(`{"validator_index":"2","head":"0","target":"0","source":"0","inactivity":"x"}`), + err: "invalid value for inactivity: strconv.ParseInt: parsing \"x\": invalid syntax", + }, + { + name: "InactivityMissing", + input: []byte(`{"validator_index":"2","head":"0","target":"0","source":"0"}`), + err: "inactivity missing", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ValidatorAttestationRewards + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.head, res.Head) + assert.Equal(t, test.target, res.Target) + assert.Equal(t, test.source, res.Source) + assert.Equal(t, test.inactivity, res.Inactivity) + + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.JSONEq(t, string(test.input), string(rt)) + }) + } +} + +func TestIdealAttestationRewardsJSON(t *testing.T) { + tests := []struct { + name string + input []byte + inactivity int64 + err string + }{ + { + name: "Good", + input: []byte(`{"effective_balance":"32000000000","head":"1825","target":"5034","source":"2543","inactivity":"0"}`), + }, + { + name: "InactivityPenalty", + input: []byte(`{"effective_balance":"32000000000","head":"1825","target":"5034","source":"2543","inactivity":"-4768"}`), + inactivity: -4768, + }, + { + name: "EffectiveBalanceNegative", + input: []byte(`{"effective_balance":"-1","head":"1825","target":"5034","source":"2543","inactivity":"0"}`), + err: "invalid value for effective balance: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.IdealAttestationRewards + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.inactivity, res.Inactivity) + + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.JSONEq(t, string(test.input), string(rt)) + }) + } +} diff --git a/spec/versionedattesterslashing.go b/spec/versionedattesterslashing.go index 2a260c6a..705c6748 100644 --- a/spec/versionedattesterslashing.go +++ b/spec/versionedattesterslashing.go @@ -61,7 +61,7 @@ func (v *VersionedAttesterSlashing) Attestation1() (*VersionedIndexedAttestation } versionedIndexedAttestation := VersionedIndexedAttestation{ - Version: DataVersionPhase0, + Version: DataVersionAltair, Altair: v.Altair.Attestation1, } @@ -72,7 +72,7 @@ func (v *VersionedAttesterSlashing) Attestation1() (*VersionedIndexedAttestation } versionedIndexedAttestation := VersionedIndexedAttestation{ - Version: DataVersionPhase0, + Version: DataVersionBellatrix, Bellatrix: v.Bellatrix.Attestation1, } @@ -168,7 +168,7 @@ func (v *VersionedAttesterSlashing) Attestation2() (*VersionedIndexedAttestation } versionedIndexedAttestation := VersionedIndexedAttestation{ - Version: DataVersionPhase0, + Version: DataVersionAltair, Altair: v.Altair.Attestation2, } @@ -179,7 +179,7 @@ func (v *VersionedAttesterSlashing) Attestation2() (*VersionedIndexedAttestation } versionedIndexedAttestation := VersionedIndexedAttestation{ - Version: DataVersionPhase0, + Version: DataVersionBellatrix, Bellatrix: v.Bellatrix.Attestation2, } diff --git a/spec/versionedattesterslashing_test.go b/spec/versionedattesterslashing_test.go new file mode 100644 index 00000000..608bb390 --- /dev/null +++ b/spec/versionedattesterslashing_test.go @@ -0,0 +1,91 @@ +// 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 spec_test + +import ( + "testing" + + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionedAttesterSlashingAttestations(t *testing.T) { + data := func(slot phase0.Slot) *phase0.AttestationData { + return &phase0.AttestationData{ + Slot: slot, + Source: &phase0.Checkpoint{}, + Target: &phase0.Checkpoint{}, + } + } + + phase0Slashing := &phase0.AttesterSlashing{ + Attestation1: &phase0.IndexedAttestation{AttestingIndices: []uint64{1, 2}, Data: data(10)}, + Attestation2: &phase0.IndexedAttestation{AttestingIndices: []uint64{2, 3}, Data: data(11)}, + } + electraSlashing := &electra.AttesterSlashing{ + Attestation1: &electra.IndexedAttestation{AttestingIndices: []uint64{1, 2}, Data: data(10)}, + Attestation2: &electra.IndexedAttestation{AttestingIndices: []uint64{2, 3}, Data: data(11)}, + } + gloasSlashing := &gloas.AttesterSlashing{ + Attestation1: &gloas.IndexedAttestation{AttestingIndices: []uint64{1, 2}, Data: data(10)}, + Attestation2: &gloas.IndexedAttestation{AttestingIndices: []uint64{2, 3}, Data: data(11)}, + } + + tests := []spec.VersionedAttesterSlashing{ + {Version: spec.DataVersionPhase0, Phase0: phase0Slashing}, + {Version: spec.DataVersionAltair, Altair: phase0Slashing}, + {Version: spec.DataVersionBellatrix, Bellatrix: phase0Slashing}, + {Version: spec.DataVersionCapella, Capella: phase0Slashing}, + {Version: spec.DataVersionDeneb, Deneb: phase0Slashing}, + {Version: spec.DataVersionElectra, Electra: electraSlashing}, + {Version: spec.DataVersionFulu, Fulu: electraSlashing}, + {Version: spec.DataVersionGloas, Gloas: gloasSlashing}, + {Version: spec.DataVersionHeze, Heze: gloasSlashing}, + } + + for _, slashing := range tests { + t.Run(slashing.Version.String(), func(t *testing.T) { + att1, err := slashing.Attestation1() + require.NoError(t, err) + assert.Equal(t, slashing.Version, att1.Version) + + indices, err := att1.AttestingIndices() + require.NoError(t, err) + assert.Equal(t, []uint64{1, 2}, indices) + + attData, err := att1.Data() + require.NoError(t, err) + assert.Equal(t, phase0.Slot(10), attData.Slot) + + _, err = att1.Signature() + require.NoError(t, err) + + att2, err := slashing.Attestation2() + require.NoError(t, err) + assert.Equal(t, slashing.Version, att2.Version) + + indices, err = att2.AttestingIndices() + require.NoError(t, err) + assert.Equal(t, []uint64{2, 3}, indices) + + attData, err = att2.Data() + require.NoError(t, err) + assert.Equal(t, phase0.Slot(11), attData.Slot) + }) + } +}