Optimized Slasher Docs and Helpers (#9578)

* bring over helpers

* slasher helpers pass tests

* fix dead link

* rem eth2

* gaz

* params

* gaz

* builds

Co-authored-by: Preston Van Loon <preston@prysmaticlabs.com>
This commit is contained in:
Raul Jordan
2021-09-24 13:38:13 -05:00
committed by GitHub
parent ea9ceeff03
commit 75936853af
4 changed files with 676 additions and 1 deletions

View File

@@ -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",
],
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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: &ethpb.IndexedAttestation{},
},
},
inputEpoch: 0,
wantedDropped: 1,
},
{
name: "Nil attestation source and target gets dropped",
input: []*slashertypes.IndexedAttestationWrapper{
{
IndexedAttestation: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{},
},
},
},
inputEpoch: 0,
wantedDropped: 1,
},
{
name: "Nil attestation source and good target gets dropped",
input: []*slashertypes.IndexedAttestationWrapper{
{
IndexedAttestation: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Target: &ethpb.Checkpoint{},
},
},
},
},
inputEpoch: 0,
wantedDropped: 1,
},
{
name: "Nil attestation target and good source gets dropped",
input: []*slashertypes.IndexedAttestationWrapper{
{
IndexedAttestation: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.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: &ethpb.AttesterSlashing{
Attestation_1: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation,
Attestation_2: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation,
},
},
{
name: "Surrounded vote",
slashing: &ethpb.AttesterSlashing{
Attestation_1: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation,
Attestation_2: createAttestationWrapper(t, 0, 0, nil, nil).IndexedAttestation,
},
},
{
name: "Double vote",
slashing: &ethpb.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: &ethpb.IndexedAttestation{},
want: false,
},
{
name: "Nil attestation source and target returns false",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{},
},
want: false,
},
{
name: "Nil attestation source and good target returns false",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Target: &ethpb.Checkpoint{},
},
},
want: false,
},
{
name: "Nil attestation target and good source returns false",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.Checkpoint{},
},
},
want: false,
},
{
name: "Source > target returns false",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.Checkpoint{
Epoch: 1,
},
Target: &ethpb.Checkpoint{
Epoch: 0,
},
},
},
want: false,
},
{
name: "Source == target returns false",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.Checkpoint{
Epoch: 1,
},
Target: &ethpb.Checkpoint{
Epoch: 1,
},
},
},
want: false,
},
{
name: "Source < target returns true",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.Checkpoint{
Epoch: 1,
},
Target: &ethpb.Checkpoint{
Epoch: 2,
},
},
},
want: true,
},
{
name: "Source 0 target 0 returns true (genesis epoch attestations)",
att: &ethpb.IndexedAttestation{
Data: &ethpb.AttestationData{
Source: &ethpb.Checkpoint{
Epoch: 0,
},
Target: &ethpb.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 := &ethpb.AttestationData{
BeaconBlockRoot: bytesutil.PadTo(signingRoot, 32),
Source: &ethpb.Checkpoint{
Epoch: source,
Root: params.BeaconConfig().ZeroHash[:],
},
Target: &ethpb.Checkpoint{
Epoch: target,
Root: params.BeaconConfig().ZeroHash[:],
},
}
signRoot, err := data.HashTreeRoot()
if err != nil {
t.Fatal(err)
}
return &slashertypes.IndexedAttestationWrapper{
IndexedAttestation: &ethpb.IndexedAttestation{
AttestingIndices: indices,
Data: data,
Signature: params.BeaconConfig().EmptySignature[:],
},
SigningRoot: signRoot,
}
}