diff --git a/cmd/validatorexit.go b/cmd/validatorexit.go index ec77f5e..55ba8a4 100644 --- a/cmd/validatorexit.go +++ b/cmd/validatorexit.go @@ -15,18 +15,27 @@ package cmd import ( "context" + "encoding/hex" + "encoding/json" "fmt" "os" + "strings" "time" + "github.com/pkg/errors" ethpb "github.com/prysmaticlabs/ethereumapis/eth/v1alpha1" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/wealdtech/ethdo/grpc" + "github.com/wealdtech/ethdo/util" e2types "github.com/wealdtech/go-eth2-types/v2" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) var validatorExitEpoch int64 +var validatorExitKey string +var validatorExitJSON string +var validatorExitJSONOutput bool var validatorExitCmd = &cobra.Command{ Use: "exit", @@ -35,60 +44,120 @@ var validatorExitCmd = &cobra.Command{ ethdo validator exit --account=primary/validator --passphrase=secret -In quiet mode this will return 0 if the transaction has been sent, otherwise 1.`, +In quiet mode this will return 0 if the transaction has been generated, otherwise 1.`, Run: func(cmd *cobra.Command, args []string) { - // Sanity checking and setup. - assert(rootAccount != "", "--account is required") - account, err := accountFromPath(rootAccount) - errCheck(err, "Failed to access account") - err = connect() + err := connect() errCheck(err, "Failed to obtain connect to Ethereum 2 beacon chain node") - // Beacon chain config required for later work. - config, err := grpc.FetchChainConfig(eth2GRPCConn) - errCheck(err, "Failed to obtain beacon chain configuration") + exit, signature := validatorExitHandleInput() + validatorExitHandleExit(exit, signature) + os.Exit(_exitSuccess) + }, +} - // Fetch the validator's index. - index, err := grpc.FetchValidatorIndex(eth2GRPCConn, account) - errCheck(err, "Failed to obtain validator index") - outputIf(debug, fmt.Sprintf("Validator index is %d", index)) +func validatorExitHandleInput() (*ethpb.VoluntaryExit, e2types.Signature) { + if validatorExitJSON != "" { + return validatorExitHandleJSONInput(validatorExitJSON) + } + if rootAccount != "" { + account, err := accountFromPath(rootAccount) + errCheck(err, "Failed to access account") + return validatorExitHandleAccountInput(account) + } + if validatorExitKey != "" { + privKeyBytes, err := hex.DecodeString(strings.TrimPrefix(validatorExitKey, "0x")) + errCheck(err, fmt.Sprintf("Failed to decode key %s", validatorExitKey)) + account, err := util.NewScratchAccount(privKeyBytes, nil) + errCheck(err, "Invalid private key") + return validatorExitHandleAccountInput(account) + } + die("one of --json, --account or --key is required") + return nil, nil +} - // Ensure the validator is active. - state, err := grpc.FetchValidatorState(eth2GRPCConn, account) - errCheck(err, "Failed to obtain validator state") - outputIf(debug, fmt.Sprintf("Validator state is %v", state)) - assert(state == ethpb.ValidatorStatus_ACTIVE, "Validator must be active to exit") +func validatorExitHandleJSONInput(input string) (*ethpb.VoluntaryExit, e2types.Signature) { + data := &validatorExitData{} + err := json.Unmarshal([]byte(input), data) + errCheck(err, "Invalid JSON input") + exit := ðpb.VoluntaryExit{ + Epoch: data.Epoch, + ValidatorIndex: data.ValidatorIndex, + } + signature, err := e2types.BLSSignatureFromBytes(data.Signature) + errCheck(err, "Invalid signature") + return exit, signature +} +func validatorExitHandleAccountInput(account e2wtypes.Account) (*ethpb.VoluntaryExit, e2types.Signature) { + exit := ðpb.VoluntaryExit{} + + // Beacon chain config required for later work. + config, err := grpc.FetchChainConfig(eth2GRPCConn) + errCheck(err, "Failed to obtain beacon chain configuration") + secondsPerEpoch := config["SecondsPerSlot"].(uint64) * config["SlotsPerEpoch"].(uint64) + + // Fetch the validator's index. + index, err := grpc.FetchValidatorIndex(eth2GRPCConn, account) + errCheck(err, "Failed to obtain validator index") + outputIf(debug, fmt.Sprintf("Validator index is %d", index)) + exit.ValidatorIndex = index + + // Ensure the validator is active. + state, err := grpc.FetchValidatorState(eth2GRPCConn, account) + errCheck(err, "Failed to obtain validator state") + outputIf(debug, fmt.Sprintf("Validator state is %v", state)) + assert(state == ethpb.ValidatorStatus_ACTIVE, "Validator must be active to exit") + + if validatorExitEpoch < 0 { // Ensure the validator has been active long enough to exit. validator, err := grpc.FetchValidator(eth2GRPCConn, account) errCheck(err, "Failed to obtain validator information") outputIf(debug, fmt.Sprintf("Activation epoch is %v", validator.ActivationEpoch)) earliestExitEpoch := validator.ActivationEpoch + config["PersistentCommitteePeriod"].(uint64) - secondsPerEpoch := config["SecondsPerSlot"].(uint64) * config["SlotsPerEpoch"].(uint64) genesisTime, err := grpc.FetchGenesis(eth2GRPCConn) errCheck(err, "Failed to obtain genesis time") currentEpoch := uint64(time.Since(genesisTime).Seconds()) / secondsPerEpoch assert(currentEpoch >= earliestExitEpoch, fmt.Sprintf("Validator cannot exit until %s ( epoch %d)", genesisTime.Add(time.Duration(secondsPerEpoch*earliestExitEpoch)*time.Second).Format(time.UnixDate), earliestExitEpoch)) outputIf(verbose, "Validator confirmed to be in a suitable state") + exit.Epoch = currentEpoch + } else { + // User-specified epoch; no checks. + exit.Epoch = uint64(validatorExitEpoch) + } - // Set up the transaction. - exit := ðpb.VoluntaryExit{ - Epoch: currentEpoch, - ValidatorIndex: index, + // TODO fetch current fork version from config (currently using genesis fork version) + currentForkVersion := config["GenesisForkVersion"].([]byte) + // TODO fetch genesis validators root from API. + genesisValidatorsRoot := []byte{ + 0x55, 0x13, 0x8e, 0x46, 0xa2, 0x44, 0x2d, 0x2f, + 0xfd, 0x89, 0x55, 0x0a, 0x0f, 0x30, 0x56, 0x21, + 0x27, 0xbc, 0x56, 0xe6, 0x24, 0x4d, 0x0f, 0xa2, + 0xb5, 0x18, 0xa3, 0xf4, 0xce, 0x19, 0x33, 0x7e, + } + domain := e2types.Domain(e2types.DomainVoluntaryExit, currentForkVersion, genesisValidatorsRoot) + + err = account.Unlock([]byte(rootAccountPassphrase)) + errCheck(err, "Failed to unlock account; please confirm passphrase is correct") + signature, err := signStruct(account, exit, domain) + errCheck(err, "Failed to sign exit proposal") + + return exit, signature +} + +// validatorExitHandleExit handles the exit request. +func validatorExitHandleExit(exit *ethpb.VoluntaryExit, signature e2types.Signature) { + if validatorExitJSONOutput { + data := &validatorExitData{ + Epoch: exit.Epoch, + ValidatorIndex: exit.ValidatorIndex, + Signature: signature.Marshal(), } - // TODO fetch current fork version from config (currently using genesis fork version) - currentForkVersion := config["GenesisForkVersion"].([]byte) - // TODO fetch genesis validators root from somewhere. - //domain := e2types.Domain(e2types.DomainVoluntaryExit, currentForkVersion, genesisValidatorsRoot) - domain := e2types.Domain(e2types.DomainVoluntaryExit, currentForkVersion, e2types.ZeroGenesisValidatorsRoot) - - err = account.Unlock([]byte(rootAccountPassphrase)) - errCheck(err, "Failed to unlock account; please confirm passphrase is correct") - signature, err := signStruct(account, exit, domain) - errCheck(err, "Failed to sign exit proposal") - + res, err := json.Marshal(data) + errCheck(err, "Failed to generate JSON") + outputIf(!quiet, string(res)) + } else { proposal := ðpb.SignedVoluntaryExit{ Exit: exit, Signature: signature.Marshal(), @@ -97,17 +166,75 @@ In quiet mode this will return 0 if the transaction has been sent, otherwise 1.` validatorClient := ethpb.NewBeaconNodeValidatorClient(eth2GRPCConn) ctx, cancel := context.WithTimeout(context.Background(), viper.GetDuration("timeout")) defer cancel() - _, err = validatorClient.ProposeExit(ctx, proposal) + _, err := validatorClient.ProposeExit(ctx, proposal) errCheck(err, "Failed to propose exit") - outputIf(!quiet, "Validator exit transaction sent") - os.Exit(_exitSuccess) - }, + } } func init() { validatorCmd.AddCommand(validatorExitCmd) validatorFlags(validatorExitCmd) - validatorExitCmd.Flags().Int64Var(&validatorExitEpoch, "epoch", -1, "Epoch at which to exit (defaults to now)") + validatorExitCmd.Flags().Int64Var(&validatorExitEpoch, "epoch", -1, "Epoch at which to exit (defaults to current epoch)") + validatorExitCmd.Flags().StringVar(&validatorExitKey, "key", "", "Private key if account not known by ethdo") + validatorExitCmd.Flags().BoolVar(&validatorExitJSONOutput, "json-output", false, "Print JSON transaction; do not broadcast to network") + validatorExitCmd.Flags().StringVar(&validatorExitJSON, "json", "", "Use JSON as created by --json-output to exit") addTransactionFlags(validatorExitCmd) } + +type validatorExitData struct { + Epoch uint64 `json:"epoch"` + ValidatorIndex uint64 `json:"validator_index"` + Signature []byte `json:"signature"` +} + +// MarshalJSON implements custom JSON marshaller. +func (d *validatorExitData) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`{"epoch":%d,"validator_index":%d,"signature":"%#x"}`, d.Epoch, d.ValidatorIndex, d.Signature)), nil +} + +// UnmarshalJSON implements custom JSON unmarshaller. +func (d *validatorExitData) UnmarshalJSON(data []byte) error { + var v map[string]interface{} + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + if val, exists := v["epoch"]; exists { + var ok bool + epoch, ok := val.(float64) + if !ok { + return errors.New("epoch invalid") + } + d.Epoch = uint64(epoch) + } else { + return errors.New("epoch missing") + } + + if val, exists := v["validator_index"]; exists { + var ok bool + validatorIndex, ok := val.(float64) + if !ok { + return errors.New("validator_index invalid") + } + d.ValidatorIndex = uint64(validatorIndex) + } else { + return errors.New("validator_index missing") + } + + if val, exists := v["signature"]; exists { + signatureBytes, ok := val.(string) + if !ok { + return errors.New("signature invalid") + } + signature, err := hex.DecodeString(strings.TrimPrefix(signatureBytes, "0x")) + if err != nil { + return errors.Wrap(err, "signature invalid") + } + d.Signature = signature + } else { + return errors.New("signature missing") + } + + return nil +} diff --git a/cmd/validatorinfo.go b/cmd/validatorinfo.go index 28a9aaf..41ecc79 100644 --- a/cmd/validatorinfo.go +++ b/cmd/validatorinfo.go @@ -52,7 +52,7 @@ In quiet mode this will return 0 if the validator information can be obtained, o } else { pubKeyBytes, err := hex.DecodeString(strings.TrimPrefix(validatorInfoPubKey, "0x")) errCheck(err, fmt.Sprintf("Failed to decode public key %s", validatorInfoPubKey)) - account, err = util.NewScratchAccount(pubKeyBytes) + account, err = util.NewScratchAccount(nil, pubKeyBytes) errCheck(err, fmt.Sprintf("Invalid public key %s", validatorInfoPubKey)) } diff --git a/cmd/version.go b/cmd/version.go index 6085c4e..701f818 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -30,7 +30,7 @@ var versionCmd = &cobra.Command{ ethdo version.`, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("1.4.2") + fmt.Println("1.4.3") if viper.GetBool("verbose") { buildInfo, ok := dbg.ReadBuildInfo() if ok { diff --git a/docs/usage.md b/docs/usage.md index 059b87d..1b4a6bf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -339,12 +339,21 @@ Validator commands focus on interaction with Ethereum 2 validators. #### `exit` -`ethdo validator exit` sends a transaction to the chain to tell an active validator to exit the validation queue. +`ethdo validator exit` sends a transaction to the chain to tell an active validator to exit the validation queue. Options include: + - `epoch` specify an epoch before which this exit is not valid + - `json-output` generate JSON output rather than sending a transaction immediately + - `json` use JSON input created by the `--json-output` option rather than generate data from scratch ```sh $ ethdo validator exit --account=Validators/1 --passphrase="my validator secret" ``` +To send a transaction when the account is not accessible to ethdo accout you can use the validator's private key instead: + +```sh +$ ethdo validator exit --key=0x01e748d098d3bcb477d636f19d510399ae18205fadf9814ee67052f88c1f88c0 +``` + #### `info` `ethdo validator info` provides information for a given validator. diff --git a/util/scratchaccount.go b/util/scratchaccount.go index a56f3c9..8870478 100644 --- a/util/scratchaccount.go +++ b/util/scratchaccount.go @@ -22,17 +22,38 @@ import ( // ScratchAccount is an account that exists temporarily. type ScratchAccount struct { - id uuid.UUID - pubKey types.PublicKey + id uuid.UUID + privKey types.PrivateKey + pubKey types.PublicKey + unlocked bool } // NewScratchAccount creates a new local account. -func NewScratchAccount(pubKey []byte) (*ScratchAccount, error) { +func NewScratchAccount(privKey []byte, pubKey []byte) (*ScratchAccount, error) { + if len(privKey) > 0 { + return newScratchAccountFromPrivKey(privKey) + } else { + return newScratchAccountFromPubKey(privKey) + } +} + +func newScratchAccountFromPrivKey(privKey []byte) (*ScratchAccount, error) { + key, err := types.BLSPrivateKeyFromBytes(privKey) + if err != nil { + return nil, err + } + return &ScratchAccount{ + id: uuid.New(), + privKey: key, + pubKey: key.PublicKey(), + }, nil +} + +func newScratchAccountFromPubKey(pubKey []byte) (*ScratchAccount, error) { key, err := types.BLSPublicKeyFromBytes(pubKey) if err != nil { return nil, err } - return &ScratchAccount{ id: uuid.New(), pubKey: key, @@ -56,16 +77,24 @@ func (a *ScratchAccount) Path() string { } func (a *ScratchAccount) Lock() { + a.unlocked = false } func (a *ScratchAccount) Unlock([]byte) error { + a.unlocked = true return nil } func (a *ScratchAccount) IsUnlocked() bool { - return false + return a.unlocked } func (a *ScratchAccount) Sign(data []byte) (types.Signature, error) { - return nil, errors.New("Not implemented") + if !a.IsUnlocked() { + return nil, errors.New("locked") + } + if a.privKey == nil { + return nil, errors.New("no private key") + } + return a.privKey.Sign(data), nil }