From 3d49e091e575133367fdb555297eadd11f6f4b88 Mon Sep 17 00:00:00 2001 From: Jim McDonald Date: Sun, 6 Mar 2022 22:47:37 +0000 Subject: [PATCH] Add block analyze. --- CHANGELOG.md | 1 + cmd/block/analyze/command.go | 136 +++++++ cmd/block/analyze/command_internal_test.go | 82 ++++ cmd/block/analyze/output.go | 157 ++++++++ cmd/block/analyze/process.go | 431 +++++++++++++++++++++ cmd/block/analyze/process_internal_test.go | 63 +++ cmd/block/analyze/run.go | 50 +++ cmd/blockanalyze.go | 65 ++++ cmd/blockinfo.go | 2 +- cmd/root.go | 2 + docs/usage.md | 26 +- 11 files changed, 1012 insertions(+), 3 deletions(-) create mode 100644 cmd/block/analyze/command.go create mode 100644 cmd/block/analyze/command_internal_test.go create mode 100644 cmd/block/analyze/output.go create mode 100644 cmd/block/analyze/process.go create mode 100644 cmd/block/analyze/process_internal_test.go create mode 100644 cmd/block/analyze/run.go create mode 100644 cmd/blockanalyze.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8cfc3..b18735c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ dev: - add "-ssz" option to "block info" + - add "block analyze" command 1.17.0: - add sync committee information to "chain time" diff --git a/cmd/block/analyze/command.go b/cmd/block/analyze/command.go new file mode 100644 index 0000000..ac56d0c --- /dev/null +++ b/cmd/block/analyze/command.go @@ -0,0 +1,136 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "context" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/prysmaticlabs/go-bitfield" + "github.com/spf13/viper" + "github.com/wealdtech/ethdo/services/chaintime" +) + +type command struct { + quiet bool + verbose bool + debug bool + + // Beacon node connection. + timeout time.Duration + connection string + allowInsecureConnections bool + + // Operation. + blockID string + stream bool + jsonOutput bool + + // Data access. + eth2Client eth2client.Service + chainTime chaintime.Service + blocksProvider eth2client.SignedBeaconBlockProvider + blockHeadersProvider eth2client.BeaconBlockHeadersProvider + + // Constants. + timelySourceWeight uint64 + timelyTargetWeight uint64 + timelyHeadWeight uint64 + syncRewardWeight uint64 + proposerWeight uint64 + weightDenominator uint64 + + // Processing. + priorAttestations map[string]*attestationData + // Head roots provides the root of the head slot at given slots. + headRoots map[phase0.Slot]phase0.Root + // Target roots provides the root of the target epoch at given slots. + targetRoots map[phase0.Slot]phase0.Root + + // Block info. + // Map is slot -> committee index -> validator committee index -> votes. + votes map[phase0.Slot]map[phase0.CommitteeIndex]bitfield.Bitlist + + // Results. + analysis *blockAnalysis +} + +type blockAnalysis struct { + Slot phase0.Slot `json:"slot"` + Attestations []*attestationAnalysis `json:"attestations"` + SyncCommitee *syncCommitteeAnalysis `json:"sync_committee"` + Value float64 `json:"value"` +} + +type attestationAnalysis struct { + Head phase0.Root `json:"head"` + Target phase0.Root `json:"target"` + Distance int `json:"distance"` + Duplicate *attestationData `json:"duplicate,omitempty"` + NewVotes int `json:"new_votes"` + Votes int `json:"votes"` + PossibleVotes int `json:"possible_votes"` + HeadCorrect bool `json:"head_correct"` + HeadTimely bool `json:"head_timely"` + SourceTimely bool `json:"source_timely"` + TargetCorrect bool `json:"target_correct"` + TargetTimely bool `json:"target_timely"` + Score float64 `json:"score"` + Value float64 `json:"value"` +} + +type syncCommitteeAnalysis struct { + Contributions int `json:"contributions"` + PossibleContributions int `json:"possible_contributions"` + Score float64 `json:"score"` + Value float64 `json:"value"` +} + +type attestationData struct { + Block phase0.Slot `json:"block"` + Index int `json:"index"` +} + +func newCommand(ctx context.Context) (*command, error) { + c := &command{ + quiet: viper.GetBool("quiet"), + verbose: viper.GetBool("verbose"), + debug: viper.GetBool("debug"), + priorAttestations: make(map[string]*attestationData), + headRoots: make(map[phase0.Slot]phase0.Root), + targetRoots: make(map[phase0.Slot]phase0.Root), + votes: make(map[phase0.Slot]map[phase0.CommitteeIndex]bitfield.Bitlist), + } + + // Timeout. + if viper.GetDuration("timeout") == 0 { + return nil, errors.New("timeout is required") + } + c.timeout = viper.GetDuration("timeout") + + if viper.GetString("connection") == "" { + return nil, errors.New("connection is required") + } + c.connection = viper.GetString("connection") + c.allowInsecureConnections = viper.GetBool("allow-insecure-connections") + + c.blockID = viper.GetString("blockid") + c.stream = viper.GetBool("stream") + c.jsonOutput = viper.GetBool("json") + + return c, nil +} diff --git a/cmd/block/analyze/command_internal_test.go b/cmd/block/analyze/command_internal_test.go new file mode 100644 index 0000000..89927d1 --- /dev/null +++ b/cmd/block/analyze/command_internal_test.go @@ -0,0 +1,82 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "context" + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestInput(t *testing.T) { + if os.Getenv("ETHDO_TEST_CONNECTION") == "" { + t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests") + } + + tests := []struct { + name string + vars map[string]interface{} + err string + }{ + { + name: "TimeoutMissing", + vars: map[string]interface{}{}, + err: "timeout is required", + }, + { + name: "ConnectionMissing", + vars: map[string]interface{}{ + "validators": "1", + "timeout": "5s", + }, + err: "connection is required", + }, + { + name: "ValidatorsZero", + vars: map[string]interface{}{ + "timeout": "5s", + "validators": "0", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + }, + err: "validators must be at least 1", + }, + { + name: "Good", + vars: map[string]interface{}{ + "validators": "1", + "timeout": "5s", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + viper.Reset() + + for k, v := range test.vars { + viper.Set(k, v) + } + _, err := newCommand(context.Background()) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/block/analyze/output.go b/cmd/block/analyze/output.go new file mode 100644 index 0000000..3be9864 --- /dev/null +++ b/cmd/block/analyze/output.go @@ -0,0 +1,157 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +func (c *command) output(ctx context.Context) (string, error) { + if c.quiet { + return "", nil + } + + if c.jsonOutput { + return c.outputJSON(ctx) + } + + return c.outputTxt(ctx) +} + +type attestationAnalysisJSON struct { + Head string `json:"head"` + Target string `json:"target"` + Distance int `json:"distance"` + Duplicate *attestationData `json:"duplicate,omitempty"` + NewVotes int `json:"new_votes"` + Votes int `json:"votes"` + PossibleVotes int `json:"possible_votes"` + HeadCorrect bool `json:"head_correct"` + HeadTimely bool `json:"head_timely"` + SourceTimely bool `json:"source_timely"` + TargetCorrect bool `json:"target_correct"` + TargetTimely bool `json:"target_timely"` + Score float64 `json:"score"` + Value float64 `json:"value"` +} + +func (a *attestationAnalysis) MarshalJSON() ([]byte, error) { + return json.Marshal(attestationAnalysisJSON{ + Head: fmt.Sprintf("%#x", a.Head), + Target: fmt.Sprintf("%#x", a.Target), + Distance: a.Distance, + Duplicate: a.Duplicate, + NewVotes: a.NewVotes, + Votes: a.Votes, + PossibleVotes: a.PossibleVotes, + HeadCorrect: a.HeadCorrect, + HeadTimely: a.HeadTimely, + SourceTimely: a.SourceTimely, + TargetCorrect: a.TargetCorrect, + TargetTimely: a.TargetTimely, + Score: a.Score, + Value: a.Value, + }) +} + +func (c *command) outputJSON(_ context.Context) (string, error) { + data, err := json.Marshal(c.analysis) + if err != nil { + return "", err + } + return string(data), nil +} + +func (c *command) outputTxt(_ context.Context) (string, error) { + builder := strings.Builder{} + + for i, attestation := range c.analysis.Attestations { + if c.verbose { + builder.WriteString("Attestation ") + builder.WriteString(fmt.Sprintf("%d", i)) + builder.WriteString(": ") + builder.WriteString("distance ") + builder.WriteString(fmt.Sprintf("%d", attestation.Distance)) + builder.WriteString(", ") + + if attestation.Duplicate != nil { + builder.WriteString("duplicate of attestation ") + builder.WriteString(fmt.Sprintf("%d", attestation.Duplicate.Index)) + builder.WriteString(" in block ") + builder.WriteString(fmt.Sprintf("%d", attestation.Duplicate.Block)) + builder.WriteString("\n") + continue + } + + builder.WriteString(fmt.Sprintf("%d", attestation.NewVotes)) + builder.WriteString("/") + builder.WriteString(fmt.Sprintf("%d", attestation.Votes)) + builder.WriteString("/") + builder.WriteString(fmt.Sprintf("%d", attestation.PossibleVotes)) + builder.WriteString(" new/total/possible votes") + if attestation.NewVotes == 0 { + builder.WriteString("\n") + continue + } else { + builder.WriteString(", ") + } + switch { + case !attestation.HeadCorrect: + builder.WriteString("head vote incorrect, ") + case !attestation.HeadTimely: + builder.WriteString("head vote correct but late, ") + } + + if !attestation.SourceTimely { + builder.WriteString("source vote late, ") + } + + switch { + case !attestation.TargetCorrect: + builder.WriteString("target vote incorrect, ") + case !attestation.TargetTimely: + builder.WriteString("target vote correct but late, ") + } + + builder.WriteString("score ") + builder.WriteString(fmt.Sprintf("%0.3f", attestation.Score)) + builder.WriteString(", value ") + builder.WriteString(fmt.Sprintf("%0.3f", attestation.Value)) + builder.WriteString("\n") + } + } + + if c.analysis.SyncCommitee.Contributions > 0 { + if c.verbose { + builder.WriteString("Sync committee contributions: ") + builder.WriteString(fmt.Sprintf("%d", c.analysis.SyncCommitee.Contributions)) + builder.WriteString(" contributions, score ") + builder.WriteString(fmt.Sprintf("%0.3f", c.analysis.SyncCommitee.Score)) + builder.WriteString(", value ") + builder.WriteString(fmt.Sprintf("%0.3f", c.analysis.SyncCommitee.Value)) + builder.WriteString("\n") + } + } + + builder.WriteString("Value for block ") + builder.WriteString(fmt.Sprintf("%d", c.analysis.Slot)) + builder.WriteString(": ") + builder.WriteString(fmt.Sprintf("%0.3f", c.analysis.Value)) + builder.WriteString("\n") + + return builder.String(), nil +} diff --git a/cmd/block/analyze/process.go b/cmd/block/analyze/process.go new file mode 100644 index 0000000..fd307d3 --- /dev/null +++ b/cmd/block/analyze/process.go @@ -0,0 +1,431 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "bytes" + "context" + "fmt" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/prysmaticlabs/go-bitfield" + standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard" + "github.com/wealdtech/ethdo/util" +) + +func (c *command) process(ctx context.Context) error { + // Obtain information we need to process. + if err := c.setup(ctx); err != nil { + return err + } + + block, err := c.blocksProvider.SignedBeaconBlock(ctx, c.blockID) + if err != nil { + return errors.Wrap(err, "failed to obtain beacon block") + } + if block == nil { + return errors.New("empty beacon block") + } + + slot, err := block.Slot() + if err != nil { + return err + } + attestations, err := block.Attestations() + if err != nil { + return err + } + + c.analysis = &blockAnalysis{ + Slot: slot, + } + + // Calculate how many parents we need to fetch. + minSlot := slot + for _, attestation := range attestations { + if attestation.Data.Slot < minSlot { + minSlot = attestation.Data.Slot + } + } + if c.debug { + fmt.Printf("Need to fetch blocks to slot %d\n", minSlot) + } + + if err := c.fetchParents(ctx, block, minSlot); err != nil { + return err + } + + return c.analyze(ctx, block) +} + +func (c *command) analyze(ctx context.Context, block *spec.VersionedSignedBeaconBlock) error { + if err := c.analyzeAttestations(ctx, block); err != nil { + return err + } + + if err := c.analyzeSyncCommittees(ctx, block); err != nil { + return err + } + + return nil +} + +func (c *command) analyzeAttestations(ctx context.Context, block *spec.VersionedSignedBeaconBlock) error { + attestations, err := block.Attestations() + if err != nil { + return err + } + slot, err := block.Slot() + if err != nil { + return err + } + + c.analysis.Attestations = make([]*attestationAnalysis, len(attestations)) + + blockVotes := make(map[phase0.Slot]map[phase0.CommitteeIndex]bitfield.Bitlist) + for i, attestation := range attestations { + if c.debug { + fmt.Printf("Processing attestation %d\n", i) + } + analysis := &attestationAnalysis{ + Head: attestation.Data.BeaconBlockRoot, + Target: attestation.Data.Target.Root, + Distance: int(slot - attestation.Data.Slot), + } + + root, err := attestation.HashTreeRoot() + if err != nil { + return err + } + if info, exists := c.priorAttestations[fmt.Sprintf("%#x", root)]; exists { + analysis.Duplicate = info + } else { + data := attestation.Data + _, exists := blockVotes[data.Slot] + if !exists { + blockVotes[data.Slot] = make(map[phase0.CommitteeIndex]bitfield.Bitlist) + } + _, exists = blockVotes[data.Slot][data.Index] + if !exists { + blockVotes[data.Slot][data.Index] = bitfield.NewBitlist(attestation.AggregationBits.Len()) + } + + // Count new votes. + analysis.PossibleVotes = int(attestation.AggregationBits.Len()) + for j := uint64(0); j < attestation.AggregationBits.Len(); j++ { + if attestation.AggregationBits.BitAt(j) { + analysis.Votes++ + if blockVotes[data.Slot][data.Index].BitAt(j) { + // Already attested to in this block; skip. + continue + } + if c.votes[data.Slot][data.Index].BitAt(j) { + // Already attested to in a previous block; skip. + continue + } + analysis.NewVotes++ + blockVotes[data.Slot][data.Index].SetBitAt(j, true) + } + } + // Calculate head correct. + var err error + analysis.HeadCorrect, err = c.calcHeadCorrect(ctx, attestation) + if err != nil { + return err + } + + // Calculate head timely. + analysis.HeadTimely = attestation.Data.Slot == slot-1 + + // Calculate source timely. + analysis.SourceTimely = attestation.Data.Slot >= slot-5 + + // Calculate target correct. + analysis.TargetCorrect, err = c.calcTargetCorrect(ctx, attestation) + if err != nil { + return err + } + + // Calculate target timely. + analysis.TargetTimely = attestation.Data.Slot >= slot-32 + } + + // Calculate score and value. + if analysis.TargetCorrect && analysis.TargetTimely { + analysis.Score += float64(c.timelyTargetWeight) / float64(c.weightDenominator) + } + if analysis.SourceTimely { + analysis.Score += float64(c.timelySourceWeight) / float64(c.weightDenominator) + } + if analysis.HeadCorrect && analysis.HeadTimely { + analysis.Score += float64(c.timelyHeadWeight) / float64(c.weightDenominator) + } + analysis.Value = analysis.Score * float64(analysis.NewVotes) + c.analysis.Value += analysis.Value + + c.analysis.Attestations[i] = analysis + } + + return nil +} + +func (c *command) fetchParents(ctx context.Context, block *spec.VersionedSignedBeaconBlock, minSlot phase0.Slot) error { + parentRoot, err := block.ParentRoot() + if err != nil { + return err + } + + // Obtain the parent block. + parentBlock, err := c.blocksProvider.SignedBeaconBlock(ctx, fmt.Sprintf("%#x", parentRoot)) + if err != nil { + return err + } + if parentBlock == nil { + return fmt.Errorf("unable to obtain parent block %s", parentBlock) + } + + parentSlot, err := parentBlock.Slot() + if err != nil { + return err + } + if parentSlot < minSlot { + return nil + } + + if err := c.processParentBlock(ctx, parentBlock); err != nil { + return err + } + + return c.fetchParents(ctx, parentBlock, minSlot) +} + +func (c *command) processParentBlock(ctx context.Context, block *spec.VersionedSignedBeaconBlock) error { + attestations, err := block.Attestations() + if err != nil { + return err + } + slot, err := block.Slot() + if err != nil { + return err + } + if c.debug { + fmt.Printf("Processing block %d\n", slot) + } + + for i, attestation := range attestations { + root, err := attestation.HashTreeRoot() + if err != nil { + return err + } + c.priorAttestations[fmt.Sprintf("%#x", root)] = &attestationData{ + Block: slot, + Index: i, + } + + data := attestation.Data + _, exists := c.votes[data.Slot] + if !exists { + c.votes[data.Slot] = make(map[phase0.CommitteeIndex]bitfield.Bitlist) + } + _, exists = c.votes[data.Slot][data.Index] + if !exists { + c.votes[data.Slot][data.Index] = bitfield.NewBitlist(attestation.AggregationBits.Len()) + } + for j := uint64(0); j < attestation.AggregationBits.Len(); j++ { + if attestation.AggregationBits.BitAt(j) { + c.votes[data.Slot][data.Index].SetBitAt(j, true) + } + } + } + + return nil +} + +func (c *command) setup(ctx context.Context) error { + var err error + + // Connect to the client. + c.eth2Client, err = util.ConnectToBeaconNode(ctx, c.connection, c.timeout, c.allowInsecureConnections) + if err != nil { + return errors.Wrap(err, "failed to connect to beacon node") + } + + c.chainTime, err = standardchaintime.New(ctx, + standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), + standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), + standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), + ) + if err != nil { + return errors.Wrap(err, "failed to set up chaintime service") + } + + // Obtain the number of active validators. + var isProvider bool + c.blocksProvider, isProvider = c.eth2Client.(eth2client.SignedBeaconBlockProvider) + if !isProvider { + return errors.New("connection does not provide signed beacon block information") + } + c.blockHeadersProvider, isProvider = c.eth2Client.(eth2client.BeaconBlockHeadersProvider) + if !isProvider { + return errors.New("connection does not provide beacon block header information") + } + + specProvider, isProvider := c.eth2Client.(eth2client.SpecProvider) + if !isProvider { + return errors.New("connection does not provide spec information") + } + + spec, err := specProvider.Spec(ctx) + if err != nil { + return errors.Wrap(err, "failed to obtain spec") + } + + tmp, exists := spec["TIMELY_SOURCE_WEIGHT"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(14) + } + var ok bool + c.timelySourceWeight, ok = tmp.(uint64) + if !ok { + return errors.New("TIMELY_SOURCE_WEIGHT of unexpected type") + } + + tmp, exists = spec["TIMELY_TARGET_WEIGHT"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(26) + } + c.timelyTargetWeight, ok = tmp.(uint64) + if !ok { + return errors.New("TIMELY_TARGET_WEIGHT of unexpected type") + } + + tmp, exists = spec["TIMELY_HEAD_WEIGHT"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(14) + } + c.timelyHeadWeight, ok = tmp.(uint64) + if !ok { + return errors.New("TIMELY_HEAD_WEIGHT of unexpected type") + } + + tmp, exists = spec["SYNC_REWARD_WEIGHT"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(2) + } + c.syncRewardWeight, ok = tmp.(uint64) + if !ok { + return errors.New("SYNC_REWARD_WEIGHT of unexpected type") + } + + tmp, exists = spec["PROPOSER_WEIGHT"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(8) + } + c.proposerWeight, ok = tmp.(uint64) + if !ok { + return errors.New("PROPOSER_WEIGHT of unexpected type") + } + + tmp, exists = spec["WEIGHT_DENOMINATOR"] + if !exists { + // Set a default value based on the Altair spec. + tmp = uint64(64) + } + c.weightDenominator, ok = tmp.(uint64) + if !ok { + return errors.New("WEIGHT_DENOMINATOR of unexpected type") + } + return nil +} + +func (c *command) calcHeadCorrect(ctx context.Context, attestation *phase0.Attestation) (bool, error) { + slot := attestation.Data.Slot + root, exists := c.headRoots[slot] + if !exists { + for { + header, err := c.blockHeadersProvider.BeaconBlockHeader(ctx, fmt.Sprintf("%d", slot)) + if err != nil { + return false, nil + } + if header == nil { + // No block. + slot-- + continue + } + if !header.Canonical { + // Not canonical. + slot-- + continue + } + c.headRoots[attestation.Data.Slot] = header.Root + root = header.Root + break + } + } + + return bytes.Equal(root[:], attestation.Data.BeaconBlockRoot[:]), nil +} + +func (c *command) calcTargetCorrect(ctx context.Context, attestation *phase0.Attestation) (bool, error) { + root, exists := c.targetRoots[attestation.Data.Slot] + if !exists { + // Start with first slot of the target epoch. + slot := c.chainTime.FirstSlotOfEpoch(attestation.Data.Target.Epoch) + for { + header, err := c.blockHeadersProvider.BeaconBlockHeader(ctx, fmt.Sprintf("%d", slot)) + if err != nil { + return false, nil + } + if header == nil { + // No block. + slot-- + continue + } + if !header.Canonical { + // Not canonical. + slot-- + continue + } + c.targetRoots[attestation.Data.Slot] = header.Root + root = header.Root + break + } + } + return bytes.Equal(root[:], attestation.Data.Target.Root[:]), nil +} + +func (c *command) analyzeSyncCommittees(ctx context.Context, block *spec.VersionedSignedBeaconBlock) error { + c.analysis.SyncCommitee = &syncCommitteeAnalysis{} + switch block.Version { + case spec.DataVersionPhase0: + return nil + case spec.DataVersionAltair: + c.analysis.SyncCommitee.Contributions = int(block.Altair.Message.Body.SyncAggregate.SyncCommitteeBits.Count()) + c.analysis.SyncCommitee.PossibleContributions = int(block.Altair.Message.Body.SyncAggregate.SyncCommitteeBits.Len()) + c.analysis.SyncCommitee.Score = float64(c.syncRewardWeight) / float64(c.weightDenominator) + c.analysis.SyncCommitee.Value = c.analysis.SyncCommitee.Score * float64(c.analysis.SyncCommitee.Contributions) + c.analysis.Value += c.analysis.SyncCommitee.Value + return nil + default: + return fmt.Errorf("unsupported block version %d", block.Version) + } +} diff --git a/cmd/block/analyze/process_internal_test.go b/cmd/block/analyze/process_internal_test.go new file mode 100644 index 0000000..3f30605 --- /dev/null +++ b/cmd/block/analyze/process_internal_test.go @@ -0,0 +1,63 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "context" + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestProcess(t *testing.T) { + if os.Getenv("ETHDO_TEST_CONNECTION") == "" { + t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests") + } + + tests := []struct { + name string + vars map[string]interface{} + err string + }{ + { + name: "InvalidData", + vars: map[string]interface{}{ + "timeout": "60s", + "validators": "1", + "data": "[[", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + viper.Reset() + + for k, v := range test.vars { + viper.Set(k, v) + } + cmd, err := newCommand(context.Background()) + require.NoError(t, err) + err = cmd.process(context.Background()) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/block/analyze/run.go b/cmd/block/analyze/run.go new file mode 100644 index 0000000..3f91701 --- /dev/null +++ b/cmd/block/analyze/run.go @@ -0,0 +1,50 @@ +// Copyright © 2022 Weald Technology Trading. +// 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 blockanalyze + +import ( + "context" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Run runs the command. +func Run(cmd *cobra.Command) (string, error) { + ctx := context.Background() + + c, err := newCommand(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to set up command") + } + + // Further errors do not need a usage report. + cmd.SilenceUsage = true + + if err := c.process(ctx); err != nil { + return "", errors.Wrap(err, "failed to process") + } + + if viper.GetBool("quiet") { + return "", nil + } + + results, err := c.output(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to obtain output") + } + + return results, nil +} diff --git a/cmd/blockanalyze.go b/cmd/blockanalyze.go new file mode 100644 index 0000000..b4baa9d --- /dev/null +++ b/cmd/blockanalyze.go @@ -0,0 +1,65 @@ +// Copyright © 2022 Weald Technology Trading +// 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 cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + blockanalyze "github.com/wealdtech/ethdo/cmd/block/analyze" +) + +var blockAnalyzeCmd = &cobra.Command{ + Use: "analyze", + Short: "Analyze a block", + Long: `Analyze the contents of a block. For example: + + ethdo block analyze --blockid=12345 + +In quiet mode this will return 0 if the block information is present and not skipped, otherwise 1.`, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := blockanalyze.Run(cmd) + if err != nil { + return err + } + if viper.GetBool("quiet") { + return nil + } + if res != "" { + fmt.Print(res) + } + return nil + }, +} + +func init() { + blockCmd.AddCommand(blockAnalyzeCmd) + blockFlags(blockAnalyzeCmd) + blockAnalyzeCmd.Flags().String("blockid", "head", "the ID of the block to fetch") + blockAnalyzeCmd.Flags().Bool("stream", false, "continually stream blocks as they arrive") + blockAnalyzeCmd.Flags().Bool("json", false, "output data in JSON format") +} + +func blockAnalyzeBindings() { + if err := viper.BindPFlag("blockid", blockAnalyzeCmd.Flags().Lookup("blockid")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stream", blockAnalyzeCmd.Flags().Lookup("stream")); err != nil { + panic(err) + } + if err := viper.BindPFlag("json", blockAnalyzeCmd.Flags().Lookup("json")); err != nil { + panic(err) + } +} diff --git a/cmd/blockinfo.go b/cmd/blockinfo.go index b1b5467..e1954ee 100644 --- a/cmd/blockinfo.go +++ b/cmd/blockinfo.go @@ -26,7 +26,7 @@ var blockInfoCmd = &cobra.Command{ Short: "Obtain information about a block", Long: `Obtain information about a block. For example: - ethdo block info --slot=12345 + ethdo block info --blockid=12345 In quiet mode this will return 0 if the block information is present and not skipped, otherwise 1.`, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/root.go b/cmd/root.go index 25f8a58..57e33fb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -89,6 +89,8 @@ func includeCommandBindings(cmd *cobra.Command) { attesterDutiesBindings() case "attester/inclusion": attesterInclusionBindings() + case "block/analyze": + blockAnalyzeBindings() case "block/info": blockInfoBindings() case "chain/time": diff --git a/docs/usage.md b/docs/usage.md index e15c157..fafa5ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -266,13 +266,35 @@ $ ethdo version ### `block` commands Block commands focus on providing information about Ethereum 2 blocks. +#### `analyze` +`ethdo block info` obtains information about a block in Ethereum 2. Options include: + - `blockid`: the ID (slot, root, 'head') of the block to obtain + +```sh +$ ethdo block analyze --blockid=80 +Value for block 80: 488.531 +``` + +Additional information is supplied when using `--verbose` + +``` +$ ethdo block analyze --blockid=80 --verbose +Attestation 0: distance 1, 119/119/132 new/total/possible votes, score 0.844, value 100.406 +Attestation 1: distance 1, 116/116/131 new/total/possible votes, score 0.844, value 97.875 +Attestation 2: distance 1, 115/115/131 new/total/possible votes, score 0.844, value 97.031 +Attestation 3: distance 1, 114/114/132 new/total/possible votes, score 0.844, value 96.188 +Attestation 4: distance 1, 113/113/132 new/total/possible votes, score 0.844, value 95.344 +Attestation 5: distance 1, 2/22/132 new/total/possible votes, score 0.844, value 1.688 +Value for block 80: 488.531 +``` + #### `info` `ethdo block info` obtains information about a block in Ethereum 2. Options include: - - `slot`: the slot at which to attempt to fetch the block + - `blockid`: the ID (slot, root, 'head') of the block to obtain ```sh -$ ethdo block info --slot=80 +$ ethdo block info --blockid=80 Attestations: 1 Attester slashings: 0 Deposits: 0