From 91dc4eae3a90c8fda43ee72c325adc698263b5eb Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Tue, 20 Oct 2026 11:30:19 +1000 Subject: [PATCH] fix: attester slashing attestation versions and signed attestation rewards VersionedAttesterSlashing.Attestation1/2 labelled Altair and Bellatrix indexed attestations as Phase0 while filling the Altair/Bellatrix field, so every accessor on the result failed with "no Phase0 indexed attestation". The beacon API defines head, target, source and inactivity in both ideal and total attestation rewards as Int64. Inactivity penalties are negative during an inactivity leak (mainnet epoch 200759 returns inactivity -5722), which failed to decode as uint64. --- api/v1/attestationrewards.go | 44 +++----- api/v1/attestationrewards_test.go | 133 +++++++++++++++++++++++++ spec/versionedattesterslashing.go | 8 +- spec/versionedattesterslashing_test.go | 91 +++++++++++++++++ 4 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 api/v1/attestationrewards_test.go create mode 100644 spec/versionedattesterslashing_test.go 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) + }) + } +}