diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..f67bbe6 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,23 @@ +name: golangci-lint +on: [ push, pull_request ] +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. + version: v1.29 + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + args: --timeout=10m + + # Optional: show only new issues if it's a pull request. The default value is `false`. + # only-new-issues: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d25f2a..d3766eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +1.8.0 + - add "chain time" + - add "validator keycheck" + 1.7.5: - add "slot time" - add "attester duties" diff --git a/cmd/chain/time/input.go b/cmd/chain/time/input.go new file mode 100644 index 0000000..5206d3c --- /dev/null +++ b/cmd/chain/time/input.go @@ -0,0 +1,81 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +type dataIn struct { + // System. + timeout time.Duration + quiet bool + verbose bool + debug bool + json bool + // Input + connection string + allowInsecureConnections bool + timestamp string + slot string + epoch string +} + +func input(ctx context.Context) (*dataIn, error) { + data := &dataIn{} + + if viper.GetDuration("timeout") == 0 { + return nil, errors.New("timeout is required") + } + data.timeout = viper.GetDuration("timeout") + data.quiet = viper.GetBool("quiet") + data.verbose = viper.GetBool("verbose") + data.debug = viper.GetBool("debug") + data.json = viper.GetBool("json") + + haveInput := false + if viper.GetString("timestamp") != "" { + data.timestamp = viper.GetString("timestamp") + haveInput = true + } + if viper.GetString("slot") != "" { + if haveInput { + return nil, errors.New("only one of timestamp, slot and epoch allowed") + } + data.slot = viper.GetString("slot") + haveInput = true + } + if viper.GetString("epoch") != "" { + if haveInput { + return nil, errors.New("only one of timestamp, slot and epoch allowed") + } + data.epoch = viper.GetString("epoch") + haveInput = true + } + if !haveInput { + return nil, errors.New("one of timestamp, slot or epoch required") + } + + if viper.GetString("connection") == "" { + return nil, errors.New("connection is required") + } + data.connection = viper.GetString("connection") + data.allowInsecureConnections = viper.GetBool("allow-insecure-connections") + + return data, nil +} diff --git a/cmd/chain/time/input_internal_test.go b/cmd/chain/time/input_internal_test.go new file mode 100644 index 0000000..f409a3b --- /dev/null +++ b/cmd/chain/time/input_internal_test.go @@ -0,0 +1,97 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "github.com/wealdtech/ethdo/testutil" + e2types "github.com/wealdtech/go-eth2-types/v2" + e2wallet "github.com/wealdtech/go-eth2-wallet" + keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" + nd "github.com/wealdtech/go-eth2-wallet-nd/v2" + scratch "github.com/wealdtech/go-eth2-wallet-store-scratch" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" +) + +func TestInput(t *testing.T) { + if os.Getenv("ETHDO_TEST_CONNECTION") == "" { + t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests") + } + + require.NoError(t, e2types.InitBLS()) + + store := scratch.New() + require.NoError(t, e2wallet.UseStore(store)) + testWallet, err := nd.CreateWallet(context.Background(), "Test wallet", store, keystorev4.New()) + require.NoError(t, err) + require.NoError(t, testWallet.(e2wtypes.WalletLocker).Unlock(context.Background(), nil)) + viper.Set("passphrase", "pass") + _, err = testWallet.(e2wtypes.WalletAccountImporter).ImportAccount(context.Background(), + "Interop 0", + testutil.HexToBytes("0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866"), + []byte("pass"), + ) + require.NoError(t, err) + + tests := []struct { + name string + vars map[string]interface{} + res *dataIn + err string + }{ + { + name: "TimeoutMissing", + vars: map[string]interface{}{}, + err: "timeout is required", + }, + { + name: "ConnectionMissing", + vars: map[string]interface{}{ + "timeout": "5s", + "slot": "1", + }, + err: "connection is required", + }, + { + name: "IDMissing", + vars: map[string]interface{}{ + "timeout": "5s", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + }, + err: "one of timestamp, slot or epoch required", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + viper.Reset() + + for k, v := range test.vars { + viper.Set(k, v) + } + res, err := input(context.Background()) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.res.timeout, res.timeout) + } + }) + } +} diff --git a/cmd/chain/time/output.go b/cmd/chain/time/output.go new file mode 100644 index 0000000..a24b6ec --- /dev/null +++ b/cmd/chain/time/output.go @@ -0,0 +1,67 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + "fmt" + "strings" + "time" + + spec "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +type dataOut struct { + debug bool + quiet bool + verbose bool + json bool + + epoch spec.Epoch + epochStart time.Time + epochEnd time.Time + slot spec.Slot + slotStart time.Time + slotEnd time.Time +} + +func output(ctx context.Context, data *dataOut) (string, error) { + if data == nil { + return "", errors.New("no data") + } + + if data.quiet { + return "", nil + } + + builder := strings.Builder{} + + builder.WriteString("Epoch ") + builder.WriteString(fmt.Sprintf("%d", data.epoch)) + builder.WriteString("\n Epoch start ") + builder.WriteString(fmt.Sprintf("%s", data.epochStart.Format("2006-01-02 15:04:05"))) + builder.WriteString("\n Epoch end ") + builder.WriteString(fmt.Sprintf("%s", data.epochEnd.Format("2006-01-02 15:04:05"))) + + builder.WriteString("\nSlot ") + builder.WriteString(fmt.Sprintf("%d", data.slot)) + builder.WriteString("\n Slot start ") + builder.WriteString(fmt.Sprintf("%s", data.slotStart.Format("2006-01-02 15:04:05"))) + builder.WriteString("\n Slot end ") + builder.WriteString(fmt.Sprintf("%s", data.slotEnd.Format("2006-01-02 15:04:05"))) + builder.WriteString("\n") + + return builder.String(), nil +} diff --git a/cmd/chain/time/output_internal_test.go b/cmd/chain/time/output_internal_test.go new file mode 100644 index 0000000..44ba638 --- /dev/null +++ b/cmd/chain/time/output_internal_test.go @@ -0,0 +1,85 @@ +// Copyright © 2021 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 chaintime + +// import ( +// "context" +// "testing" +// +// api "github.com/attestantio/go-eth2-client/api/v1" +// "github.com/stretchr/testify/require" +// "github.com/wealdtech/ethdo/testutil" +// ) +// +// func TestOutput(t *testing.T) { +// tests := []struct { +// name string +// dataOut *dataOut +// res string +// err string +// }{ +// { +// name: "Nil", +// err: "no data", +// }, +// { +// name: "Empty", +// dataOut: &dataOut{}, +// res: "No duties found", +// }, +// { +// name: "Present", +// dataOut: &dataOut{ +// duty: &api.AttesterDuty{ +// PubKey: testutil.HexToPubKey("0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95"), +// Slot: 1, +// ValidatorIndex: 2, +// CommitteeIndex: 3, +// CommitteeLength: 4, +// CommitteesAtSlot: 5, +// ValidatorCommitteeIndex: 6, +// }, +// }, +// res: "Validator attesting in slot 1 committee 3", +// }, +// { +// name: "JSON", +// dataOut: &dataOut{ +// json: true, +// duty: &api.AttesterDuty{ +// PubKey: testutil.HexToPubKey("0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95"), +// Slot: 1, +// ValidatorIndex: 2, +// CommitteeIndex: 3, +// CommitteeLength: 4, +// CommitteesAtSlot: 5, +// ValidatorCommitteeIndex: 6, +// }, +// }, +// res: `{"pubkey":"0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95","slot":"1","validator_index":"2","committee_index":"3","committee_length":"4","committees_at_slot":"5","validator_committee_index":"6"}`, +// }, +// } +// +// for _, test := range tests { +// t.Run(test.name, func(t *testing.T) { +// res, err := output(context.Background(), test.dataOut) +// if test.err != "" { +// require.EqualError(t, err, test.err) +// } else { +// require.NoError(t, err) +// require.Equal(t, test.res, res) +// } +// }) +// } +// } diff --git a/cmd/chain/time/process.go b/cmd/chain/time/process.go new file mode 100644 index 0000000..1cdc992 --- /dev/null +++ b/cmd/chain/time/process.go @@ -0,0 +1,90 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + "strconv" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + spec "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/wealdtech/ethdo/util" +) + +func process(ctx context.Context, data *dataIn) (*dataOut, error) { + if data == nil { + return nil, errors.New("no data") + } + + eth2Client, err := util.ConnectToBeaconNode(ctx, data.connection, data.timeout, data.allowInsecureConnections) + if err != nil { + return nil, errors.Wrap(err, "failed to connect to Ethereum 2 beacon node") + } + + config, err := eth2Client.(eth2client.SpecProvider).Spec(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain beacon chain configuration") + } + + slotsPerEpoch := config["SLOTS_PER_EPOCH"].(uint64) + slotDuration := config["SECONDS_PER_SLOT"].(time.Duration) + genesis, err := eth2Client.(eth2client.GenesisProvider).Genesis(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain genesis data") + } + //epoch = int64(time.Since(genesis.GenesisTime).Seconds()) / (int64(slotDuration.Seconds()) * int64(data.slotsPerEpoch)) + + results := &dataOut{ + debug: data.debug, + quiet: data.quiet, + verbose: data.verbose, + } + + // Calculate the slot given the input. + switch { + case data.slot != "": + slot, err := strconv.ParseUint(data.slot, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "failed to parse slot") + } + results.slot = spec.Slot(slot) + case data.epoch != "": + epoch, err := strconv.ParseUint(data.epoch, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "failed to parse epoch") + } + results.slot = spec.Slot(epoch * slotsPerEpoch) + case data.timestamp != "": + timestamp, err := time.Parse("2006-01-02T15:04:05", data.timestamp) + if err != nil { + return nil, errors.Wrap(err, "failed to parse timestamp") + } + secs := timestamp.Sub(genesis.GenesisTime) + if secs < 0 { + return nil, errors.New("timestamp prior to genesis") + } + results.slot = spec.Slot(secs / slotDuration) + } + + // Fill in the info given the slot. + results.slotStart = genesis.GenesisTime.Add(time.Duration(results.slot) * slotDuration) + results.slotEnd = genesis.GenesisTime.Add(time.Duration(results.slot+1) * slotDuration) + results.epoch = spec.Epoch(uint64(results.slot) / slotsPerEpoch) + results.epochStart = genesis.GenesisTime.Add(time.Duration(uint64(results.epoch)*slotsPerEpoch) * slotDuration) + results.epochEnd = genesis.GenesisTime.Add(time.Duration(uint64(results.epoch+1)*slotsPerEpoch) * slotDuration) + + return results, nil +} diff --git a/cmd/chain/time/process_internal_test.go b/cmd/chain/time/process_internal_test.go new file mode 100644 index 0000000..7fb304c --- /dev/null +++ b/cmd/chain/time/process_internal_test.go @@ -0,0 +1,103 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + "os" + "testing" + "time" + + "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 + dataIn *dataIn + expected *dataOut + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "Slot", + dataIn: &dataIn{ + connection: os.Getenv("ETHDO_TEST_CONNECTION"), + timeout: 10 * time.Second, + allowInsecureConnections: true, + slot: "1", + }, + expected: &dataOut{ + epochStart: time.Unix(1606824023, 0), + epochEnd: time.Unix(1606824407, 0), + slot: 1, + slotStart: time.Unix(1606824035, 0), + slotEnd: time.Unix(1606824047, 0), + }, + }, + { + name: "Epoch", + dataIn: &dataIn{ + connection: os.Getenv("ETHDO_TEST_CONNECTION"), + timeout: 10 * time.Second, + allowInsecureConnections: true, + epoch: "2", + }, + expected: &dataOut{ + epoch: 2, + epochStart: time.Unix(1606824791, 0), + epochEnd: time.Unix(1606825175, 0), + slot: 64, + slotStart: time.Unix(1606824791, 0), + slotEnd: time.Unix(1606824803, 0), + }, + }, + { + name: "Timestamp", + dataIn: &dataIn{ + connection: os.Getenv("ETHDO_TEST_CONNECTION"), + timeout: 10 * time.Second, + allowInsecureConnections: true, + timestamp: "2021-01-01T00:00:00", + }, + expected: &dataOut{ + epoch: 6862, + epochStart: time.Unix(1609459031, 0), + epochEnd: time.Unix(1609459415, 0), + slot: 219598, + slotStart: time.Unix(1609459199, 0), + slotEnd: time.Unix(1609459211, 0), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := process(context.Background(), test.dataIn) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, res) + } + }) + } +} diff --git a/cmd/chain/time/run.go b/cmd/chain/time/run.go new file mode 100644 index 0000000..52e5310 --- /dev/null +++ b/cmd/chain/time/run.go @@ -0,0 +1,50 @@ +// Copyright © 2021 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 chaintime + +import ( + "context" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Run runs the wallet create data command. +func Run(cmd *cobra.Command) (string, error) { + ctx := context.Background() + dataIn, err := input(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to obtain input") + } + + // Further errors do not need a usage report. + cmd.SilenceUsage = true + + dataOut, err := process(ctx, dataIn) + if err != nil { + return "", errors.Wrap(err, "failed to process") + } + + if viper.GetBool("quiet") { + return "", nil + } + + results, err := output(ctx, dataOut) + if err != nil { + return "", errors.Wrap(err, "failed to obtain output") + } + + return results, nil +} diff --git a/cmd/chaintime.go b/cmd/chaintime.go new file mode 100644 index 0000000..2c79cb1 --- /dev/null +++ b/cmd/chaintime.go @@ -0,0 +1,60 @@ +// Copyright © 2021 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" + chaintime "github.com/wealdtech/ethdo/cmd/chain/time" +) + +var chainTimeCmd = &cobra.Command{ + Use: "time", + Short: "Obtain info about the chain at a given time", + Long: `Obtain info about the chain at a given time. For example: + + ethdo chain time --slot=12345`, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := chaintime.Run(cmd) + if err != nil { + return err + } + if res != "" { + fmt.Print(res) + } + return nil + }, +} + +func init() { + chainCmd.AddCommand(chainTimeCmd) + chainFlags(chainTimeCmd) + chainTimeCmd.Flags().String("slot", "", "The slot for which to obtain information") + chainTimeCmd.Flags().String("epoch", "", "The epoch for which to obtain information") + chainTimeCmd.Flags().String("timestamp", "", "The timestamp for which to obtain information (format YYYY-MM-DDTHH:MM:SS)") +} + +func chainTimeBindings() { + if err := viper.BindPFlag("slot", chainTimeCmd.Flags().Lookup("slot")); err != nil { + panic(err) + } + if err := viper.BindPFlag("epoch", chainTimeCmd.Flags().Lookup("epoch")); err != nil { + panic(err) + } + if err := viper.BindPFlag("timestamp", chainTimeCmd.Flags().Lookup("timestamp")); err != nil { + panic(err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 3640844..86deb64 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,6 +74,8 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { attesterInclusionBindings() case "block/info": blockInfoBindings() + case "chain/time": + chainTimeBindings() case "exit/verify": exitVerifyBindings() case "node/events": @@ -88,6 +90,8 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { validatorExitBindings() case "validator/info": validatorInfoBindings() + case "validator/keycheck": + validatorKeycheckBindings() case "wallet/create": walletCreateBindings() case "wallet/import": diff --git a/cmd/validator/keycheck/input.go b/cmd/validator/keycheck/input.go new file mode 100644 index 0000000..4ecaf08 --- /dev/null +++ b/cmd/validator/keycheck/input.go @@ -0,0 +1,57 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +type dataIn struct { + // System. + timeout time.Duration + quiet bool + verbose bool + debug bool + // Withdrawal credentials. + withdrawalCredentials string + // Operation. + mnemonic string + privKey string +} + +func input(ctx context.Context) (*dataIn, error) { + data := &dataIn{} + + data.quiet = viper.GetBool("quiet") + data.verbose = viper.GetBool("verbose") + data.debug = viper.GetBool("debug") + + // Withdrawal credentials. + data.withdrawalCredentials = viper.GetString("withdrawal-credentials") + if data.withdrawalCredentials == "" { + return nil, errors.New("withdrawal credentials are required") + } + + data.mnemonic = viper.GetString("mnemonic") + data.privKey = viper.GetString("privkey") + if data.mnemonic == "" && data.privKey == "" { + return nil, errors.New("mnemonic or privkey is required") + } + + return data, nil +} diff --git a/cmd/validator/keycheck/input_internal_test.go b/cmd/validator/keycheck/input_internal_test.go new file mode 100644 index 0000000..969a681 --- /dev/null +++ b/cmd/validator/keycheck/input_internal_test.go @@ -0,0 +1,71 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestInput(t *testing.T) { + tests := []struct { + name string + vars map[string]interface{} + res *dataIn + err string + }{ + { + name: "WithdrawalCredentialsMissing", + vars: map[string]interface{}{}, + err: "withdrawal credentials are required", + }, + { + name: "MnemonicAndPrivateKeyMissing", + vars: map[string]interface{}{ + "withdrawal-credentials": "0x007e28dcf9029e8d92ca4b5d01c66c934e7f3110606f34ae3052cbf67bd3fc02", + }, + err: "mnemonic or privkey is required", + }, + { + name: "GoodWithMnemonic", + vars: map[string]interface{}{ + "withdrawal-credentials": "0x007e28dcf9029e8d92ca4b5d01c66c934e7f3110606f34ae3052cbf67bd3fc02", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + }, + res: &dataIn{ + withdrawalCredentials: "0x007e28dcf9029e8d92ca4b5d01c66c934e7f3110606f34ae3052cbf67bd3fc02", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + viper.Reset() + + for k, v := range test.vars { + viper.Set(k, v) + } + res, err := input(context.Background()) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.res.withdrawalCredentials, res.withdrawalCredentials) + } + }) + } +} diff --git a/cmd/validator/keycheck/output.go b/cmd/validator/keycheck/output.go new file mode 100644 index 0000000..75d1e62 --- /dev/null +++ b/cmd/validator/keycheck/output.go @@ -0,0 +1,52 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" +) + +type dataOut struct { + debug bool + quiet bool + verbose bool + match bool + path string +} + +func output(ctx context.Context, data *dataOut) (string, int, error) { + if data == nil { + return "", 1, errors.New("no data") + } + + if data.quiet { + if !data.match { + os.Exit(1) + } + return "", 1, nil + } + + if data.match { + if data.path == "" { + return "Withdrawal credentials confirmed", 0, nil + } + return fmt.Sprintf("Withdrawal credentials confirmed at path %s", data.path), 0, nil + } else { + return "Could not confirm withdrawal credentials with given information", 1, nil + } +} diff --git a/cmd/validator/keycheck/output_internal_test.go b/cmd/validator/keycheck/output_internal_test.go new file mode 100644 index 0000000..912c38f --- /dev/null +++ b/cmd/validator/keycheck/output_internal_test.go @@ -0,0 +1,81 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOutput(t *testing.T) { + tests := []struct { + name string + dataOut *dataOut + exitCode int + expected []string + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "Not found", + dataOut: &dataOut{ + match: false, + }, + exitCode: 1, + expected: []string{ + "Could not confirm withdrawal credentials with given information", + }, + }, + { + name: "Found", + dataOut: &dataOut{ + match: true, + }, + expected: []string{ + "Withdrawal credentials confirmed", + }, + }, + { + name: "FoundWithPath", + dataOut: &dataOut{ + match: true, + path: "m/12381/3600/10/0", + }, + expected: []string{ + "Withdrawal credentials confirmed at path m/12381/3600/10/0", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, exitCode, err := output(context.Background(), test.dataOut) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.exitCode, exitCode) + for _, expected := range test.expected { + require.True(t, strings.Contains(res, expected)) + } + } + }) + } +} diff --git a/cmd/validator/keycheck/process.go b/cmd/validator/keycheck/process.go new file mode 100644 index 0000000..97f7b78 --- /dev/null +++ b/cmd/validator/keycheck/process.go @@ -0,0 +1,120 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "strings" + + "github.com/pkg/errors" + "github.com/tyler-smith/go-bip39" + e2types "github.com/wealdtech/go-eth2-types/v2" + util "github.com/wealdtech/go-eth2-util" + "golang.org/x/text/unicode/norm" +) + +func process(ctx context.Context, data *dataIn) (*dataOut, error) { + if data == nil { + return nil, errors.New("no data") + } + + validatorWithdrawalCredentials, err := hex.DecodeString(strings.TrimPrefix(data.withdrawalCredentials, "0x")) + if err != nil { + return nil, errors.Wrap(err, "failed to parse withdrawal credentials") + } + + match := false + path := "" + if data.privKey != "" { + // Single private key to check. + keyBytes, err := hex.DecodeString(strings.TrimPrefix(data.privKey, "0x")) + if err != nil { + return nil, err + } + key, err := e2types.BLSPrivateKeyFromBytes(keyBytes) + if err != nil { + return nil, err + } + + match, err = checkPrivKey(ctx, data.debug, validatorWithdrawalCredentials, key) + if err != nil { + return nil, err + } + } else { + // Mnemonic to check. + match, path, err = checkMnemonic(ctx, data.debug, validatorWithdrawalCredentials, data.mnemonic) + } + + results := &dataOut{ + debug: data.debug, + quiet: data.quiet, + verbose: data.verbose, + match: match, + path: path, + } + + return results, nil +} + +func checkPrivKey(ctx context.Context, debug bool, validatorWithdrawalCredentials []byte, key *e2types.BLSPrivateKey) (bool, error) { + pubKey := key.PublicKey() + + withdrawalCredentials := util.SHA256(pubKey.Marshal()) + withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX + + return bytes.Equal(withdrawalCredentials, validatorWithdrawalCredentials), nil +} + +func checkMnemonic(ctx context.Context, debug bool, validatorWithdrawalCredentials []byte, mnemonic string) (bool, string, error) { + // If there are more than 24 words we treat the additional characters as the passphrase. + mnemonicParts := strings.Split(mnemonic, " ") + mnemonicPassphrase := "" + if len(mnemonicParts) > 24 { + mnemonic = strings.Join(mnemonicParts[:24], " ") + mnemonicPassphrase = strings.Join(mnemonicParts[24:], " ") + } + // Normalise the input. + mnemonic = string(norm.NFKD.Bytes([]byte(mnemonic))) + mnemonicPassphrase = string(norm.NFKD.Bytes([]byte(mnemonicPassphrase))) + + if !bip39.IsMnemonicValid(mnemonic) { + return false, "", errors.New("mnemonic is invalid") + } + + // Create seed from mnemonic and passphrase. + seed := bip39.NewSeed(mnemonic, mnemonicPassphrase) + // Check first 1024 indices. + for i := 0; i < 1024; i++ { + path := fmt.Sprintf("m/12381/3600/%d/0", i) + if debug { + fmt.Printf("Checking path %s\n", path) + } + key, err := util.PrivateKeyFromSeedAndPath(seed, path) + if err != nil { + return false, "", errors.Wrap(err, "failed to generate key") + } + match, err := checkPrivKey(ctx, debug, validatorWithdrawalCredentials, key) + if err != nil { + return false, "", errors.Wrap(err, "failed to match key") + } + if match { + return true, path, nil + } + } + + return false, "", nil +} diff --git a/cmd/validator/keycheck/run.go b/cmd/validator/keycheck/run.go new file mode 100644 index 0000000..f330814 --- /dev/null +++ b/cmd/validator/keycheck/run.go @@ -0,0 +1,51 @@ +// Copyright © 2021 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 validatorkeycheck + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// Run runs the wallet create data command. +func Run(cmd *cobra.Command) (string, error) { + ctx := context.Background() + dataIn, err := input(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to obtain input") + } + + // Further errors do not need a usage report. + cmd.SilenceUsage = true + + dataOut, err := process(ctx, dataIn) + if err != nil { + return "", err + } + + results, exitCode, err := output(ctx, dataOut) + if err != nil { + return "", err + } + if exitCode != 0 { + fmt.Println(results) + os.Exit(exitCode) + } + + return results, nil +} diff --git a/cmd/validatorkeycheck.go b/cmd/validatorkeycheck.go new file mode 100644 index 0000000..fccd4a2 --- /dev/null +++ b/cmd/validatorkeycheck.go @@ -0,0 +1,67 @@ +// Copyright © 2021 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" + validatorkeycheck "github.com/wealdtech/ethdo/cmd/validator/keycheck" +) + +var validatorKeycheckCmd = &cobra.Command{ + Use: "keycheck", + Short: "Check that the withdrawal credentials for a validator matches the given key.", + Long: `Check that the withdrawal credentials for a validator matches the given key. For example: + + ethdo validator keycheck --withdrawal-credentials=0x007e28dcf9029e8d92ca4b5d01c66c934e7f3110606f34ae3052cbf67bd3fc02 --privkey=0x1b46e61babc7a6a0fbfe8e416de3c71f85e367f24e0bfcb12e57adb11117662c + +A mnemonic can be used in place of a private key, in which case the first 1,024 indices of the standard withdrawal key path will be scanned for a matching key. + +In quiet mode this will return 0 if the withdrawal credentials match the key, otherwise 1.`, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := validatorkeycheck.Run(cmd) + if err != nil { + return err + } + if viper.GetBool("quiet") { + return nil + } + if res != "" { + fmt.Println(res) + } + return nil + }, +} + +func init() { + validatorCmd.AddCommand(validatorKeycheckCmd) + validatorFlags(validatorKeycheckCmd) + validatorKeycheckCmd.Flags().String("withdrawal-credentials", "", "Withdrawal credentials to check (can run offline)") + validatorKeycheckCmd.Flags().String("mnemonic", "", "Mnemonic from which to generate withdrawal credentials") + validatorKeycheckCmd.Flags().String("privkey", "", "Private key from which to generate withdrawal credentials") +} + +func validatorKeycheckBindings() { + if err := viper.BindPFlag("withdrawal-credentials", validatorKeycheckCmd.Flags().Lookup("withdrawal-credentials")); err != nil { + panic(err) + } + if err := viper.BindPFlag("mnemonic", validatorKeycheckCmd.Flags().Lookup("mnemonic")); err != nil { + panic(err) + } + if err := viper.BindPFlag("privkey", validatorKeycheckCmd.Flags().Lookup("privkey")); err != nil { + panic(err) + } +} diff --git a/cmd/version.go b/cmd/version.go index 61f246a..1042f1d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -24,7 +24,7 @@ import ( // ReleaseVersion is the release version of the codebase. // Usually overrideen by tag names when building binaries. -var ReleaseVersion = "local build (latest release 1.7.5)" +var ReleaseVersion = "local build (latest release 1.8.0)" // versionCmd represents the version command var versionCmd = &cobra.Command{ diff --git a/docs/usage.md b/docs/usage.md index 6462744..2c01f8f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -325,6 +325,23 @@ Prior justified epoch: 3 Prior justified epoch distance: 4 ``` +#### `time` + +`ethdo chain time` calculates the time period of Ethereum 2 epochs and slots. Options include: + - `epoch` show epoch and slot times for the given epoch + - `slot` show epoch and slot times for the given slot + - `timestamp` show epoch and slot times for the given timestamp + +```sh +$ ethdo chain time --epoch=1234 +Epoch 1234 + Epoch start 2020-12-06 23:37:59 + Epoch end 2020-12-06 23:44:23 +Slot 39488 + Slot start 2020-12-06 23:37:59 + Slot end 2020-12-06 23:38:11 +``` + ### `deposit` comands Deposit commands focus on information about deposit data information in a JSON file generated by the `ethdo validator depositdata` command. @@ -471,6 +488,18 @@ Balance: 3.201850307 Ether Effective balance: 3.1 Ether ``` +#### `keycheck` + +`ethdo validator keycheck` checks if a given key matches a validator's withdrawal credentials. Options include: + - `withdrawal-credentials` the withdrawal credentials against which to match + - `privkey` the private key used to generat matching withdrawal credentials + - `mnemonic` the mnemonic used to generate matching withdrawal credentials + +```sh +$ ethdo validator keycheck --withdrawal-credentials=0x007e28dcf9029e8d92ca4b5d01c66c934e7f3110606f34ae3052cbf67bd3fc02 --mnemonic='abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' +Withdrawal credentials confirmed at path m/12381/3600/10/0 +``` + ### `attester` commands Attester commands focus on Ethereum 2 validators' actions as attesters.