From b89154ada364d843566156d39ff0674d36fd46c1 Mon Sep 17 00:00:00 2001 From: Jim McDonald Date: Wed, 26 Feb 2025 09:40:03 +0000 Subject: [PATCH] Add "block trail". --- CHANGELOG.md | 3 + cmd/block/info/output.go | 2 +- cmd/block/trail/command.go | 89 +++++++++++ cmd/block/trail/output.go | 74 +++++++++ cmd/block/trail/process.go | 182 +++++++++++++++++++++++ cmd/block/trail/process_internal_test.go | 63 ++++++++ cmd/block/trail/run.go | 59 ++++++++ cmd/blocktrail.go | 65 ++++++++ cmd/chainstatus.go | 22 +++ cmd/root.go | 1 + cmd/version.go | 2 +- docs/usage.md | 12 ++ 12 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 cmd/block/trail/command.go create mode 100644 cmd/block/trail/output.go create mode 100644 cmd/block/trail/process.go create mode 100644 cmd/block/trail/process_internal_test.go create mode 100644 cmd/block/trail/run.go create mode 100644 cmd/blocktrail.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a713ca1..6870dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +1.37.2: + - add "block trail" + 1.37.1: - handle missing blobs for block info - fix `--epoch` flag for epoch summary diff --git a/cmd/block/info/output.go b/cmd/block/info/output.go index 5b9120c..3021c22 100644 --- a/cmd/block/info/output.go +++ b/cmd/block/info/output.go @@ -79,9 +79,9 @@ func outputBlockGeneral(ctx context.Context, res.WriteString(fmt.Sprintf("Epoch: %d\n", phase0.Epoch(uint64(slot)/slotsPerEpoch))) res.WriteString(fmt.Sprintf("Timestamp: %v\n", time.Unix(genesisTime.Unix()+int64(slot)*int64(slotDuration.Seconds()), 0))) res.WriteString(fmt.Sprintf("Block root: %#x\n", blockRoot)) + res.WriteString(fmt.Sprintf("Parent root: %#x\n", parentRoot)) if verbose { res.WriteString(fmt.Sprintf("Body root: %#x\n", bodyRoot)) - res.WriteString(fmt.Sprintf("Parent root: %#x\n", parentRoot)) res.WriteString(fmt.Sprintf("State root: %#x\n", stateRoot)) } res.WriteString(blockGraffiti(ctx, graffiti)) diff --git a/cmd/block/trail/command.go b/cmd/block/trail/command.go new file mode 100644 index 0000000..6d08ade --- /dev/null +++ b/cmd/block/trail/command.go @@ -0,0 +1,89 @@ +// Copyright © 2025 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 blocktrail + +import ( + "context" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "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 + jsonOutput bool + target string + maxBlocks int + + // Data access. + consensusClient eth2client.Service + chainTime chaintime.Service + blocksProvider eth2client.SignedBeaconBlockProvider + blockHeadersProvider eth2client.BeaconBlockHeadersProvider + + // Processing. + justifiedCheckpoint *phase0.Checkpoint + finalizedCheckpoint *phase0.Checkpoint + + // Results. + steps []*step + found bool +} + +type step struct { + Slot phase0.Slot `json:"slot"` + Root phase0.Root `json:"root"` + ParentRoot phase0.Root `json:"parent_root"` + State string `json:"state,omitempty"` + // Not a slot, but we're using it to steal the JSON processing. + ExecutionBlock phase0.Slot `json:"execution_block"` + ExecutionHash phase0.Hash32 `json:"execution_hash"` +} + +func newCommand(_ context.Context) (*command, error) { + c := &command{ + timeout: viper.GetDuration("timeout"), + quiet: viper.GetBool("quiet"), + verbose: viper.GetBool("verbose"), + debug: viper.GetBool("debug"), + jsonOutput: viper.GetBool("json"), + connection: viper.GetString("connection"), + allowInsecureConnections: viper.GetBool("allow-insecure-connections"), + blockID: viper.GetString("blockid"), + target: viper.GetString("target"), + maxBlocks: viper.GetInt("max-blocks"), + steps: make([]*step, 0), + } + + // Timeout. + if c.timeout == 0 { + return nil, errors.New("timeout is required") + } + + return c, nil +} diff --git a/cmd/block/trail/output.go b/cmd/block/trail/output.go new file mode 100644 index 0000000..0a77072 --- /dev/null +++ b/cmd/block/trail/output.go @@ -0,0 +1,74 @@ +// Copyright © 2025 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 blocktrail + +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 simpleOut struct { + Start *step `json:"start"` + End *step `json:"end"` + Steps int `json:"distance"` +} + +func (c *command) outputJSON(_ context.Context) (string, error) { + var err error + var data []byte + if c.verbose { + data, err = json.Marshal(c.steps) + } else { + basic := &simpleOut{ + Start: c.steps[0], + End: c.steps[len(c.steps)-1], + Steps: len(c.steps) - 1, + } + data, err = json.Marshal(basic) + } + + if err != nil { + return "", err + } + return string(data), nil +} + +func (c *command) outputTxt(_ context.Context) (string, error) { + if !c.found { + return "Target not found", nil + } + + builder := strings.Builder{} + builder.WriteString("Target '") + builder.WriteString(c.target) + builder.WriteString("' found at a distance of ") + builder.WriteString(fmt.Sprintf("%d", len(c.steps)-1)) + builder.WriteString(" block(s)") + + return builder.String(), nil +} diff --git a/cmd/block/trail/process.go b/cmd/block/trail/process.go new file mode 100644 index 0000000..32eba14 --- /dev/null +++ b/cmd/block/trail/process.go @@ -0,0 +1,182 @@ +// Copyright © 2025 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 blocktrail + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + 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 + } + + untilRoot := phase0.Root{} + var untilBlock phase0.Slot + switch { + case strings.ToLower(c.target) == "justified", strings.ToLower(c.target) == "finalized": + // Nothing to do. + case strings.HasPrefix(c.target, "0x"): + // Assume a root. + if err := json.Unmarshal([]byte(fmt.Sprintf("%q", c.target)), &untilRoot); err != nil { + return err + } + default: + // Assume a block number. + tmp, err := strconv.ParseUint(c.target, 10, 64) + if err != nil { + return err + } + untilBlock = phase0.Slot(tmp) + } + + blockID := c.blockID + for range c.maxBlocks { + step := &step{} + + blockResponse, err := c.blocksProvider.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ + Block: blockID, + }) + if err != nil { + var apiError *api.Error + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { + return errors.New("empty beacon block") + } + return errors.Wrap(err, "failed to obtain beacon block") + } + block := blockResponse.Data + + step.Slot, err = block.Slot() + if err != nil { + return err + } + step.Root, err = block.Root() + if err != nil { + return err + } + step.ParentRoot, err = block.ParentRoot() + if err != nil { + return err + } + executionBlock, err := block.ExecutionBlockNumber() + if err != nil { + return err + } + step.ExecutionBlock = phase0.Slot(executionBlock) + step.ExecutionHash, err = block.ExecutionBlockHash() + if err != nil { + return err + } + + if c.debug { + data, err := json.Marshal(step) + if err == nil { + fmt.Fprintf(os.Stderr, "Step is %s\n", string(data)) + } + } + + c.steps = append(c.steps, step) + + blockID = step.ParentRoot.String() + + if c.target == "justified" && bytes.Equal(step.Root[:], c.justifiedCheckpoint.Root[:]) { + c.found = true + break + } + if c.target == "finalized" && bytes.Equal(step.Root[:], c.finalizedCheckpoint.Root[:]) { + c.found = true + break + } + if untilBlock > 0 && step.Slot == untilBlock { + c.found = true + break + } + if (!untilRoot.IsZero()) && bytes.Equal(step.Root[:], untilRoot[:]) { + c.found = true + break + } + } + + return nil +} + +func (c *command) setup(ctx context.Context) error { + var err error + + // Connect to the client. + c.consensusClient, err = util.ConnectToBeaconNode(ctx, &util.ConnectOpts{ + Address: c.connection, + Timeout: c.timeout, + AllowInsecure: c.allowInsecureConnections, + LogFallback: !c.quiet, + }) + if err != nil { + return errors.Wrap(err, "failed to connect to beacon node") + } + + c.chainTime, err = standardchaintime.New(ctx, + standardchaintime.WithSpecProvider(c.consensusClient.(eth2client.SpecProvider)), + standardchaintime.WithGenesisProvider(c.consensusClient.(eth2client.GenesisProvider)), + ) + if err != nil { + return errors.Wrap(err, "failed to set up chaintime service") + } + + var isProvider bool + c.blocksProvider, isProvider = c.consensusClient.(eth2client.SignedBeaconBlockProvider) + if !isProvider { + return errors.New("connection does not provide signed beacon block information") + } + c.blockHeadersProvider, isProvider = c.consensusClient.(eth2client.BeaconBlockHeadersProvider) + if !isProvider { + return errors.New("connection does not provide beacon block header information") + } + + finalityProvider, isProvider := c.consensusClient.(eth2client.FinalityProvider) + if !isProvider { + return errors.New("connection does not provide finality information") + } + finalityResponse, err := finalityProvider.Finality(ctx, &api.FinalityOpts{ + State: "head", + }) + if err != nil { + return errors.Wrap(err, "failed to obtain finality") + } + finality := finalityResponse.Data + c.justifiedCheckpoint = finality.Justified + if c.debug { + fmt.Fprintf(os.Stderr, "Justified checkpoint is %d / %#x\n", c.justifiedCheckpoint.Epoch, c.justifiedCheckpoint.Root) + } + c.finalizedCheckpoint = finality.Finalized + if c.debug { + fmt.Fprintf(os.Stderr, "Finalized checkpoint is %d / %#x\n", c.finalizedCheckpoint.Epoch, c.finalizedCheckpoint.Root) + } + + return nil +} diff --git a/cmd/block/trail/process_internal_test.go b/cmd/block/trail/process_internal_test.go new file mode 100644 index 0000000..0ace627 --- /dev/null +++ b/cmd/block/trail/process_internal_test.go @@ -0,0 +1,63 @@ +// Copyright © 2025 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 blocktrail + +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: "NoBlock", + vars: map[string]interface{}{ + "timeout": "60s", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "blockid": "invalid", + }, + err: "failed to obtain beacon block: failed to request signed beacon block: GET failed with status 400: {\"code\":400,\"message\":\"BAD_REQUEST: Unsupported endpoint version: v2\",\"stacktraces\":[]}", + }, + } + + 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/trail/run.go b/cmd/block/trail/run.go new file mode 100644 index 0000000..733854d --- /dev/null +++ b/cmd/block/trail/run.go @@ -0,0 +1,59 @@ +// Copyright © 2025 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 blocktrail + +import ( + "context" + "errors" + "os" + + "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.Join(errors.New("failed to set up command"), err) + } + + // Further errors do not need a usage report. + cmd.SilenceUsage = true + + if err := c.process(ctx); err != nil { + switch { + case errors.Is(err, context.DeadlineExceeded): + return "", errors.New("operation timed out; try increasing with --timeout option") + default: + return "", errors.Join(errors.New("failed to process"), err) + } + } + + if viper.GetBool("quiet") { + if c.found { + return "", nil + } + os.Exit(1) + } + + results, err := c.output(ctx) + if err != nil { + return "", errors.Join(errors.New("failed to obtain output"), err) + } + + return results, nil +} diff --git a/cmd/blocktrail.go b/cmd/blocktrail.go new file mode 100644 index 0000000..c6cb307 --- /dev/null +++ b/cmd/blocktrail.go @@ -0,0 +1,65 @@ +// Copyright © 2025 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" + blocktrail "github.com/wealdtech/ethdo/cmd/block/trail" +) + +var blockTrailCmd = &cobra.Command{ + Use: "trail", + Short: "Trail back in the chain from a given block.", + Long: `Trail back in the chain for a given block. For example: + + ethdo block trail --blockid=12345 --target=finalized + +In quiet mode this will return 0 if the block trail ends up at the finalized state, otherwise 1.`, + RunE: func(cmd *cobra.Command, _ []string) error { + res, err := blocktrail.Run(cmd) + if err != nil { + return err + } + if viper.GetBool("quiet") { + return nil + } + if res != "" { + fmt.Println(res) + } + return nil + }, +} + +func init() { + blockCmd.AddCommand(blockTrailCmd) + blockFlags(blockTrailCmd) + blockTrailCmd.Flags().String("blockid", "head", "the ID of the block to fetch") + blockTrailCmd.Flags().String("target", "justified", "the target block (block number, hash, justified or finalized)") + blockTrailCmd.Flags().Int("max-blocks", 16384, "the maximum number of blocks to look at before halting") +} + +func blockTrailBindings(cmd *cobra.Command) { + if err := viper.BindPFlag("blockid", cmd.Flags().Lookup("blockid")); err != nil { + panic(err) + } + if err := viper.BindPFlag("target", cmd.Flags().Lookup("target")); err != nil { + panic(err) + } + if err := viper.BindPFlag("max-blocks", cmd.Flags().Lookup("max-blocks")); err != nil { + panic(err) + } +} diff --git a/cmd/chainstatus.go b/cmd/chainstatus.go index b0df412..9a26bff 100644 --- a/cmd/chainstatus.go +++ b/cmd/chainstatus.go @@ -65,6 +65,12 @@ In quiet mode this will return 0 if the chain status can be obtained, otherwise errCheck(err, "Failed to obtain finality information") finality := finalityResponse.Data + blockProvider, isProvider := eth2Client.(eth2client.SignedBeaconBlockProvider) + assert(isProvider, "beacon node does not provide signed beacon blocks; cannot report on chain status") + blockResponse, err := blockProvider.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{Block: "head"}) + errCheck(err, "Failed to obtain block information") + block := blockResponse.Data + slot := chainTime.CurrentSlot() nextSlot := slot + 1 @@ -78,12 +84,28 @@ In quiet mode this will return 0 if the chain status can be obtained, otherwise nextEpochStartSlot := chainTime.FirstSlotOfEpoch(nextEpoch) nextEpochTimestamp := chainTime.StartOfEpoch(nextEpoch) + headSlot, err := block.Slot() + errCheck(err, "Failed to obtain block slot") + res := strings.Builder{} res.WriteString("Current slot: ") res.WriteString(fmt.Sprintf("%d", slot)) res.WriteString("\n") + res.WriteString("Head slot: ") + res.WriteString(fmt.Sprintf("%d", headSlot)) + if headSlot != slot { + if slot-headSlot == 1 { + res.WriteString("(1 slot behind)") + } else { + res.WriteString(" (") + res.WriteString(fmt.Sprintf("%d", slot-headSlot)) + res.WriteString(" slots behind)") + } + } + res.WriteString("\n") + res.WriteString("Current epoch: ") res.WriteString(fmt.Sprintf("%d", epoch)) res.WriteString("\n") diff --git a/cmd/root.go b/cmd/root.go index 81e753c..46d7907 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,7 @@ var bindings = map[string]func(cmd *cobra.Command){ "attester/inclusion": attesterInclusionBindings, "block/analyze": blockAnalyzeBindings, "block/info": blockInfoBindings, + "block/trail": blockTrailBindings, "chain/eth1votes": chainEth1VotesBindings, "chain/info": chainInfoBindings, "chain/queues": chainQueuesBindings, diff --git a/cmd/version.go b/cmd/version.go index 65831ab..8b368ed 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -24,7 +24,7 @@ import ( // ReleaseVersion is the release version of the codebase. // Usually overridden by tag names when building binaries. -var ReleaseVersion = "local build (latest release 1.37.1)" +var ReleaseVersion = "local build (latest release 1.37.2)" // versionCmd represents the version command. var versionCmd = &cobra.Command{ diff --git a/docs/usage.md b/docs/usage.md index 85e300c..546cbb5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -371,6 +371,18 @@ Deposits: 0 Voluntary exits: 0 ``` +#### `trail` + +`ethdo block trail` tracks back from the provided block to see if it is in a chain descending from the a target block. Options include: + +- `blockid`: the ID (slot, root, 'head') of the block to trail from; defaults to head +- `target`: the target block (slot, block hash, 'justified', 'finalized') to check; defaults to 'justified' +- `max-blocks`: the maximum number of blocks to look at to find the target + +```sh +$ ethdo block trail +Target 'justified' found at a distance of 54 block(s) +``` ### `chain` commands Chain commands focus on providing information about Ethereum consensus chains.