From 7aeba4333860a6078b6f2499ddb19b8fc30158af Mon Sep 17 00:00:00 2001 From: Jim McDonald Date: Wed, 9 Dec 2020 20:33:30 +0000 Subject: [PATCH] Add validtor duties; update validator exit --- CHANGELOG.md | 4 + cmd/exitverify.go | 14 +- cmd/root.go | 2 + cmd/validator/duties/input.go | 71 +++++++ cmd/validator/duties/input_internal_test.go | 100 ++++++++++ cmd/validator/duties/output.go | 112 +++++++++++ cmd/validator/duties/output_internal_test.go | 83 ++++++++ cmd/validator/duties/process.go | 182 ++++++++++++++++++ cmd/validator/duties/process_internal_test.go | 60 ++++++ cmd/validator/duties/run.go | 50 +++++ cmd/validator/exit/input.go | 2 +- cmd/validator/exit/input_internal_test.go | 51 ++--- cmd/validator/exit/output.go | 2 +- cmd/validator/exit/output_internal_test.go | 2 +- cmd/validatorduties.go | 61 ++++++ docs/usage.md | 4 +- go.mod | 2 +- go.sum | 2 + util/beaconnode.go | 4 + util/misc.go | 2 +- util/validatorexitdata.go | 12 +- 21 files changed, 780 insertions(+), 42 deletions(-) create mode 100644 cmd/validator/duties/input.go create mode 100644 cmd/validator/duties/input_internal_test.go create mode 100644 cmd/validator/duties/output.go create mode 100644 cmd/validator/duties/output_internal_test.go create mode 100644 cmd/validator/duties/process.go create mode 100644 cmd/validator/duties/process_internal_test.go create mode 100644 cmd/validator/duties/run.go create mode 100644 cmd/validatorduties.go diff --git a/CHANGELOG.md b/CHANGELOG.md index feedb7f..666988e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +Development: + - fix issue where base directory was ignored for wallet creation + - new "validator duties" command to display known duties for a given validator + - update go-eth2-client to display correct validator status from prysm 1.7.2: - new "account derive" command to derive keys directly from a mnemonic and derivation path - add more output to "deposit verify" to explain operation diff --git a/cmd/exitverify.go b/cmd/exitverify.go index 2698598..174b5ee 100644 --- a/cmd/exitverify.go +++ b/cmd/exitverify.go @@ -50,8 +50,8 @@ In quiet mode this will return 0 if the the exit is verified correctly, otherwis account, err := exitVerifyAccount(ctx) errCheck(err, "Failed to obtain account") - assert(viper.GetString("exit.data") != "", "exit data is required") - data, err := obtainExitData(viper.GetString("exit.Data")) + assert(viper.GetString("exit") != "", "exit is required") + data, err := obtainExitData(viper.GetString("exit")) errCheck(err, "Failed to obtain exit data") // Confirm signature is good. @@ -65,12 +65,12 @@ In quiet mode this will return 0 if the the exit is verified correctly, otherwis var exitDomain spec.Domain copy(exitDomain[:], domain) exit := &spec.VoluntaryExit{ - Epoch: data.Data.Message.Epoch, - ValidatorIndex: data.Data.Message.ValidatorIndex, + Epoch: data.Exit.Message.Epoch, + ValidatorIndex: data.Exit.Message.ValidatorIndex, } exitRoot, err := exit.HashTreeRoot() errCheck(err, "Failed to obtain exit hash tree root") - sig, err := e2types.BLSSignatureFromBytes(data.Data.Signature[:]) + sig, err := e2types.BLSSignatureFromBytes(data.Exit.Signature[:]) errCheck(err, "Invalid signature") verified, err := util.VerifyRoot(account, exitRoot, exitDomain, sig) errCheck(err, "Failed to verify voluntary exit") @@ -134,12 +134,12 @@ func exitVerifyAccount(ctx context.Context) (e2wtypes.Account, error) { func init() { exitCmd.AddCommand(exitVerifyCmd) exitFlags(exitVerifyCmd) - exitVerifyCmd.Flags().String("data", "", "JSON data, or path to JSON data") + exitVerifyCmd.Flags().String("exit", "", "JSON data, or path to JSON data") exitVerifyCmd.Flags().StringVar(&exitVerifyPubKey, "pubkey", "", "Public key for which to verify exit") } func exitVerifyBindings() { - if err := viper.BindPFlag("data", exitVerifyCmd.Flags().Lookup("data")); err != nil { + if err := viper.BindPFlag("exit", exitVerifyCmd.Flags().Lookup("exit")); err != nil { panic(err) } } diff --git a/cmd/root.go b/cmd/root.go index 08faa51..125e7f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -76,6 +76,8 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { exitVerifyBindings() case "validator/depositdata": validatorDepositdataBindings() + case "validator/duties": + validatorDutiesBindings() case "validator/exit": validatorExitBindings() case "validator/info": diff --git a/cmd/validator/duties/input.go b/cmd/validator/duties/input.go new file mode 100644 index 0000000..2299042 --- /dev/null +++ b/cmd/validator/duties/input.go @@ -0,0 +1,71 @@ +// Copyright © 2020 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 validatorduties + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +type dataIn struct { + // System. + timeout time.Duration + quiet bool + verbose bool + debug bool + // Ethereum 2 connection. + eth2Client string + allowInsecure bool + // Operation. + account string + pubKey string + index 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") + + // Ethereum 2 connection. + data.eth2Client = viper.GetString("connection") + if data.eth2Client == "" { + return nil, errors.New("connection is required") + } + data.allowInsecure = viper.GetBool("allow-insecure-connections") + + // Account. + data.account = viper.GetString("account") + + // PubKey. + data.pubKey = viper.GetString("pubkey") + + // ID. + data.index = viper.GetString("index") + + if data.account == "" && data.pubKey == "" && data.index == "" { + return nil, errors.New("account, pubkey or index required") + } + + return data, nil +} diff --git a/cmd/validator/duties/input_internal_test.go b/cmd/validator/duties/input_internal_test.go new file mode 100644 index 0000000..a15872c --- /dev/null +++ b/cmd/validator/duties/input_internal_test.go @@ -0,0 +1,100 @@ +// Copyright © 2020 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 validatorduties + +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{}{ + "connection": "http://locahost:4000", + "pubkey": "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c", + }, + err: "timeout is required", + }, + { + name: "AccountMissing", + vars: map[string]interface{}{ + "timeout": "5s", + "connection": "http://locahost:4000", + }, + err: "account, pubkey or index required", + }, + { + name: "ConnectionMissing", + vars: map[string]interface{}{ + "timeout": "5s", + "pubkey": "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c", + }, + err: "connection is 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/validator/duties/output.go b/cmd/validator/duties/output.go new file mode 100644 index 0000000..9bbd4c1 --- /dev/null +++ b/cmd/validator/duties/output.go @@ -0,0 +1,112 @@ +// Copyright © 2019, 2020 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 validatorduties + +import ( + "context" + "fmt" + "strings" + "time" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/pkg/errors" +) + +type dataOut struct { + debug bool + quiet bool + verbose bool + genesisTime time.Time + slotDuration time.Duration + slotsPerEpoch uint64 + thisEpochAttesterDuty *api.AttesterDuty + thisEpochProposerDuties []*api.ProposerDuty + nextEpochAttesterDuty *api.AttesterDuty +} + +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{} + + now := time.Now() + builder.WriteString("Current time: ") + builder.WriteString(now.Format("15:04:05\n")) + + if data.thisEpochAttesterDuty != nil { + thisEpochAttesterSlot := data.thisEpochAttesterDuty.Slot + thisSlotStart := data.genesisTime.Add(time.Duration(thisEpochAttesterSlot) * data.slotDuration) + thisSlotEnd := thisSlotStart.Add(data.slotDuration) + if thisSlotEnd.After(now) { + builder.WriteString("Upcoming attestation slot this epoch: ") + builder.WriteString(thisSlotStart.Format("15:04:05")) + builder.WriteString(" - ") + builder.WriteString(thisSlotEnd.Format("15:04:05 (")) + until := thisSlotStart.Sub(now) + if until > 0 { + builder.WriteString(fmt.Sprintf("%ds until start of slot)\n", int(until.Seconds()))) + } else { + builder.WriteString("\n") + } + + } + } + + for _, proposerDuty := range data.thisEpochProposerDuties { + proposerSlot := proposerDuty.Slot + proposerSlotStart := data.genesisTime.Add(time.Duration(proposerSlot) * data.slotDuration) + proposerSlotEnd := proposerSlotStart.Add(data.slotDuration) + builder.WriteString("Upcoming proposer slot this epoch: ") + builder.WriteString(proposerSlotStart.Format("15:04:05")) + builder.WriteString(" - ") + builder.WriteString(proposerSlotEnd.Format("15:04:05 (")) + until := proposerSlotStart.Sub(now) + if until > 0 { + builder.WriteString(fmt.Sprintf("%ds until start of slot)\n", int(until.Seconds()))) + } else { + builder.WriteString("\n") + } + } + + if data.nextEpochAttesterDuty != nil { + nextEpochAttesterSlot := data.nextEpochAttesterDuty.Slot + nextSlotStart := data.genesisTime.Add(time.Duration(nextEpochAttesterSlot) * data.slotDuration) + nextSlotEnd := nextSlotStart.Add(data.slotDuration) + builder.WriteString("Upcoming attestation slot next epoch: ") + builder.WriteString(nextSlotStart.Format("15:04:05")) + builder.WriteString(" - ") + builder.WriteString(nextSlotEnd.Format("15:04:05 (")) + until := nextSlotStart.Sub(now) + builder.WriteString(fmt.Sprintf("%ds until start of slot)\n", int(until.Seconds()))) + + nextEpoch := uint64(data.nextEpochAttesterDuty.Slot) / data.slotsPerEpoch + nextEpochStart := data.genesisTime.Add(time.Duration(nextEpoch*data.slotsPerEpoch) * data.slotDuration) + builder.WriteString("Next epoch starts ") + builder.WriteString(nextEpochStart.Format("15:04:05 (")) + until = nextEpochStart.Sub(now) + if until > 0 { + builder.WriteString(fmt.Sprintf("%ds until start of epoch)\n", int(until.Seconds()))) + } else { + builder.WriteString("\n") + } + } + + return builder.String(), nil +} diff --git a/cmd/validator/duties/output_internal_test.go b/cmd/validator/duties/output_internal_test.go new file mode 100644 index 0000000..8e561f3 --- /dev/null +++ b/cmd/validator/duties/output_internal_test.go @@ -0,0 +1,83 @@ +// Copyright © 2019, 2020 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 validatorduties + +import ( + "context" + "strings" + "testing" + "time" + + api "github.com/attestantio/go-eth2-client/api/v1" + spec "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestOutput(t *testing.T) { + tests := []struct { + name string + dataOut *dataOut + expected []string + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "Empty", + dataOut: &dataOut{}, + expected: []string{"Current time"}, + }, + { + name: "Found", + dataOut: &dataOut{ + genesisTime: time.Unix(16000000000, 0), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + thisEpochAttesterDuty: &api.AttesterDuty{ + Slot: spec.Slot(1), + }, + thisEpochProposerDuties: []*api.ProposerDuty{ + { + Slot: spec.Slot(2), + }, + }, + nextEpochAttesterDuty: &api.AttesterDuty{ + Slot: spec.Slot(40), + }, + }, + expected: []string{ + "Current time", + "Upcoming attestation slot this epoch", + "Upcoming proposer slot this epoch", + "Upcoming attestation slot next epoch", + }, + }, + } + + 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) + for _, expected := range test.expected { + require.True(t, strings.Contains(res, expected)) + } + } + }) + } +} diff --git a/cmd/validator/duties/process.go b/cmd/validator/duties/process.go new file mode 100644 index 0000000..c9f7f6e --- /dev/null +++ b/cmd/validator/duties/process.go @@ -0,0 +1,182 @@ +// Copyright © 2019, 2020 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 validatorduties + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + api "github.com/attestantio/go-eth2-client/api/v1" + spec "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/wealdtech/ethdo/util" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" +) + +func process(ctx context.Context, data *dataIn) (*dataOut, error) { + if data == nil { + return nil, errors.New("no data") + } + + // Ethereum 2 client. + eth2Client, err := util.ConnectToBeaconNode(ctx, data.eth2Client, data.timeout, data.allowInsecure) + if err != nil { + return nil, err + } + + results := &dataOut{ + debug: data.debug, + quiet: data.quiet, + verbose: data.verbose, + } + + validatorIndex, err := validatorIndex(ctx, eth2Client, data) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain validator index") + } + + // Fetch duties for this and next epoch. + thisEpoch, err := currentEpoch(ctx, eth2Client) + if err != nil { + return nil, errors.Wrap(err, "failed to calculate current epoch") + } + thisEpochAttesterDuty, err := attesterDuty(ctx, eth2Client, validatorIndex, thisEpoch) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain this epoch duty for validator") + } + results.thisEpochAttesterDuty = thisEpochAttesterDuty + + thisEpochProposerDuties, err := proposerDuties(ctx, eth2Client, validatorIndex, thisEpoch) + results.thisEpochProposerDuties = thisEpochProposerDuties + + nextEpoch := thisEpoch + 1 + nextEpochAttesterDuty, err := attesterDuty(ctx, eth2Client, validatorIndex, nextEpoch) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain next epoch duty for validator") + } + results.nextEpochAttesterDuty = nextEpochAttesterDuty + + genesis, err := eth2Client.(eth2client.GenesisProvider).Genesis(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain genesis data") + } + results.genesisTime = genesis.GenesisTime + + config, err := eth2Client.(eth2client.SpecProvider).Spec(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain beacon chain configuration") + } + results.slotsPerEpoch = config["SLOTS_PER_EPOCH"].(uint64) + results.slotDuration = config["SECONDS_PER_SLOT"].(time.Duration) + + return results, nil +} + +func attesterDuty(ctx context.Context, eth2Client eth2client.Service, validatorIndex spec.ValidatorIndex, epoch spec.Epoch) (*api.AttesterDuty, error) { + // Find the attesting slot for the given epoch. + duties, err := eth2Client.(eth2client.AttesterDutiesProvider).AttesterDuties(ctx, epoch, []spec.ValidatorIndex{validatorIndex}) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain attester duties") + } + + if len(duties) == 0 { + return nil, errors.New("validator does not have duty for that epoch") + } + + return duties[0], nil +} + +func proposerDuties(ctx context.Context, eth2Client eth2client.Service, validatorIndex spec.ValidatorIndex, epoch spec.Epoch) ([]*api.ProposerDuty, error) { + // Fetch the proposer duties for this epoch. + proposerDuties, err := eth2Client.(eth2client.ProposerDutiesProvider).ProposerDuties(ctx, epoch, []spec.ValidatorIndex{validatorIndex}) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain proposer duties") + } + + return proposerDuties, nil +} + +func currentEpoch(ctx context.Context, eth2Client eth2client.Service) (spec.Epoch, error) { + config, err := eth2Client.(eth2client.SpecProvider).Spec(ctx) + if err != nil { + return 0, 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 0, errors.Wrap(err, "failed to obtain genesis data") + } + + if genesis.GenesisTime.After(time.Now()) { + return spec.Epoch(0), nil + } + return spec.Epoch(uint64(time.Since(genesis.GenesisTime).Seconds()) / (uint64(slotDuration.Seconds()) * slotsPerEpoch)), nil +} + +// validatorIndex obtains the index of a validator +func validatorIndex(ctx context.Context, eth2Client eth2client.Service, data *dataIn) (spec.ValidatorIndex, error) { + switch { + case data.account != "": + ctx, cancel := context.WithTimeout(context.Background(), data.timeout) + defer cancel() + _, account, err := util.WalletAndAccountFromPath(ctx, data.account) + if err != nil { + return 0, errors.Wrap(err, "failed to obtain account") + } + return accountToIndex(ctx, account, eth2Client) + case data.pubKey != "": + pubKeyBytes, err := hex.DecodeString(strings.TrimPrefix(data.pubKey, "0x")) + if err != nil { + return 0, errors.Wrap(err, fmt.Sprintf("failed to decode public key %s", data.pubKey)) + } + account, err := util.NewScratchAccount(nil, pubKeyBytes) + if err != nil { + return 0, errors.Wrap(err, fmt.Sprintf("invalid public key %s", data.pubKey)) + } + return accountToIndex(ctx, account, eth2Client) + case data.index != "": + val, err := strconv.ParseUint(data.index, 10, 64) + if err != nil { + return 0, err + } + return spec.ValidatorIndex(val), nil + default: + return 0, errors.New("no validator") + } +} + +func accountToIndex(ctx context.Context, account e2wtypes.Account, eth2Client eth2client.Service) (spec.ValidatorIndex, error) { + pubKey, err := util.BestPublicKey(account) + if err != nil { + return 0, err + } + + pubKeys := make([]spec.BLSPubKey, 1) + copy(pubKeys[0][:], pubKey.Marshal()) + validators, err := eth2Client.(eth2client.ValidatorsProvider).ValidatorsByPubKey(ctx, "head", pubKeys) + if err != nil { + return 0, err + } + + for index := range validators { + return index, nil + } + return 0, errors.New("validator not found") +} diff --git a/cmd/validator/duties/process_internal_test.go b/cmd/validator/duties/process_internal_test.go new file mode 100644 index 0000000..97e0b71 --- /dev/null +++ b/cmd/validator/duties/process_internal_test.go @@ -0,0 +1,60 @@ +// Copyright © 2019, 2020 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 validatorduties + +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 + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "Client", + dataIn: &dataIn{ + timeout: 5 * time.Second, + eth2Client: os.Getenv("ETHDO_TEST_CONNECTION"), + allowInsecure: true, + index: "1", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := process(context.Background(), test.dataIn) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/validator/duties/run.go b/cmd/validator/duties/run.go new file mode 100644 index 0000000..3ffc028 --- /dev/null +++ b/cmd/validator/duties/run.go @@ -0,0 +1,50 @@ +// Copyright © 2019, 2020 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 validatorduties + +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/validator/exit/input.go b/cmd/validator/exit/input.go index 4068078..7cd682a 100644 --- a/cmd/validator/exit/input.go +++ b/cmd/validator/exit/input.go @@ -79,7 +79,7 @@ func inputJSON(ctx context.Context, data *dataIn) (*dataIn, error) { if err != nil { return nil, err } - data.signedVoluntaryExit = validatorData.Data + data.signedVoluntaryExit = validatorData.Exit return inputChainData(ctx, data) } diff --git a/cmd/validator/exit/input_internal_test.go b/cmd/validator/exit/input_internal_test.go index 151f21b..7329568 100644 --- a/cmd/validator/exit/input_internal_test.go +++ b/cmd/validator/exit/input_internal_test.go @@ -91,9 +91,10 @@ func TestInput(t *testing.T) { { name: "KeyGood", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", }, res: &dataIn{ timeout: 5 * time.Second, @@ -102,9 +103,10 @@ func TestInput(t *testing.T) { { name: "AccountUnknown", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "account": "Test wallet/unknown", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "account": "Test wallet/unknown", }, res: &dataIn{ timeout: 5 * time.Second, @@ -114,9 +116,10 @@ func TestInput(t *testing.T) { { name: "AccountGood", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "account": "Test wallet/Interop 0", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "account": "Test wallet/Interop 0", }, res: &dataIn{ timeout: 5 * time.Second, @@ -125,9 +128,10 @@ func TestInput(t *testing.T) { { name: "JSONInvalid", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "exit": `invalid`, + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "exit": `invalid`, }, res: &dataIn{ timeout: 5 * time.Second, @@ -137,9 +141,10 @@ func TestInput(t *testing.T) { { name: "JSONGood", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "exit": `{"message":{"epoch":"123","validator_index":"456"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"}`, + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "exit": `{"exit":{"message":{"epoch":"123","validator_index":"456"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"},"fork_version":"0x00002009"}`, }, res: &dataIn{ timeout: 5 * time.Second, @@ -148,19 +153,21 @@ func TestInput(t *testing.T) { { name: "ClientBad", vars: map[string]interface{}{ - "connection": "localhost:1", - "timeout": "5s", - "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", + "connection": "localhost:1", + "allow-insecure-connections": true, + "timeout": "5s", + "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", }, err: "failed to connect to Ethereum 2 beacon node: failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method", }, { name: "EpochProvided", vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "timeout": "5s", - "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", - "epoch": "123", + "connection": os.Getenv("ETHDO_TEST_CONNECTION"), + "allow-insecure-connections": true, + "timeout": "5s", + "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", + "epoch": "123", }, res: &dataIn{ timeout: 5 * time.Second, diff --git a/cmd/validator/exit/output.go b/cmd/validator/exit/output.go index 309d7b8..8c42308 100644 --- a/cmd/validator/exit/output.go +++ b/cmd/validator/exit/output.go @@ -46,7 +46,7 @@ func output(ctx context.Context, data *dataOut) (string, error) { func outputJSON(ctx context.Context, data *dataOut) (string, error) { validatorExitData := &util.ValidatorExitData{ - Data: data.signedVoluntaryExit, + Exit: data.signedVoluntaryExit, ForkVersion: data.forkVersion, } bytes, err := json.Marshal(validatorExitData) diff --git a/cmd/validator/exit/output_internal_test.go b/cmd/validator/exit/output_internal_test.go index 144ee99..246976d 100644 --- a/cmd/validator/exit/output_internal_test.go +++ b/cmd/validator/exit/output_internal_test.go @@ -79,7 +79,7 @@ func TestOutput(t *testing.T) { }, }, }, - res: `{"data":{"message":{"epoch":"123","validator_index":"456"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"},"fork_version":"0x01020304"}`, + res: `{"exit":{"message":{"epoch":"123","validator_index":"456"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"},"fork_version":"0x01020304"}`, }, } diff --git a/cmd/validatorduties.go b/cmd/validatorduties.go new file mode 100644 index 0000000..a744c1e --- /dev/null +++ b/cmd/validatorduties.go @@ -0,0 +1,61 @@ +// Copyright © 2020 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" + validatorduties "github.com/wealdtech/ethdo/cmd/validator/duties" +) + +var validatorDutiesCmd = &cobra.Command{ + Use: "duties", + Short: "List known duties for a validator", + Long: `List known duties for a validator. For example: + + ethdo validator duties --account=Validators/One + +Attester duties are known for the current and next epoch. Proposer duties are known for the current epoch. + +In quiet mode this will return 0 if the the duties have been obtained, otherwise 1.`, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := validatorduties.Run(cmd) + if err != nil { + return err + } + if viper.GetBool("quiet") { + return nil + } + fmt.Printf(res) + return nil + }, +} + +func init() { + validatorCmd.AddCommand(validatorDutiesCmd) + validatorFlags(validatorDutiesCmd) + validatorDutiesCmd.Flags().String("pubkey", "", "validator public key for duties") + validatorDutiesCmd.Flags().String("index", "", "validator index for duties") +} + +func validatorDutiesBindings() { + if err := viper.BindPFlag("pubkey", validatorDutiesCmd.Flags().Lookup("pubkey")); err != nil { + panic(err) + } + if err := viper.BindPFlag("index", validatorDutiesCmd.Flags().Lookup("index")); err != nil { + panic(err) + } +} diff --git a/docs/usage.md b/docs/usage.md index d4aa091..5c6423a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -348,12 +348,12 @@ Exit commands focus on information about validator exits generated by the `ethdo #### `verify` `ethdo exit verify` verifies the validator exit information in a JSON file generated by the `ethdo validator exit` command. Options include: - - `data`: either a path to the JSON file or the JSON itself + - `exit`: either a path to the JSON file or the JSON itself - `account`: the account that generated the exit transaction (if available as an account, in format "wallet/account") - `pubkey`: the public key of the account that generated the exit transaction ```sh -$ ethdo exit verify --data=${HOME}/exit.json --pubkey=0xa951530887ae2494a8cc4f11cf186963b0051ac4f7942375585b9cf98324db1e532a67e521d0fcaab510edad1352394c +$ ethdo exit verify --exit=${HOME}/exit.json --pubkey=0xa951530887ae2494a8cc4f11cf186963b0051ac4f7942375585b9cf98324db1e532a67e521d0fcaab510edad1352394c ``` ### `node` commands diff --git a/go.mod b/go.mod index f92caf5..7396b26 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.13 require ( github.com/OneOfOne/xxhash v1.2.5 // indirect github.com/attestantio/dirk v0.9.3 - github.com/attestantio/go-eth2-client v0.6.15 + github.com/attestantio/go-eth2-client v0.6.16 github.com/aws/aws-sdk-go v1.36.2 // indirect github.com/ferranbt/fastssz v0.0.0-20201207112544-98a5de30d648 github.com/fsnotify/fsnotify v1.4.9 // indirect diff --git a/go.sum b/go.sum index b1c899d..46e3008 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/attestantio/go-eth2-client v0.6.10 h1:PMNBMLk6xfMEUqhaUnsI0/HZRrstZF1 github.com/attestantio/go-eth2-client v0.6.10/go.mod h1:ODAZ4yS1YYYew/EsgGsVb/siNEoa505CrGsvlVFdkfo= github.com/attestantio/go-eth2-client v0.6.15 h1:GNkiSF2Dqp6qahMXMW8r8Wy61WEvytnAM+rEyutdfv8= github.com/attestantio/go-eth2-client v0.6.15/go.mod h1:Hya4fp1ZLWAFI64qMhNbQgfY4StWiHulW4CFwu+vP3s= +github.com/attestantio/go-eth2-client v0.6.16 h1:2Xn5RKqXUXfxLYVHn3D6l0FK7NUCjzl5v4oYIxcxc5k= +github.com/attestantio/go-eth2-client v0.6.16/go.mod h1:Hya4fp1ZLWAFI64qMhNbQgfY4StWiHulW4CFwu+vP3s= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.32.6 h1:HoswAabUWgnrUF7X/9dr4WRgrr8DyscxXvTDm7Qw/5c= diff --git a/util/beaconnode.go b/util/beaconnode.go index cdc9f51..785f6fb 100644 --- a/util/beaconnode.go +++ b/util/beaconnode.go @@ -27,6 +27,10 @@ import ( // ConnectToBeaconNode connects to a beacon node at the given address. func ConnectToBeaconNode(ctx context.Context, address string, timeout time.Duration, allowInsecure bool) (eth2client.Service, error) { + if timeout == 0 { + return nil, errors.New("no timeout specified") + } + if !allowInsecure { // Ensure the connection is either secure or local. connectionURL, err := url.Parse(address) diff --git a/util/misc.go b/util/misc.go index 0a43f56..ba379a2 100644 --- a/util/misc.go +++ b/util/misc.go @@ -56,7 +56,7 @@ func SetupStore() error { opts = append(opts, filesystem.WithPassphrase([]byte(GetStorePassphrase()))) } if GetBaseDir() != "" { - opts = append(opts, filesystem.WithLocation(viper.GetString("base-dir"))) + opts = append(opts, filesystem.WithLocation(GetBaseDir())) } store = filesystem.New(opts...) default: diff --git a/util/validatorexitdata.go b/util/validatorexitdata.go index 2b57a73..dc991a7 100644 --- a/util/validatorexitdata.go +++ b/util/validatorexitdata.go @@ -25,19 +25,19 @@ import ( // ValidatorExitData contains data for a validator exit. type ValidatorExitData struct { - Data *spec.SignedVoluntaryExit + Exit *spec.SignedVoluntaryExit ForkVersion spec.Version } type validatorExitJSON struct { - Data *spec.SignedVoluntaryExit `json:"data"` + Exit *spec.SignedVoluntaryExit `json:"exit"` ForkVersion string `json:"fork_version"` } // MarshalJSON implements custom JSON marshaller. func (d *ValidatorExitData) MarshalJSON() ([]byte, error) { validatorExitJSON := &validatorExitJSON{ - Data: d.Data, + Exit: d.Exit, ForkVersion: fmt.Sprintf("%#x", d.ForkVersion), } return json.Marshal(validatorExitJSON) @@ -51,10 +51,10 @@ func (d *ValidatorExitData) UnmarshalJSON(data []byte) error { return errors.Wrap(err, "failed to unmarshal JSON") } - if validatorExitJSON.Data == nil { - return errors.New("data missing") + if validatorExitJSON.Exit == nil { + return errors.New("exit missing") } - d.Data = validatorExitJSON.Data + d.Exit = validatorExitJSON.Exit if validatorExitJSON.ForkVersion == "" { return errors.New("fork version missing")