diff --git a/beacon-chain/slasher/BUILD.bazel b/beacon-chain/slasher/BUILD.bazel index 3d989fd88e..d75a7a478f 100644 --- a/beacon-chain/slasher/BUILD.bazel +++ b/beacon-chain/slasher/BUILD.bazel @@ -3,12 +3,18 @@ load("@prysm//tools/go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ + "doc.go", + "helpers.go", "params.go", "service.go", ], importpath = "github.com/prysmaticlabs/prysm/beacon-chain/slasher", visibility = ["//beacon-chain:__subpackages__"], deps = [ + "//beacon-chain/slasher/types:go_default_library", + "//config/params:go_default_library", + "//container/slice:go_default_library", + "//proto/prysm/v1alpha1:go_default_library", "@com_github_ferranbt_fastssz//:go_default_library", "@com_github_prysmaticlabs_eth2_types//:go_default_library", "@com_github_sirupsen_logrus//:go_default_library", @@ -17,11 +23,20 @@ go_library( go_test( name = "go_default_test", - srcs = ["params_test.go"], + srcs = [ + "helpers_test.go", + "params_test.go", + ], embed = [":go_default_library"], deps = [ + "//beacon-chain/slasher/types:go_default_library", + "//config/params:go_default_library", + "//encoding/bytesutil:go_default_library", + "//proto/prysm/v1alpha1:go_default_library", "//testing/assert:go_default_library", + "//testing/require:go_default_library", "@com_github_ferranbt_fastssz//:go_default_library", "@com_github_prysmaticlabs_eth2_types//:go_default_library", + "@com_github_sirupsen_logrus//hooks/test:go_default_library", ], ) diff --git a/beacon-chain/slasher/doc.go b/beacon-chain/slasher/doc.go new file mode 100644 index 0000000000..35f8604595 --- /dev/null +++ b/beacon-chain/slasher/doc.go @@ -0,0 +1,43 @@ +// Package slasher defines an optimized implementation of Ethereum proof-of-stake slashing +// detection, namely focused on catching "surround vote" slashable +// offenses as explained here: https://blog.ethereum.org/2020/01/13/validated-staking-on-eth2-1-incentives/. +// +// Surround vote detection is a difficult problem if done naively, as slasher +// needs to keep track of every single attestation by every single validator +// in the network and be ready to efficiently detect whether incoming attestations +// are slashable with respect to older ones. To do this, the Sigma Prime team +// created an elaborate design document: https://hackmd.io/@sproul/min-max-slasher +// offering an optimal solution. +// +// Attesting histories are kept for each validator in two separate arrays known +// as min and max spans, which are explained in our design document: +// https://hackmd.io/@prysmaticlabs/slasher. +// +// A regular pair of min and max spans for a validator look as follows +// with length = H where H is the amount of epochs worth of history +// we want to persist for slashing detection. +// +// validator_1_min_span = [2, 2, 2, ..., 2] +// validator_1_max_span = [0, 0, 0, ..., 0] +// +// Instead of always dealing with length H arrays, which can be prohibitively +// expensive to handle in memory, we split these arrays into chunks of length C. +// For C = 3, for example, the 0th chunk of validator 1's min and max spans would look +// as follows: +// +// validator_1_min_span_chunk_0 = [2, 2, 2] +// validator_1_max_span_chunk_0 = [2, 2, 2] +// +// Next, on disk, we take chunks for K validators, and store them as flat slices. +// For example, if H = 3, C = 3, and K = 3, then we can store 3 validators' chunks as a flat +// slice as follows: +// +// val0 val1 val2 +// | | | +// { } { } { } +// [2, 2, 2, 2, 2, 2, 2, 2, 2] +// +// This is known as 2D chunking, pioneered by the Sigma Prime team here: +// https://hackmd.io/@sproul/min-max-slasher. The parameters H, C, and K will be +// used extensively throughout this package. +package slasher diff --git a/beacon-chain/slasher/helpers.go b/beacon-chain/slasher/helpers.go new file mode 100644 index 0000000000..af3233fb21 --- /dev/null +++ b/beacon-chain/slasher/helpers.go @@ -0,0 +1,140 @@ +package slasher + +import ( + "strconv" + + types "github.com/prysmaticlabs/eth2-types" + slashertypes "github.com/prysmaticlabs/prysm/beacon-chain/slasher/types" + "github.com/prysmaticlabs/prysm/config/params" + "github.com/prysmaticlabs/prysm/container/slice" + ethpb "github.com/prysmaticlabs/prysm/proto/prysm/v1alpha1" + "github.com/sirupsen/logrus" +) + +// Group a list of attestations into batches by validator chunk index. +// This way, we can detect on the batch of attestations for each validator chunk index +// concurrently, and also allowing us to effectively use a single 2D chunk +// for slashing detection through this logical grouping. +func (s *Service) groupByValidatorChunkIndex( + attestations []*slashertypes.IndexedAttestationWrapper, +) map[uint64][]*slashertypes.IndexedAttestationWrapper { + groupedAttestations := make(map[uint64][]*slashertypes.IndexedAttestationWrapper) + for _, att := range attestations { + validatorChunkIndices := make(map[uint64]bool) + for _, validatorIdx := range att.IndexedAttestation.AttestingIndices { + validatorChunkIndex := s.params.validatorChunkIndex(types.ValidatorIndex(validatorIdx)) + validatorChunkIndices[validatorChunkIndex] = true + } + for validatorChunkIndex := range validatorChunkIndices { + groupedAttestations[validatorChunkIndex] = append( + groupedAttestations[validatorChunkIndex], + att, + ) + } + } + return groupedAttestations +} + +// Group attestations by the chunk index their source epoch corresponds to. +func (s *Service) groupByChunkIndex( + attestations []*slashertypes.IndexedAttestationWrapper, +) map[uint64][]*slashertypes.IndexedAttestationWrapper { + attestationsByChunkIndex := make(map[uint64][]*slashertypes.IndexedAttestationWrapper) + for _, att := range attestations { + chunkIdx := s.params.chunkIndex(att.IndexedAttestation.Data.Source.Epoch) + attestationsByChunkIndex[chunkIdx] = append(attestationsByChunkIndex[chunkIdx], att) + } + return attestationsByChunkIndex +} + +// This function returns a list of valid attestations, a list of attestations that are +// valid in the future, and the number of attestations dropped. +func (s *Service) filterAttestations( + atts []*slashertypes.IndexedAttestationWrapper, currentEpoch types.Epoch, +) (valid, validInFuture []*slashertypes.IndexedAttestationWrapper, numDropped int) { + valid = make([]*slashertypes.IndexedAttestationWrapper, 0, len(atts)) + validInFuture = make([]*slashertypes.IndexedAttestationWrapper, 0, len(atts)) + + for _, attWrapper := range atts { + if attWrapper == nil || !validateAttestationIntegrity(attWrapper.IndexedAttestation) { + numDropped++ + continue + } + + // If an attestation's source is epoch is older than the max history length + // we keep track of for slashing detection, we drop it. + if attWrapper.IndexedAttestation.Data.Source.Epoch+s.params.historyLength <= currentEpoch { + numDropped++ + continue + } + + // If an attestations's target epoch is in the future, we defer processing for later. + if attWrapper.IndexedAttestation.Data.Target.Epoch > currentEpoch { + validInFuture = append(validInFuture, attWrapper) + } else { + valid = append(valid, attWrapper) + } + } + return +} + +// Validates the attestation data integrity, ensuring we have no nil values for +// source, epoch, and that the source epoch of the attestation must be less than +// the target epoch, which is a precondition for performing slashing detection. +// This function also checks the attestation source epoch is within the history size +// we keep track of for slashing detection. +func validateAttestationIntegrity(att *ethpb.IndexedAttestation) bool { + // If an attestation is malformed, we drop it. + if att == nil || + att.Data == nil || + att.Data.Source == nil || + att.Data.Target == nil { + return false + } + + sourceEpoch := att.Data.Source.Epoch + targetEpoch := att.Data.Target.Epoch + + // The genesis epoch is a special case, since all attestations formed in it + // will have source and target 0, and they should be considered valid. + if sourceEpoch == 0 && targetEpoch == 0 { + return true + } + + // All valid attestations must have source epoch < target epoch. + return sourceEpoch < targetEpoch +} + +func logAttesterSlashing(slashing *ethpb.AttesterSlashing) { + indices := slice.IntersectionUint64(slashing.Attestation_1.AttestingIndices, slashing.Attestation_2.AttestingIndices) + log.WithFields(logrus.Fields{ + "validatorIndex": indices, + "prevSourceEpoch": slashing.Attestation_1.Data.Source.Epoch, + "prevTargetEpoch": slashing.Attestation_1.Data.Target.Epoch, + "sourceEpoch": slashing.Attestation_2.Data.Source.Epoch, + "targetEpoch": slashing.Attestation_2.Data.Target.Epoch, + }).Info("Attester slashing detected") +} + +func logProposerSlashing(slashing *ethpb.ProposerSlashing) { + log.WithFields(logrus.Fields{ + "validatorIndex": slashing.Header_1.Header.ProposerIndex, + "slot": slashing.Header_1.Header.Slot, + }).Info("Proposer slashing detected") +} + +// Turns a uint64 value to a string representation. +func uintToString(val uint64) string { + return strconv.FormatUint(val, 10) +} + +// If an existing signing root does not match an incoming proposal signing root, +// we then have a double block proposer slashing event. +func isDoubleProposal(incomingSigningRoot, existingSigningRoot [32]byte) bool { + // If the existing signing root is the zero hash, we do not consider + // this a double proposal. + if existingSigningRoot == params.BeaconConfig().ZeroHash { + return false + } + return incomingSigningRoot != existingSigningRoot +} diff --git a/beacon-chain/slasher/helpers_test.go b/beacon-chain/slasher/helpers_test.go new file mode 100644 index 0000000000..8c87f3740c --- /dev/null +++ b/beacon-chain/slasher/helpers_test.go @@ -0,0 +1,477 @@ +package slasher + +import ( + "reflect" + "testing" + + types "github.com/prysmaticlabs/eth2-types" + slashertypes "github.com/prysmaticlabs/prysm/beacon-chain/slasher/types" + "github.com/prysmaticlabs/prysm/config/params" + "github.com/prysmaticlabs/prysm/encoding/bytesutil" + ethpb "github.com/prysmaticlabs/prysm/proto/prysm/v1alpha1" + "github.com/prysmaticlabs/prysm/testing/require" + logTest "github.com/sirupsen/logrus/hooks/test" +) + +func TestService_groupByValidatorChunkIndex(t *testing.T) { + tests := []struct { + name string + params *Parameters + atts []*slashertypes.IndexedAttestationWrapper + want map[uint64][]*slashertypes.IndexedAttestationWrapper + }{ + { + name: "No attestations returns empty map", + params: DefaultParams(), + atts: make([]*slashertypes.IndexedAttestationWrapper, 0), + want: make(map[uint64][]*slashertypes.IndexedAttestationWrapper), + }, + { + name: "Groups multiple attestations belonging to single validator chunk", + params: &Parameters{ + validatorChunkSize: 2, + }, + atts: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, []uint64{0, 1}, nil), + createAttestationWrapper(t, 0, 0, []uint64{0, 1}, nil), + }, + want: map[uint64][]*slashertypes.IndexedAttestationWrapper{ + 0: { + createAttestationWrapper(t, 0, 0, []uint64{0, 1}, nil), + createAttestationWrapper(t, 0, 0, []uint64{0, 1}, nil), + }, + }, + }, + { + name: "Groups single attestation belonging to multiple validator chunk", + params: &Parameters{ + validatorChunkSize: 2, + }, + atts: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, []uint64{0, 2, 4}, nil), + }, + want: map[uint64][]*slashertypes.IndexedAttestationWrapper{ + 0: { + createAttestationWrapper(t, 0, 0, []uint64{0, 2, 4}, nil), + }, + 1: { + createAttestationWrapper(t, 0, 0, []uint64{0, 2, 4}, nil), + }, + 2: { + createAttestationWrapper(t, 0, 0, []uint64{0, 2, 4}, nil), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + params: tt.params, + } + if got := s.groupByValidatorChunkIndex(tt.atts); !reflect.DeepEqual(got, tt.want) { + t.Errorf("groupByValidatorChunkIndex() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestService_groupByChunkIndex(t *testing.T) { + tests := []struct { + name string + params *Parameters + atts []*slashertypes.IndexedAttestationWrapper + want map[uint64][]*slashertypes.IndexedAttestationWrapper + }{ + { + name: "No attestations returns empty map", + params: DefaultParams(), + atts: make([]*slashertypes.IndexedAttestationWrapper, 0), + want: make(map[uint64][]*slashertypes.IndexedAttestationWrapper), + }, + { + name: "Groups multiple attestations belonging to single chunk", + params: &Parameters{ + chunkSize: 2, + historyLength: 3, + }, + atts: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, nil, nil), + createAttestationWrapper(t, 1, 0, nil, nil), + }, + want: map[uint64][]*slashertypes.IndexedAttestationWrapper{ + 0: { + createAttestationWrapper(t, 0, 0, nil, nil), + createAttestationWrapper(t, 1, 0, nil, nil), + }, + }, + }, + { + name: "Groups multiple attestations belonging to multiple chunks", + params: &Parameters{ + chunkSize: 2, + historyLength: 3, + }, + atts: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, nil, nil), + createAttestationWrapper(t, 1, 0, nil, nil), + createAttestationWrapper(t, 2, 0, nil, nil), + }, + want: map[uint64][]*slashertypes.IndexedAttestationWrapper{ + 0: { + createAttestationWrapper(t, 0, 0, nil, nil), + createAttestationWrapper(t, 1, 0, nil, nil), + }, + 1: { + createAttestationWrapper(t, 2, 0, nil, nil), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + params: tt.params, + } + if got := s.groupByChunkIndex(tt.atts); !reflect.DeepEqual(got, tt.want) { + t.Errorf("groupByChunkIndex() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestService_filterAttestations(t *testing.T) { + tests := []struct { + name string + input []*slashertypes.IndexedAttestationWrapper + inputEpoch types.Epoch + wantedValid []*slashertypes.IndexedAttestationWrapper + wantedDeferred []*slashertypes.IndexedAttestationWrapper + wantedDropped int + }{ + { + name: "Nil attestation input gets dropped", + input: make([]*slashertypes.IndexedAttestationWrapper, 1), + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Nil attestation data gets dropped", + input: []*slashertypes.IndexedAttestationWrapper{ + { + IndexedAttestation: ðpb.IndexedAttestation{}, + }, + }, + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Nil attestation source and target gets dropped", + input: []*slashertypes.IndexedAttestationWrapper{ + { + IndexedAttestation: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{}, + }, + }, + }, + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Nil attestation source and good target gets dropped", + input: []*slashertypes.IndexedAttestationWrapper{ + { + IndexedAttestation: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Target: ðpb.Checkpoint{}, + }, + }, + }, + }, + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Nil attestation target and good source gets dropped", + input: []*slashertypes.IndexedAttestationWrapper{ + { + IndexedAttestation: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{}, + }, + }, + }, + }, + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Source > target gets dropped", + input: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 1, 0, []uint64{1}, make([]byte, 32)), + }, + inputEpoch: 0, + wantedDropped: 1, + }, + { + name: "Source < target is valid", + input: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 1, []uint64{1}, make([]byte, 32)), + }, + inputEpoch: 1, + wantedValid: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 1, []uint64{1}, make([]byte, 32)), + }, + wantedDropped: 0, + }, + { + name: "Source == target is valid", + input: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, []uint64{1}, make([]byte, 32)), + }, + inputEpoch: 1, + wantedValid: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 0, []uint64{1}, make([]byte, 32)), + }, + wantedDropped: 0, + }, + { + name: "Attestation from the future is deferred", + input: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 2, []uint64{1}, make([]byte, 32)), + }, + inputEpoch: 1, + wantedDeferred: []*slashertypes.IndexedAttestationWrapper{ + createAttestationWrapper(t, 0, 2, []uint64{1}, make([]byte, 32)), + }, + wantedDropped: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := &Service{ + params: DefaultParams(), + } + valid, deferred, numDropped := srv.filterAttestations(tt.input, tt.inputEpoch) + if len(tt.wantedValid) > 0 { + require.DeepEqual(t, tt.wantedValid, valid) + } + if len(tt.wantedDeferred) > 0 { + require.DeepEqual(t, tt.wantedDeferred, deferred) + } + require.DeepEqual(t, tt.wantedDropped, numDropped) + }) + } +} + +func Test_logSlashingEvent(t *testing.T) { + tests := []struct { + name string + slashing *ethpb.AttesterSlashing + }{ + { + name: "Surrounding vote", + slashing: ðpb.AttesterSlashing{ + Attestation_1: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + Attestation_2: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + }, + }, + { + name: "Surrounded vote", + slashing: ðpb.AttesterSlashing{ + Attestation_1: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + Attestation_2: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + }, + }, + { + name: "Double vote", + slashing: ðpb.AttesterSlashing{ + Attestation_1: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + Attestation_2: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hook := logTest.NewGlobal() + logAttesterSlashing(tt.slashing) + require.LogsContain(t, hook, "") + }) + } +} + +func Test_validateAttestationIntegrity(t *testing.T) { + tests := []struct { + name string + att *ethpb.IndexedAttestation + want bool + }{ + { + name: "Nil attestation returns false", + att: nil, + want: false, + }, + { + name: "Nil attestation data returns false", + att: ðpb.IndexedAttestation{}, + want: false, + }, + { + name: "Nil attestation source and target returns false", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{}, + }, + want: false, + }, + { + name: "Nil attestation source and good target returns false", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Target: ðpb.Checkpoint{}, + }, + }, + want: false, + }, + { + name: "Nil attestation target and good source returns false", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{}, + }, + }, + want: false, + }, + { + name: "Source > target returns false", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{ + Epoch: 1, + }, + Target: ðpb.Checkpoint{ + Epoch: 0, + }, + }, + }, + want: false, + }, + { + name: "Source == target returns false", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{ + Epoch: 1, + }, + Target: ðpb.Checkpoint{ + Epoch: 1, + }, + }, + }, + want: false, + }, + { + name: "Source < target returns true", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{ + Epoch: 1, + }, + Target: ðpb.Checkpoint{ + Epoch: 2, + }, + }, + }, + want: true, + }, + { + name: "Source 0 target 0 returns true (genesis epoch attestations)", + att: ðpb.IndexedAttestation{ + Data: ðpb.AttestationData{ + Source: ðpb.Checkpoint{ + Epoch: 0, + }, + Target: ðpb.Checkpoint{ + Epoch: 0, + }, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validateAttestationIntegrity(tt.att); got != tt.want { + t.Errorf("validateAttestationIntegrity() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_isDoubleProposal(t *testing.T) { + type args struct { + incomingSigningRoot [32]byte + existingSigningRoot [32]byte + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "Existing signing root empty returns false", + args: args{ + incomingSigningRoot: [32]byte{1}, + existingSigningRoot: params.BeaconConfig().ZeroHash, + }, + want: false, + }, + { + name: "Existing signing root non-empty and equal to incoming returns false", + args: args{ + incomingSigningRoot: [32]byte{1}, + existingSigningRoot: [32]byte{1}, + }, + want: false, + }, + { + name: "Existing signing root non-empty and not-equal to incoming returns true", + args: args{ + incomingSigningRoot: [32]byte{1}, + existingSigningRoot: [32]byte{2}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isDoubleProposal(tt.args.incomingSigningRoot, tt.args.existingSigningRoot); got != tt.want { + t.Errorf("isDoubleProposal() = %v, want %v", got, tt.want) + } + }) + } +} + +func createAttestationWrapper(t testing.TB, source, target types.Epoch, indices []uint64, signingRoot []byte) *slashertypes.IndexedAttestationWrapper { + data := ðpb.AttestationData{ + BeaconBlockRoot: bytesutil.PadTo(signingRoot, 32), + Source: ðpb.Checkpoint{ + Epoch: source, + Root: params.BeaconConfig().ZeroHash[:], + }, + Target: ðpb.Checkpoint{ + Epoch: target, + Root: params.BeaconConfig().ZeroHash[:], + }, + } + signRoot, err := data.HashTreeRoot() + if err != nil { + t.Fatal(err) + } + return &slashertypes.IndexedAttestationWrapper{ + IndexedAttestation: ðpb.IndexedAttestation{ + AttestingIndices: indices, + Data: data, + Signature: params.BeaconConfig().EmptySignature[:], + }, + SigningRoot: signRoot, + } +}