diff --git a/.gitignore b/.gitignore index 70fa7ec..616fdea 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,8 @@ coverage.html # Vim *.sw? +# Local JSON files +*.json + # Local TODO TODO.md diff --git a/cmd/validator/credentials/set/chaininfo.go b/cmd/validator/credentials/set/chaininfo.go new file mode 100644 index 0000000..8fa404a --- /dev/null +++ b/cmd/validator/credentials/set/chaininfo.go @@ -0,0 +1,127 @@ +// Copyright © 2022 Weald Technology Trading. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validatorcredentialsset + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +type chainInfo struct { + Version uint64 + Validators []*validatorInfo + GenesisValidatorsRoot phase0.Root + Epoch phase0.Epoch + ForkVersion phase0.Version + Domain phase0.Domain +} + +type chainInfoJSON struct { + Version string `json:"version"` + Validators []*validatorInfo `json:"validators"` + GenesisValidatorsRoot string `json:"genesis_validators_root"` + Epoch string `json:"epoch"` + ForkVersion string `json:"fork_version"` + Domain string `json:"domain"` +} + +// MarshalJSON implements json.Marshaler. +func (v *chainInfo) MarshalJSON() ([]byte, error) { + return json.Marshal(&chainInfoJSON{ + Version: fmt.Sprintf("%d", v.Version), + Validators: v.Validators, + GenesisValidatorsRoot: fmt.Sprintf("%#x", v.GenesisValidatorsRoot), + Epoch: fmt.Sprintf("%d", v.Epoch), + ForkVersion: fmt.Sprintf("%#x", v.ForkVersion), + Domain: fmt.Sprintf("%#x", v.Domain), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *chainInfo) UnmarshalJSON(input []byte) error { + var data chainInfoJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Version == "" { + // Default to 1. + v.Version = 1 + } else { + version, err := strconv.ParseUint(data.Version, 10, 64) + if err != nil { + return errors.Wrap(err, "version invalid") + } + v.Version = version + } + + if len(data.Validators) == 0 { + return errors.New("validators missing") + } + v.Validators = data.Validators + + if data.GenesisValidatorsRoot == "" { + return errors.New("genesis validators root missing") + } + + genesisValidatorsRootBytes, err := hex.DecodeString(strings.TrimPrefix(data.GenesisValidatorsRoot, "0x")) + if err != nil { + return errors.Wrap(err, "genesis validators root invalid") + } + if len(genesisValidatorsRootBytes) != phase0.RootLength { + return errors.New("genesis validators root incorrect length") + } + copy(v.GenesisValidatorsRoot[:], genesisValidatorsRootBytes) + + if data.Epoch == "" { + return errors.New("epoch missing") + } + epoch, err := strconv.ParseUint(data.Epoch, 10, 64) + if err != nil { + return errors.Wrap(err, "epoch invalid") + } + v.Epoch = phase0.Epoch(epoch) + + if data.ForkVersion == "" { + return errors.New("fork version missing") + } + forkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(data.ForkVersion, "0x")) + if err != nil { + return errors.Wrap(err, "fork version invalid") + } + if len(forkVersionBytes) != phase0.ForkVersionLength { + return errors.New("fork version incorrect length") + } + copy(v.ForkVersion[:], forkVersionBytes) + + if data.Domain == "" { + return errors.New("domain missing") + } + domainBytes, err := hex.DecodeString(strings.TrimPrefix(data.Domain, "0x")) + if err != nil { + return errors.Wrap(err, "domain invalid") + } + if len(domainBytes) != phase0.DomainLength { + return errors.New("domain incorrect length") + } + copy(v.Domain[:], domainBytes) + + return nil +} diff --git a/cmd/validator/credentials/set/command.go b/cmd/validator/credentials/set/command.go index 7be9b37..ef64f44 100644 --- a/cmd/validator/credentials/set/command.go +++ b/cmd/validator/credentials/set/command.go @@ -18,14 +18,12 @@ import ( "time" consensusclient "github.com/attestantio/go-eth2-client" - apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/bellatrix" capella "github.com/attestantio/go-eth2-client/spec/capella" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" "github.com/spf13/viper" "github.com/wealdtech/ethdo/services/chaintime" "github.com/wealdtech/ethdo/util" - e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) type command struct { @@ -42,75 +40,65 @@ type command struct { path string privateKey string validator string - withdrawalAddress string - signedOperation string + withdrawalAddressStr string forkVersion string genesisValidatorsRoot string + prepareOffline bool // Beacon node connection. timeout time.Duration connection string allowInsecureConnections bool + // Information required to generate the operations. + withdrawalAddress bellatrix.ExecutionAddress + chainInfo *chainInfo + // Processing. - consensusClient consensusclient.Service - chainTime chaintime.Service - withdrawalAccount e2wtypes.Account - validatorInfo *apiv1.Validator - domain phase0.Domain - op *capella.BLSToExecutionChange + consensusClient consensusclient.Service + chainTime chaintime.Service // Output. - signedOp *capella.SignedBLSToExecutionChange + signedOperations []*capella.SignedBLSToExecutionChange } func newCommand(ctx context.Context) (*command, error) { c := &command{ - quiet: viper.GetBool("quiet"), - verbose: viper.GetBool("verbose"), - debug: viper.GetBool("debug"), - offline: viper.GetBool("offline"), - json: viper.GetBool("json"), + quiet: viper.GetBool("quiet"), + verbose: viper.GetBool("verbose"), + debug: viper.GetBool("debug"), + offline: viper.GetBool("offline"), + json: viper.GetBool("json"), + timeout: viper.GetDuration("timeout"), + connection: viper.GetString("connection"), + allowInsecureConnections: viper.GetBool("allow-insecure-connections"), + prepareOffline: viper.GetBool("prepare-offline"), + account: viper.GetString("account"), + passphrases: util.GetPassphrases(), + mnemonic: viper.GetString("mnemonic"), + path: viper.GetString("path"), + privateKey: viper.GetString("private-key"), + + validator: viper.GetString("validator"), + withdrawalAddressStr: viper.GetString("withdrawal-address"), + forkVersion: viper.GetString("fork-version"), + genesisValidatorsRoot: viper.GetString("genesis-validators-root"), } - // Timeout. - if viper.GetDuration("timeout") == 0 { + // Timeout is required. + if c.timeout == 0 { return nil, errors.New("timeout is required") } - c.timeout = viper.GetDuration("timeout") - c.connection = viper.GetString("connection") - c.allowInsecureConnections = viper.GetBool("allow-insecure-connections") - - c.account = viper.GetString("account") - c.passphrases = util.GetPassphrases() - c.mnemonic = viper.GetString("mnemonic") - c.path = viper.GetString("path") - c.privateKey = viper.GetString("private-key") - - if c.account == "" && c.mnemonic == "" && c.privateKey == "" { - return nil, errors.New("one of account, mnemonic or private key required") + // We are generating information for offline use, we don't need any information + // related to the accounts or signing. + if c.prepareOffline { + return c, nil } if c.account != "" && len(c.passphrases) == 0 { return nil, errors.New("passphrase required with account") } - if c.mnemonic != "" && c.path == "" { - return nil, errors.New("path required with mnemonic") - } - - if viper.GetString("validator") == "" { - return nil, errors.New("validator is required") - } - c.validator = viper.GetString("validator") - - c.withdrawalAddress = viper.GetString("withdrawal-address") - - c.signedOperation = viper.GetString("signed-operation") - - c.forkVersion = viper.GetString("fork-version") - c.genesisValidatorsRoot = viper.GetString("genesis-validators-root") - return c, nil } diff --git a/cmd/validator/credentials/set/output.go b/cmd/validator/credentials/set/output.go index 4a367f5..2a94a55 100644 --- a/cmd/validator/credentials/set/output.go +++ b/cmd/validator/credentials/set/output.go @@ -16,6 +16,7 @@ package validatorcredentialsset import ( "context" "encoding/json" + "os" "github.com/pkg/errors" ) @@ -26,11 +27,14 @@ func (c *command) output(ctx context.Context) (string, error) { } if c.json || c.offline { - data, err := json.Marshal(c.signedOp) + data, err := json.Marshal(c.signedOperations) if err != nil { - return "", errors.Wrap(err, "failed to marshal signed operation") + return "", errors.Wrap(err, "failed to marshal signed operations") } - return string(data), nil + if err := os.WriteFile("credentials-operations.json", data, 0600); err != nil { + return "", errors.Wrap(err, "failed to write credentials-operations.json") + } + return "", nil } return "", nil diff --git a/cmd/validator/credentials/set/process.go b/cmd/validator/credentials/set/process.go index a16ef5f..0c2fc89 100644 --- a/cmd/validator/credentials/set/process.go +++ b/cmd/validator/credentials/set/process.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strconv" "strings" @@ -32,64 +33,385 @@ import ( "github.com/wealdtech/ethdo/signing" "github.com/wealdtech/ethdo/util" ethutil "github.com/wealdtech/go-eth2-util" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) +// validatorPath is the regular expression that matches a validator path. +var validatorPath = regexp.MustCompile("^m/12381/3600/[0-9]+/0/0$") + +var offlinePreparationFilename = "offline-preparation.json" +var changeOperationsFilename = "change-operations.json" + func (c *command) process(ctx context.Context) error { if err := c.setup(ctx); err != nil { return err } - if err := c.obtainOp(ctx); err != nil { + if err := c.obtainRequiredInformation(ctx); err != nil { return err } + if c.prepareOffline { + return c.dumpRequiredInformation(ctx) + } + + if err := c.generateOperations(ctx); err != nil { + return err + } + + if validated, reason := c.validateOperations(ctx); !validated { + return fmt.Errorf("operation failed validation: %s", reason) + } + if c.json || c.offline { // Want JSON output, or cannot broadcast. return nil } - if validated, reason := c.validateOp(ctx); !validated { - return fmt.Errorf("operation failed validation: %s", reason) - } - - return c.broadcastOp(ctx) + return c.broadcastOperations(ctx) } -func (c *command) obtainOp(ctx context.Context) error { - // See if we have been given an op. - if c.signedOperation != "" { - // Input could be JSON or a path to JSON. - switch { - case strings.HasPrefix(c.signedOperation, "{"): - // Looks like JSON, nothing to do. - default: - // Assume it's a path to JSON - data, err := os.ReadFile(c.signedOperation) - if err != nil { - return errors.Wrap(err, "failed to read signed operation file") - } - c.signedOperation = string(data) - } - // Unmarshal it to confirm it is valid. - signedOp := &capella.SignedBLSToExecutionChange{} - if err := json.Unmarshal([]byte(c.signedOperation), signedOp); err != nil { - return err - } - return nil +// obtainRequiredInformation obtains the information required to create a +// withdrawal credentials change operation. +func (c *command) obtainRequiredInformation(ctx context.Context) error { + c.chainInfo = &chainInfo{ + Validators: make([]*validatorInfo, 0), } - // Need to create a new op. - if err := c.fetchAccount(ctx); err != nil { + // Use the offline preparation file if present (and we haven't been asked to recreate it). + if !c.prepareOffline { + err := c.loadChainInfo(ctx) + if err == nil { + return nil + } + } + + if c.offline { + return fmt.Errorf("could not find the %s file; this is required to have been previously generated using --offline-preparation on an online mcahine and be readable in the directory in which this command is being run", offlinePreparationFilename) + } + + if err := c.populateChainInfo(ctx); err != nil { return err } - pubkey, err := util.BestPublicKey(c.withdrawalAccount) + + return nil +} + +// populateChainInfo populates chain info structure from a beacon node. +func (c *command) populateChainInfo(ctx context.Context) error { + // Obtain validators. + validators, err := c.consensusClient.(consensusclient.ValidatorsProvider).Validators(ctx, "head", nil) + if err != nil { + return errors.Wrap(err, "failed to obtain validators") + } + + for _, validator := range validators { + c.chainInfo.Validators = append(c.chainInfo.Validators, &validatorInfo{ + Index: validator.Index, + Pubkey: validator.Validator.PublicKey, + WithdrawalCredentials: validator.Validator.WithdrawalCredentials, + }) + } + + // Obtain genesis validators root. + genesis, err := c.consensusClient.(consensusclient.GenesisProvider).Genesis(ctx) + if err != nil { + return errors.Wrap(err, "failed to obtain genesis information") + } + c.chainInfo.GenesisValidatorsRoot = genesis.GenesisValidatorsRoot + + // Obtain epoch. + c.chainInfo.Epoch = c.chainTime.CurrentEpoch() + + // Obtain fork version. + forkSchedule, err := c.consensusClient.(consensusclient.ForkScheduleProvider).ForkSchedule(ctx) + if err != nil { + return errors.Wrap(err, "failed to obtain fork schedule") + } + for i := range forkSchedule { + if forkSchedule[i].Epoch <= c.chainInfo.Epoch { + c.chainInfo.ForkVersion = forkSchedule[i].CurrentVersion + } + } + + // Calculate domain. + spec, err := c.consensusClient.(consensusclient.SpecProvider).Spec(ctx) + if err != nil { + return errors.Wrap(err, "failed to obtain spec") + } + domainType, exists := spec["DOMAIN_BLS_TO_EXECUTION_CHANGE"].(phase0.DomainType) + if !exists { + return errors.New("failed to obtain DOMAIN_BLS_TO_EXECUTION_CHANGE") + } + domainProvider, isProvider := c.consensusClient.(consensusclient.DomainProvider) + if !isProvider { + return errors.New("consensus node does not provide domain information") + } + c.chainInfo.Domain, err = domainProvider.Domain(ctx, domainType, c.chainInfo.Epoch) + if err != nil { + return errors.Wrap(err, "failed to obtain domain") + } + + return nil +} + +// dumpRequiredInformation prepares for an offline run of this command by dumping +// the chain information to a file. +func (c *command) dumpRequiredInformation(ctx context.Context) error { + data, err := json.Marshal(c.chainInfo) if err != nil { return err } + if err := os.WriteFile(offlinePreparationFilename, data, 0600); err != nil { + return err + } + + return nil +} + +func (c *command) generateOperations(ctx context.Context) error { + // Ensure that we are beyond the capella hard fork epoch. + if c.chainTime.CurrentEpoch() < c.chainTime.CapellaInitialEpoch() { + return errors.New("chain not yet activated capella hard fork") + } + + if c.account == "" && c.mnemonic == "" && c.privateKey == "" { + // No input information; fetch the operations from a file. + if err := c.loadOperations(ctx); err == nil { + return nil + } + return fmt.Errorf("no account, mnemonic or private key specified and no %s file found; cannot proceed", changeOperationsFilename) + } + + if c.mnemonic != "" && c.path == "" { + // Have a mnemonic and no path; scan mnemonic. + return c.generateOperationsFromMnemonic(ctx) + } + + if c.mnemonic != "" && c.path != "" { + // Have a mnemonic and path. + return c.generateOperationsFromMnemonicAndPath(ctx) + } + + // Have a validator index or public key ; fetch the validator info. + validatorInfo, err := c.fetchValidatorInfo(ctx) + if err != nil { + return err + } + + // Fetch the individual account. + withdrawalAccount, err := c.fetchAccount(ctx) + if err != nil { + return err + } + + // Generate the operation. + if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { + return err + } + + return nil +} + +func (c *command) loadChainInfo(ctx context.Context) error { + _, err := os.Stat(offlinePreparationFilename) + if err != nil { + if c.debug { + fmt.Printf("Failed to read offline preparation file: %v\n", err) + } + } + + if c.debug { + fmt.Printf("%s found; loading chain state\n", offlinePreparationFilename) + } + data, err := os.ReadFile(offlinePreparationFilename) + if err != nil { + return errors.Wrap(err, "failed to read offline preparation file") + } + if err := json.Unmarshal(data, c.chainInfo); err != nil { + return errors.Wrap(err, "failed to parse offline preparation file") + } + + return nil +} + +func (c *command) loadOperations(ctx context.Context) error { + _, err := os.Stat(changeOperationsFilename) + if err != nil { + if c.debug { + fmt.Printf("Failed to read change operations file: %v\n", err) + } + return err + } + + if c.debug { + fmt.Printf("%s found; loading operations\n", changeOperationsFilename) + } + data, err := os.ReadFile(changeOperationsFilename) + if err != nil { + return errors.Wrap(err, "failed to read change operations file") + } + if err := json.Unmarshal(data, &c.signedOperations); err != nil { + return errors.Wrap(err, "failed to parse change operations file") + } + + return nil +} + +func (c *command) generateOperationsFromMnemonic(ctx context.Context) error { + seed, err := util.SeedFromMnemonic(c.mnemonic) + if err != nil { + return err + } + + // Turn the validators in to a map for easy lookup. + validators := make(map[string]*validatorInfo, 0) + for _, validator := range c.chainInfo.Validators { + validators[fmt.Sprintf("%#x", validator.Pubkey)] = validator + } + + maxDistance := 1024 + // Start scanning the validator keys. + lastFoundIndex := 0 + for i := 0; ; i++ { + if i-lastFoundIndex > maxDistance { + if c.debug { + fmt.Printf("Gone %d indices without finding a validator, not scanning any further\n", maxDistance) + } + break + } + validatorKeyPath := fmt.Sprintf("m/12381/3600/%d/0/0", i) + + found, err := c.generateOperationFromSeedAndPath(ctx, validators, seed, validatorKeyPath) + if err != nil { + return errors.Wrap(err, "failed to generate operation from seed and path") + } + if found { + lastFoundIndex = i + } + } + return nil +} + +func (c *command) generateOperationFromSeedAndPath(ctx context.Context, + validators map[string]*validatorInfo, + seed []byte, + path string, +) ( + bool, + error, +) { + validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, path) + if err != nil { + return false, errors.Wrap(err, "failed to generate validator private key") + } + validatorPubkey := fmt.Sprintf("%#x", validatorPrivkey.PublicKey().Marshal()) + validator, exists := validators[validatorPubkey] + if !exists { + if c.debug { + fmt.Printf("No validator found with public key %s at path %s\n", validatorPubkey, path) + } + return false, nil + } + + if c.verbose { + fmt.Printf("Validator %d found with public key %s at path %s\n", validator.Index, validatorPubkey, path) + } + + if validator.WithdrawalCredentials[0] != byte(0) { + if c.debug { + fmt.Printf("Validator %s has non-BLS withdrawal credentials %#x\n", validatorPubkey, validator.WithdrawalCredentials) + } + return false, nil + } + + // Recreate the withdrawal credentials to ensure a match. + withdrawalKeyPath := strings.TrimSuffix(path, "/0") + withdrawalPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, withdrawalKeyPath) + if err != nil { + return false, errors.Wrap(err, "failed to generate withdrawal private key") + } + withdrawalPubkey := withdrawalPrivkey.PublicKey() + withdrawalCredentials := ethutil.SHA256(withdrawalPubkey.Marshal()) + withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX + if !bytes.Equal(withdrawalCredentials, validator.WithdrawalCredentials) { + if c.verbose { + fmt.Printf("Validator %s withdrawal credentials %#x do not match expected credentials, cannot update\n", validatorPubkey, validator.WithdrawalCredentials) + } + return false, nil + } + + if c.debug { + fmt.Printf("Validator %s eligible for setting credentials\n", validatorPubkey) + } + + withdrawalAccount, err := util.ParseAccount(ctx, c.mnemonic, []string{withdrawalKeyPath}, true) + if err != nil { + return false, errors.Wrap(err, "failed to create withdrawal account") + } + + err = c.generateOperationFromAccount(ctx, validator, withdrawalAccount) + if err != nil { + return false, err + } + + return true, nil +} + +func (c *command) generateOperationFromAccount(ctx context.Context, + validator *validatorInfo, + withdrawalAccount e2wtypes.Account, +) error { + signedOperation, err := c.createSignedOperation(ctx, validator, withdrawalAccount) + if err != nil { + return err + } + c.signedOperations = append(c.signedOperations, signedOperation) + return nil +} + +func (c *command) createSignedOperation(ctx context.Context, + validator *validatorInfo, + withdrawalAccount e2wtypes.Account, +) ( + *capella.SignedBLSToExecutionChange, + error, +) { + pubkey, err := util.BestPublicKey(withdrawalAccount) + if err != nil { + return nil, err + } blsPubkey := phase0.BLSPubKey{} copy(blsPubkey[:], pubkey.Marshal()) - withdrawalAddressBytes, err := hex.DecodeString(strings.TrimPrefix(c.withdrawalAddress, "0x")) + if err := c.parseWithdrawalAddress(ctx); err != nil { + return nil, errors.Wrap(err, "invalid withdrawal address") + } + + operation := &capella.BLSToExecutionChange{ + ValidatorIndex: validator.Index, + FromBLSPubkey: blsPubkey, + ToExecutionAddress: c.withdrawalAddress, + } + root, err := operation.HashTreeRoot() + if err != nil { + return nil, errors.Wrap(err, "failed to generate root for credentials change operation") + } + + // Sign the operation. + signature, err := signing.SignRoot(ctx, withdrawalAccount, nil, root, c.chainInfo.Domain) + if err != nil { + return nil, errors.Wrap(err, "failed to sign credentials change operation") + } + + return &capella.SignedBLSToExecutionChange{ + Message: operation, + Signature: signature, + }, nil +} + +func (c *command) parseWithdrawalAddress(ctx context.Context) error { + withdrawalAddressBytes, err := hex.DecodeString(strings.TrimPrefix(c.withdrawalAddressStr, "0x")) if err != nil { return errors.Wrap(err, "failed to obtain execution address") } @@ -98,154 +420,70 @@ func (c *command) obtainOp(ctx context.Context) error { } // Ensure the address is properly checksummed. checksummedAddress := addressBytesToEIP55(withdrawalAddressBytes) - if checksummedAddress != c.withdrawalAddress { + if checksummedAddress != c.withdrawalAddressStr { return fmt.Errorf("withdrawal address checksum does not match (expected %s)", checksummedAddress) } - withdrawalAddress := bellatrix.ExecutionAddress{} - copy(withdrawalAddress[:], withdrawalAddressBytes) - - if c.offline { - err = c.obtainOpOffline(ctx, blsPubkey, withdrawalAddress) - } else { - err = c.obtainOpOnline(ctx, blsPubkey, withdrawalAddress) - } - if err != nil { - return errors.Wrap(err, "failed to obtain operation") - } - - root, err := c.op.HashTreeRoot() - if err != nil { - return errors.Wrap(err, "failed to generate root for credentials change operation") - } - - // Sign the operation. - signature, err := signing.SignRoot(ctx, c.withdrawalAccount, nil, root, c.domain) - if err != nil { - return errors.Wrap(err, "failed to sign credentials change operation") - } - - c.signedOp = &capella.SignedBLSToExecutionChange{ - Message: c.op, - Signature: signature, - } + copy(c.withdrawalAddress[:], withdrawalAddressBytes) return nil } -func (c *command) obtainOpOffline(ctx context.Context, - pubkey phase0.BLSPubKey, - withdrawalAddress bellatrix.ExecutionAddress, -) error { - if c.validator == "" { - return errors.New("validator index must be supplied when offline") - } - validatorIndex, err := strconv.ParseUint(c.validator, 10, 64) - if err != nil { - return errors.Wrap(err, "validator must be an index when offline") +func (c *command) validateOperations(ctx context.Context) (bool, string) { + // Turn the validators in to a map for easy lookup. + validators := make(map[phase0.ValidatorIndex]*validatorInfo, 0) + for _, validator := range c.chainInfo.Validators { + validators[validator.Index] = validator } - if c.forkVersion == "" { - return errors.New("fork version must be supplied when offline") + for _, signedOperation := range c.signedOperations { + if validated, reason := c.validateOperation(ctx, validators, signedOperation); !validated { + return validated, reason + } } - forkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(c.forkVersion, "0x")) - if err != nil { - return errors.Wrap(err, "fork version invalid") - } - if len(forkVersionBytes) != phase0.ForkVersionLength { - return errors.New("fork version incorrect length") - } - forkVersion := phase0.Version{} - copy(forkVersion[:], forkVersionBytes) - - if c.genesisValidatorsRoot == "" { - return errors.New("genesis validators root must be supplied when offline") - } - genesisValidatorsRootBytes, err := hex.DecodeString(strings.TrimPrefix(c.genesisValidatorsRoot, "0x")) - if err != nil { - return errors.Wrap(err, "genesis validators root invalid") - } - if len(genesisValidatorsRootBytes) != phase0.RootLength { - return errors.New("genesis validators root incorrect length") - } - genesisValidatorsRoot := phase0.Root{} - copy(genesisValidatorsRoot[:], genesisValidatorsRootBytes) - - // Generate the domain. - forkData := &phase0.ForkData{ - CurrentVersion: forkVersion, - GenesisValidatorsRoot: genesisValidatorsRoot, - } - root, err := forkData.HashTreeRoot() - if err != nil { - return errors.Wrap(err, "failed to calculate signature domain") - } - c.domain = phase0.Domain{} - copy(c.domain[:], []byte{0x0a, 0x00, 0x00, 0x00}) // DOMAIN_BLS_TO_EXECUTION_CHANGE. - copy(c.domain[4:], root[:]) - - // Generate the change operation. - c.op = &capella.BLSToExecutionChange{ - ValidatorIndex: phase0.ValidatorIndex(validatorIndex), - FromBLSPubkey: pubkey, - ToExecutionAddress: withdrawalAddress, - } - - return nil + return true, "" } -func (c *command) obtainOpOnline(ctx context.Context, - pubkey phase0.BLSPubKey, - withdrawalAddress bellatrix.ExecutionAddress, -) error { - // Ensure the validator is correct and suitable. - if err := c.fetchChainInfo(ctx); err != nil { - return err - } - // TODO Move to broadcast. - if c.validatorInfo.Validator.WithdrawalCredentials[0] != 0x00 { - return errors.New("validator withdrawal credentials are not using BLS; cannot change") - } - { - // TODO remove. - x, _ := json.Marshal(c.validatorInfo) - fmt.Printf("%s\n", string(x)) - } - - // Generate the change operation. - c.op = &capella.BLSToExecutionChange{ - ValidatorIndex: c.validatorInfo.Index, - FromBLSPubkey: pubkey, - ToExecutionAddress: withdrawalAddress, - } - - return nil -} - -func (c *command) validateOp(ctx context.Context, +func (c *command) validateOperation(ctx context.Context, + validators map[phase0.ValidatorIndex]*validatorInfo, + signedOperation *capella.SignedBLSToExecutionChange, ) ( bool, string, ) { - // Confirm that the public key hashes to the existing withdrawal credentials (if available). - if c.validatorInfo != nil { - pubkey, err := util.BestPublicKey(c.withdrawalAccount) - if err != nil { - return false, "failed to obtain a public key for the withdrawal account" - } - blsHash := ethutil.Keccak256(pubkey.Marshal()) - // TODO remove. - fmt.Printf("BLS pub key is %#x, hash is %#x\n", pubkey, blsHash) - if !bytes.Equal(blsHash[1:], c.validatorInfo.Validator.WithdrawalCredentials[:]) { - return false, "validator withdrawal credentials do not match current withdrawal credentials" + validator, exists := validators[signedOperation.Message.ValidatorIndex] + if !exists { + return false, "validator not known on chain" + } + if c.debug { + fmt.Printf("Credentials change operation: %v", signedOperation) + fmt.Printf("On-chain validator info: %v\n", validator) + } + + if validator.WithdrawalCredentials[0] != byte(0) { + return false, "validator is not using BLS withdrawal credentials" + } + + withdrawalCredentials := ethutil.SHA256(signedOperation.Message.FromBLSPubkey[:]) + withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX + if !bytes.Equal(withdrawalCredentials, validator.WithdrawalCredentials) { + if c.debug { + fmt.Printf("validator withdrawal credentials %#x do not match calculated operation withdrawal credentials %#x\n", validator.WithdrawalCredentials, withdrawalCredentials) } + return false, "validator withdrawal credentials do not match those in the operation" } return true, "" } -func (c *command) broadcastOp(ctx context.Context) error { - // Broadcast the operation. - return c.consensusClient.(consensusclient.BLSToExecutionChangeSubmitter).SubmitBLSToExecutionChange(ctx, c.signedOp) +func (c *command) broadcastOperations(ctx context.Context) error { + // Broadcast the operations. + for _, signedOperation := range c.signedOperations { + if err := c.consensusClient.(consensusclient.BLSToExecutionChangeSubmitter).SubmitBLSToExecutionChange(ctx, signedOperation); err != nil { + return err + } + } + + return nil } func (c *command) setup(ctx context.Context) error { @@ -253,9 +491,8 @@ func (c *command) setup(ctx context.Context) error { return nil } - var err error - // Connect to the consensus node. + var err error c.consensusClient, err = util.ConnectToBeaconNode(ctx, c.connection, c.timeout, c.allowInsecureConnections) if err != nil { return errors.Wrap(err, "failed to connect to consensus node") @@ -274,59 +511,74 @@ func (c *command) setup(ctx context.Context) error { return nil } -func (c *command) fetchChainInfo(ctx context.Context) error { - var err error - - // Obtain the validators provider. - validatorsProvider, isProvider := c.consensusClient.(consensusclient.ValidatorsProvider) - if !isProvider { - return errors.New("consensus node does not provide validator information") +func (c *command) fetchValidatorInfo(ctx context.Context) (*validatorInfo, error) { + var validatorInfo *validatorInfo + switch { + case c.validator == "": + return nil, errors.New("no validator specified") + case strings.HasPrefix(c.validator, "0x"): + // A public key + for _, validator := range c.chainInfo.Validators { + if strings.EqualFold(c.validator, fmt.Sprintf("%#x", validator.Pubkey)) { + validatorInfo = validator + break + } + } + case strings.Contains(c.validator, "/"): + // An account. + _, account, err := util.WalletAndAccountFromPath(ctx, c.validator) + if err != nil { + return nil, errors.Wrap(err, "unable to obtain account") + } + accPubKey, err := util.BestPublicKey(account) + if err != nil { + return nil, errors.Wrap(err, "unable to obtain public key for account") + } + pubkey := fmt.Sprintf("%#x", accPubKey.Marshal()) + for _, validator := range c.chainInfo.Validators { + if strings.EqualFold(pubkey, fmt.Sprintf("%#x", validator.Pubkey)) { + validatorInfo = validator + break + } + } + default: + // An index. + index, err := strconv.ParseUint(c.validator, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "failed to parse validator index") + } + validatorIndex := phase0.ValidatorIndex(index) + for _, validator := range c.chainInfo.Validators { + if validator.Index == validatorIndex { + validatorInfo = validator + break + } + } } - c.validatorInfo, err = util.ParseValidator(ctx, validatorsProvider, c.validator, "head") - if err != nil { - return errors.Wrap(err, "failed to obtain validator") + if validatorInfo == nil { + return nil, errors.New("unknown validator") } - epoch := c.chainTime.CurrentEpoch() - - // Obtain the domain type. - spec, err := c.consensusClient.(consensusclient.SpecProvider).Spec(ctx) - if err != nil { - return errors.Wrap(err, "failed to obtain spec") - } - domainType, exists := spec["DOMAIN_BLS_TO_EXECUTION_CHANGE"].(phase0.DomainType) - if !exists { - return errors.New("failed to obtain DOMAIN_BLS_TO_EXECUTION_CHANGE") - } - - domainProvider, isProvider := c.consensusClient.(consensusclient.DomainProvider) - if !isProvider { - return errors.New("consensus node does not provide domain information") - } - c.domain, err = domainProvider.Domain(ctx, domainType, epoch) - if err != nil { - return errors.Wrap(err, "failed to obtain domain") - } - - return nil + return validatorInfo, nil } -func (c *command) fetchAccount(ctx context.Context) error { +func (c *command) fetchAccount(ctx context.Context) (e2wtypes.Account, error) { + var account e2wtypes.Account var err error switch { case c.account != "": - c.withdrawalAccount, err = util.ParseAccount(ctx, c.account, c.passphrases, true) + account, err = util.ParseAccount(ctx, c.account, c.passphrases, true) case c.mnemonic != "": - c.withdrawalAccount, err = util.ParseAccount(ctx, c.mnemonic, []string{c.path}, true) + account, err = util.ParseAccount(ctx, c.mnemonic, []string{c.path}, true) case c.privateKey != "": - c.withdrawalAccount, err = util.ParseAccount(ctx, c.privateKey, nil, true) + account, err = util.ParseAccount(ctx, c.privateKey, nil, true) default: err = errors.New("account, mnemonic or private key must be supplied") } - return err + return account, err } // addressBytesToEIP55 converts a byte array in to an EIP-55 string format. @@ -347,3 +599,28 @@ func addressBytesToEIP55(address []byte) string { return fmt.Sprintf("0x%s", string(bytes)) } + +func (c *command) generateOperationsFromMnemonicAndPath(ctx context.Context) error { + seed, err := util.SeedFromMnemonic(c.mnemonic) + if err != nil { + return err + } + + // Turn the validators in to a map for easy lookup. + validators := make(map[string]*validatorInfo, 0) + for _, validator := range c.chainInfo.Validators { + validators[fmt.Sprintf("%#x", validator.Pubkey)] = validator + } + + validatorKeyPath := c.path + match := validatorPath.Match([]byte(c.path)) + if !match { + return fmt.Errorf("path %s does not match EIP-2334 format", c.path) + } + + if _, err := c.generateOperationFromSeedAndPath(ctx, validators, seed, validatorKeyPath); err != nil { + return errors.Wrap(err, "failed to generate operation from seed and path") + } + + return nil +} diff --git a/cmd/validator/credentials/set/validatorinfo.go b/cmd/validator/credentials/set/validatorinfo.go new file mode 100644 index 0000000..1709b7d --- /dev/null +++ b/cmd/validator/credentials/set/validatorinfo.go @@ -0,0 +1,97 @@ +// Copyright © 2022 Weald Technology Trading. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validatorcredentialsset + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +type validatorInfo struct { + Index phase0.ValidatorIndex + Pubkey phase0.BLSPubKey + WithdrawalCredentials []byte +} + +type validatorInfoJSON struct { + Index string `json:"index"` + Pubkey string `json:"pubkey"` + WithdrawalCredentials string `json:"withdrawal_credentials"` +} + +// MarshalJSON implements json.Marshaler. +func (v *validatorInfo) MarshalJSON() ([]byte, error) { + return json.Marshal(&validatorInfoJSON{ + Index: fmt.Sprintf("%d", v.Index), + Pubkey: fmt.Sprintf("%#x", v.Pubkey), + WithdrawalCredentials: fmt.Sprintf("%#x", v.WithdrawalCredentials), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *validatorInfo) UnmarshalJSON(input []byte) error { + var data validatorInfoJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Index == "" { + return errors.New("index missing") + } + index, err := strconv.ParseUint(data.Index, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for index") + } + v.Index = phase0.ValidatorIndex(index) + + if data.Pubkey == "" { + return errors.New("public key missing") + } + pubkey, err := hex.DecodeString(strings.TrimPrefix(data.Pubkey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + if len(pubkey) != phase0.PublicKeyLength { + return fmt.Errorf("incorrect length %d for public key", len(pubkey)) + } + copy(v.Pubkey[:], pubkey) + + if data.WithdrawalCredentials == "" { + return errors.New("withdrawal credentials missing") + } + v.WithdrawalCredentials, err = hex.DecodeString(strings.TrimPrefix(data.WithdrawalCredentials, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for withdrawal credentials") + } + if len(v.WithdrawalCredentials) != phase0.HashLength { + return fmt.Errorf("incorrect length %d for withdrawal credentials", len(v.WithdrawalCredentials)) + } + + return nil +} + +// String implements the Stringer interface. +func (v *validatorInfo) String() string { + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("Err: %v\n", err) + } + return string(data) +} diff --git a/cmd/validatorcredentialsset.go b/cmd/validatorcredentialsset.go index e72beb4..9bdebc5 100644 --- a/cmd/validatorcredentialsset.go +++ b/cmd/validatorcredentialsset.go @@ -24,15 +24,16 @@ import ( var validatorCredentialsSetCmd = &cobra.Command{ Use: "set", Short: "Set withdrawal credentials for an Ethereum consensus validator", - Long: `Set withdrawal credentials for an Ethereum consensus validator. For example: + Long: `Set withdrawal credentials for an Ethereum consensus validator via a "change credentials" operation. For example: - ethdo validator credentials set --validator=primary/validator --execution-address=0x00...13 --private-key=0x00...1f + ethdo validator credentials set --validator=primary/validator --withdrawal-address=0x00...13 --private-key=0x00...1f -The existing account can be specified in one of three ways: +The existing account can be specified in one of a number of ways: - - private key using --private-key - - account and passphrase using --account and --passphrase - - mnemonic and path using --mnemonic and --path + - mnemonic using --mnemonic; this will scan the mnemonic and generate all required operations + - mnemonic and path to the validator key using --mnemonic and --path; this will generate a single operation + - private key using --private-key; this will generate a single operation + - account and passphrase using --account and --passphrase; this will generate a single operation In quiet mode this will return 0 if the credentials operation has been generated (and successfully broadcast if online), otherwise 1.`, RunE: func(cmd *cobra.Command, args []string) error { @@ -53,6 +54,7 @@ In quiet mode this will return 0 if the credentials operation has been generated func init() { validatorCredentialsCmd.AddCommand(validatorCredentialsSetCmd) validatorCredentialsFlags(validatorCredentialsSetCmd) + validatorCredentialsSetCmd.Flags().Bool("prepare-offline", false, "Create files for offline use") validatorCredentialsSetCmd.Flags().String("validator", "", "Validator for which to set validator credentials") validatorCredentialsSetCmd.Flags().String("withdrawal-address", "", "Execution address to which to direct withdrawals") validatorCredentialsSetCmd.Flags().String("signed-operation", "", "Use pre-defined JSON signed operation as created by --json to transmit the credentials change operation") @@ -63,6 +65,9 @@ func init() { } func validatorCredentialsSetBindings() { + if err := viper.BindPFlag("prepare-offline", validatorCredentialsSetCmd.Flags().Lookup("prepare-offline")); err != nil { + panic(err) + } if err := viper.BindPFlag("validator", validatorCredentialsSetCmd.Flags().Lookup("validator")); err != nil { panic(err) } diff --git a/docs/changingwithdrawalcredentials.md b/docs/changingwithdrawalcredentials.md index d9bb7ab..0b0e869 100644 --- a/docs/changingwithdrawalcredentials.md +++ b/docs/changingwithdrawalcredentials.md @@ -1,13 +1,54 @@ # Changing withdrawal credentials When creating a validator it is possible to set its withdrawal credentials to those based upon a BLS private key (known as BLS withdrawal credentials, or "type 0" withdrawal credentials) or based upon an Ethereum execution address (known as execution withdrawal credentials, or "type 1" withdrawal credentials). With the advent of the Capella hard fork, it is possible for rewards accrued on the consensus chain (also known as the beacon chain) to be sent to the execution chain. However, for this to occur the validator's withdrawal credentials must be type 1. Capella also brings a mechanism to change existing type 0 withdrawal credentials to type 1 withdrawal credentials, and this document outlines the process to change withdrawal credentials from type 0 to type 1 so that consensus rewards can be accessed. -**Once a validator has Ethereum execution credentials set they cannot be changed. Please be careful when following this or any similar process to ensure you end up with the ability to access the rewards that will be sent to the execution address within the credentials.** +**Once a validator has Ethereum execution credentials set they cannot be changed. Please be careful when following this or any similar process to ensure that you have access to the private key (either as a software file, a hardware key or a mnemonic) of the withdrawal address you use so that you have the ability to access your rewards.** -## Preparing for the process -A number of steps need to be taken to prepare for generating and broadcasting the credentials change operation. +## Concepts +The following concepts are useful when understanding the rest of this guide. -### Accessing the beacon node -`ethdo` requires access to the beacon node at various points during the operation. `ethdo` will attempt to find a local beacon node automatically, but if not then an explicit connection value will be required. To find out if `ethdo` has access to the beacon node run: +### Validator +A validator is a logical entity that secures the Ethereum beacon chain (and hence the execution chain) by proposing blocks and attesting to blocks proposed by other validators. + +### Withdrawal credentials +Withdrawal credentials, held as part of a validator's on-chain definition, define where consensus rewards will be sent. + +### Private key +A private key is a hexadecimal string (_e.g._ 0x010203…a1a2a3) that can be used to generate a public key and (in the case of the execution chain) Ethereum address. + +### Mnemonic +A mnemonic is a 24-word phrase that can be used to generate multiple private keys with the use of _paths_. + +### Path +A path is a string starting with "m" and containing a number of components separated by "/", for example "m/12381/3600/0/0". The process to obtain a key from a mnemonic and path is known as "hierarchical derivation". + +### Withdrawal address +A withdrawal address is an Ethereum execution address that will receive consensus rewards periodically during the operation of the validator and, ultimately, to which the initial deposit will be returned when the validator is exited. It is important to understand that at time of writing this value cannot be changed, so it is critical that one of the following criteria are met: + +- the private keys for the Ethereum address are known +- the Ethereum address is secured by a hardware wallet +- the Ethereum address is that of a smart contract with the ability to withdraw funds + +The execution address must be supplied in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) format, _i.e._ using mixed case for checksum. An example of a mixed-case Ethereum address is `0x8f0844Fd51E31ff6Bf5baBe21DCcf7328E19Fd9F` + +### Online and Offline +An _online_ computer is one that is is connected to the internet. It should be running a consensus node connected to the larger Ethereum network. An online computer is required to carry out the process, to obtain information from the consensus node and to broadcast your actions to the rest of the Ethereum network. + +An _offline_ computer is one that is not connected to the internet. As such, it will not be running a consensus node. It can optionally be used in conjunction with an online computer to provide higher levels of security for your mnemonic or private key, but is less convenient because it requires manual transfer of files from the online computer to the offline computer, and back. + +With only an online computer the flow of information is roughly as follows: + +![Online process](images/credentials-change-online.png) + +Here it can be seen that a copy of `ethdo` with access to private keys connects to a consensus node with access to the internet. Due to its connection to the internet it is possible that the computer on which `ethdo` and the consensus node runs has been compromised, and as such would expose the private keys to an attacker. + +With both an offline and an online computer the flow of information is roughly as follows: + +![Offline process](images/credentials-change-offline.png) + +Here the copy of `ethdo` with access to private keys is on an offline computer, which protects it from being compromised via the internet. Data is physically moved from the offline to the online computer via a USB storage key or similar, and none of the information on the online computer is sensitive. + +## Preparation +Regardless of the method selected, preparation must take place on the online computer to ensure that `ethdo` can access your consensus node. `ethdo` will attempt to find a local consensus node automatically, but if not then an explicit connection value will be required. To find out if `ethdo` has access to the consensus node run: ``` ethdo node info --verbose @@ -22,235 +63,144 @@ Syncing: false It is important to confirm that the "Syncing" value is "false". If this is "true" it means that the node is currently syncing, and you will need to wait for the process to finish before proceeding. -If this command instead returns an error you will need to add an explicit connection string. For example, if your beacon node is serving its REST API on port 12345 then you should add `--connection=http://localhost:12345` to all `ethdo` commands in this process, for example: +If this command instead returns an error you will need to add an explicit connection string. For example, if your consensus node is serving its REST API on port 12345 then you should add `--connection=http://localhost:12345` to all `ethdo` commands in this process, for example: ```sh ethdo --connection=http://localhost:12345 node info --verbose ``` -Note that some beacon nodes may require configuration to serve their REST API. Please refer to the documentation of your specific beacon node to enable this. +Note that some consensus nodes may require configuration to serve their REST API. Please refer to the documentation of your specific consensus node to enable this. + +Once the preparation is complete you should select either basic or advanced operation, depending on your requirements. + +## Basic operation +Given the above concepts, the purpose of this guide is to allow a change of validators' withdrawal credentials to be changed to a withdrawal address, allowing validator rewards to be accessed on the Ethereum execution chain. + +Basic operation is suitable in the majority of cases. If you: + +- generated your validators using a mnemonic (_e.g._ using the deposit CLI or launchpad) +- want to change all of your validators to have the same withdrawal address +- want to change all of your validators' withdrawal credentials at the same time + +then this method is for you. If any of the above does not apply then please go to the "Advanced operation" section. + +### Online process +The online process generates and broadcasts the operations to change withdrawal credentials for all of your validators tied to a mnemonic in a single action. + +Two pieces of information are required for carrying out this process online: the mnemonic and withdrawal address. + +On your _online_ computer run the following: + +``` +ethdo validator credentials set --mnemonic="abandon abandon abandon … art" --withdrawal-address=0x0123…cdef +``` + +Replacing the `mnemonic` and `withdrawal-address` values with your own values. This command will: + +1. obtain information from your consensus node about all currently-running validators and various additional information required to generate the operations +2. scan your mnemonic to find any validators that were generated by it, and create the operations to change their credentials +3. broadcast the credentials change operations to the Ethereum network + +### Online and Offline process +The online and offline process contains three steps. In the first, data is gathered on the online computer. In the second, the credentials change operations are generated on the offline computer. In the third, the operations are broadcast on the online computer. + +Two pieces of information are required for carrying out this process online: the mnemonic and withdrawal address. + +On your _online_ computer run the following: + +``` +ethdo validator credentials set --prepare-offline +``` + +This command will: + +1. obtain information from your consensus node about all currently-running validators and various additional information required to generate the operations +2. write this information to a file called `offline-preparation.json` + +The `offline-preparation.json` file must be copied to your _offline_ computer. Once this has been done, on your _offline_ computer run the following: + +``` +ethdo validator credentials set --offline --mnemonic="abandon abandon abandon … art" --withdrawal-address=0x0123…cdef +``` + +Replacing the `mnemonic` and `withdrawal-address` values with your own values. This command will: + +1. read the `offline-preparation.json` file to obtain information about all currently-running validators and various additional information required to generate the operations +2. scan your mnemonic to find any validators that were generated by it, and create the operations to change their credentials +3. write this information to a file called `change-operations.json` + +The `change-operations.json` file must be copied to your _online_ computer. Once this has been done, on your _online_ computer run the following: + +``` +ethdo validator credentials set +``` + +This command will: + +1. read the `change-operations.json` file to obtain the operations to change the validators' credentials +2. broadcast the credentials change operations to the Ethereum network + +## Advanced operation +Advanced operation is required when any of the following conditions are met: + +- your validators were created using something other than the deposit CLI or launchpad (_e.g._ `ethdo`) +- you want to set your validators to have different withdrawal addresses +- you want to change your validators' withdrawal credentials individually ### Validator reference There are three options to reference a validator: - the `ethdo` account of the validator (in format wallet/account) - the validator's public key (in format 0x…) -- the validator's index (in format 123…) +- the validator's on-chain index (in format 123…) Any of these can be passed to the following commands with the `--validator` parameter. You need to ensure that you have this information before starting the process. **In the following examples we will use the validator with index 123. Please replace this with the reference to your validator in all commands.** -### Execution address -The execution address will be the address to which all Ether held by the validator from the consensus chain will be sent. It is important to understand that at time of writing this value cannot be changed, so it is critical that one of the following criteria are met: +### Withdrawal address +The withdrawal address is defined above in the concepts section. -- the private keys for the Ethereum address are known -- the Ethereum address is secured by a hardware wallet -- the Ethereum address is that of a smart contract with the ability to withdraw funds +**In the following examples we will use a withdrawal address of 0x8f…9F. Please replace this with the your withdrawal address in all commands.** -The execution address must be supplied in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) format, _i.e._ using mixed case for checksum. An example of a mixed-case Ethereum address is `0x8f0844Fd51E31ff6Bf5baBe21DCcf7328E19Fd9F` +### Generating credentials change operations +Note that if you are carrying out this process offline then you still need to carry out the first and third steps outlined in the "Basic operation" section above. This is to ensure that the offline computer has the correct information to generate the operations, and that the operations are made available to the online computer for broadcasting to the network. -**In the following examples we will use an execution address of 0x8f…9F. Please replace this with the your execution address in all commands.** +If using the online and offline process run the commands below on the offline computer, and add the `--offline` flag to the commands below. You will need to copy the resultant `change-operations.json` file to the online computer to broadcast to the network. -### Online or offline -It is possible to generate the withdrawal credentials change operation either online or offline. +If using the online process run the commands below on the online computer. The operation will be broadcast to the network automatically. -In _online_ mode the credentials will be generated on a server that has both access to the internet and access to the private keys of the existing withdrawal credentials. This is the easiest process, however due to it involving private keys on a computer connected to the internet some consider this insecure. - -In _offline_ mode there are two servers: one with access to the internet, and one with access to the private keys. This is the most secure process, however requires additional steps to accomplish. - -It is a personal choice as to if an online or offline method is chosen to generate the credentials change operation. Instructions for both methods are present. - -## The process -### Check your current validator credentials -The first step will be to confirm that the validator can be found on-chain. To do so, run the following command: - -```sh -ethdo validator credentials get --validator=123 -``` - -This should return information similar to the following: - -``` -BLS credentials: 0x00ebf119d469a31ff2a534d176e6d594046a2367f7a36848009f70f3cb9a9dd1 -``` - -This result should start with the phrase "BLS credentials", which means that these credentials must be upgraded to an Ethereum execution address to receive withdrawals. If instead the result starts with the phrase "Ethereum execution address" it means that the credentials are already set to an Ethereum execution address and no further action is necessary (or possible). - - -Once you have the correct information to refer to your validator you can generate the credentials change operation. - -### Generate and publish the credentials change operation (online) -The steps for generating and publishing the credentials change operation online depend on the method by which you access your current withdrawal key. - -#### Using a mnemonic -Many stakers will have generated their validators from a mnemonic. A mnemonic is a 24-word phrase from which withdrawal and validator keys are derived using a _path_. Commonly, keys will have been generated using two paths: +#### Using a mnemonic and path. +A mnemonic is a 24-word phrase from which withdrawal and validator keys are derived using a _path_. Commonly, keys will have been generated using two paths: - m/12381/3600/_i_/0 is the path to a withdrawal key, where _i_ starts at 0 for the first validator, 1 for the second validator, _etc._ - m/12381/3600/_i_/0/0 is the path to a validator key, where _i_ starts at 0 for the first validator, 1 for the second validator, _etc._ -The first step will be to confirm that the mnemonic provides the appropriate validator key. To do so run: +however this is only a standard and not a restriction, and it is possible for users to have created validators using paths of their own choice. ``` -ethdo account derive --mnemonic='abandon … art' --path='m/12381/3600/0/0/0' +ethdo validator credentials set --validator=123 --mnemonic="abandon abandon abandon … art" --path='m/12381/3600/0/0/0' --withdrawal-address=0x0123…cdef ``` -replacing the first '0' in the path with the validator number (remember that numbering starts at 0 for the first validator). This will provide an output similar to: - -``` -Public key: 0xb384f767d964e100c8a9b21018d08c25ffebae268b3ab6d610353897541971726dbfc3c7463884c68a531515aab94c87 -``` - -The displayed public key should match the public key of the validator of which you are attempting to change the credentials. If not, then do not proceed further and obtain help to understand why there is a mismatch. - -Assuming the displayed public key does match the public key of the validator the next step is to confirm the current withdrawal credentials. To do so run: - -``` -ethdo account derive --mnemonic='abandon … art' --path='m/12381/3600/0/0' --show-withdrawal-credentials -``` - -again replacing the first '0' in the path with the validator number. This will provide an output similar to: - -``` -Withdrawal credentials: 0x008ba1cc4b091b91c1202bba3f508075d6ff565c77e559f0803c0792e0302bf1 -``` - -The displayed withdrawal credentials should match the current withdrawal credentials of your validator (note that these were obtained in an earlier step so you can use the output there to confirm that they match). If not, then do not proceed further and obtain help to understand why there is a mismatch. - -Once you are comfortable that the mnemonic and path provide the correct result you can generate and broadcast the credentials change operation with the following command: - -``` -ethdo validator credentials set --validator=123 --execution-address=0x8f…9F --mnemonic='abandon … art' --path='m/12381/3600/0/0' -``` - -again replacing the first '0' in the path with the validator number. +replacing the path with the path to your _withdrawal_ key, and all other parameters with your own values. #### Using a private key If you have the private key from which the current withdrawal credentials were derived this can be used to generate and broadcast the credentials change operation with the following command: ``` -ethdo validator credentials set --validator=123 --execution-address=0x8f…9F --private-key=0x3b…9c +ethdo validator credentials set --validator=123 --withdrawal-address=0x8f…9F --private-key=0x3b…9c ``` -using your own private key. +replacing the parameters with your own values. #### Using an account If you used `ethdo` to generate your validator deposit data you will likely have used a separate account to generate the withdrawal credentials. You can specify the account to generate and broadcast the credentials change operation with the following command: ``` -ethdo validator credentials set --validator=123 --execution-address=0x8f…9F --account=Wallet/Account --passphrase=secret +ethdo validator credentials set --validator=123 --withdrawal-address=0x8f…9F --account=Wallet/Account --passphrase=secret ``` -using your own account and passphrase. - -### Generate the credentials change operation (offline) -Generating the credentials change operation offline requires information from the online component and is more involved than the online process, however does not expose mnemonics, private keys, or passphrases to servers that are connected to the internet. The process is below. - -#### Obtain data required for offline generation. -Generating the credentials change operation requires information that comes from an online beacon node. As such, on your _online_ server you need to run the following command: - -``` -ethdo chain info --prepare-offline -``` - -This will return something similar to the following response: - -``` -Add the following to your command to run it offline: - --offline --genesis-validators=root=0x043db0d9a83813551ee2f33450d23797757d430911a9320530ad8a0eabc43efb --fork-version=0x03001020 -``` - -This information needs to be copied to your offline server to continue. - -#### Generate signed operation -Generating the signed operation offline is different from the online operation in that it will produce an output that can be copied to the online server for broadcast. Separating generation from broadcast allows the offline server to remain securely disconnected from the internet. The generation steps should be run on the _offline_ server. - -#### Using a mnemonic -Many stakers will have generated their validators from a mnemonic. A mnemonic is a 24-word phrase from which withdrawal and validator keys are derived using a path. - -- m/12381/3600/_i_/0 is the path to the _i_th withdrawal key, where _i_ starts at 0 for the first validator, 1 for the second validator, _etc._ -- m/12381/3600/_i_/0/0 is the path to the _i_th validator key, where _i_ starts at 0 for the first validator, 1 for the second validator, _etc._ - -The first step will be to confirm that the mnemonic provides the appropriate validator key. To do so run: - -``` -ethdo account derive --mnemonic='abandon … art' --path='m/12381/3600/0/0/0' -``` - -replacing the first '0' in the path with the validator number (remember that numbering starts at 0 for the first validator). This will provide an output similar to: - -``` -Public key: 0xb384f767d964e100c8a9b21018d08c25ffebae268b3ab6d610353897541971726dbfc3c7463884c68a531515aab94c87 -``` - -The displayed public key should match the public key of the validator of which you are attempting to change the credentials. If not, then do not proceed further and obtain help to understand why there is a mismatch. - -Assuming the displayed public key does match the public key of the validator the next step is to confirm the current withdrawal credentials. To do so run: - -``` -ethdo account derive --mnemonic='abandon … art' --path='m/12381/3600/0/0' --show-withdrawal-credentials -``` - -again replacing the first '0' in the path with the validator number. This will provide an output similar to: - -``` -Public key: 0x99b1f1d84d76185466d86c34bde1101316afddae76217aa86cd066979b19858c2c9d9e56eebc1e067ac54277a61790db -Withdrawal credentials: 0x008ba1cc4b091b91c1202bba3f508075d6ff565c77e559f0803c0792e0302bf1 -``` - -The displayed withdrawal credentials should match the current withdrawal credentials of your validator (note that these were obtained in an earlier step so you can use the output there to confirm that they match). If not, then do not proceed further and obtain help to understand why there is a mismatch. - -Once you are comfortable that the mnemonic and path provide the correct result you can generate the credentials change operation with the following command: - -``` -ethdo validator credentials set --offline --genesis-validators=root=0x04…fb --fork-version=0x03…20 --validator=123 --execution-address=0x8f…9F --mnemonic='abandon … art' --path='m/12381/3600/0/0' -``` - -again replacing the first '0' in the path with the validator number, and using your own execution address as explained earlier in the guide. This will produce output similar to the following: - -``` -{"message":{"validator_index":"123","from_bls_pubkey":"0xad1868210a0cff7aff22633c003c503d4c199c8dcca13bba5b3232fc784d39d3855936e94ce184c3ce27bf15d4347695","to_execution_address":"0x388ea662ef2c223ec0b047d41bf3c0f362142ad5"},"signature":"0x8fcc8ceb75cbea891540150efc7df3e482a74592f89f3fc62a2d034381c776fcd42faad82af7a4af7fb84168a74981ce0ec96cf059e134eaa979c67425138f1915d1a8b1b6056401a9f7a2e79ed673f4b0c6b6ae1f60cff5996318e4769d0642"} - -``` - -#### Using a private key -If you have the private key from which the current withdrawal credentials were derived this can be used to generate the credentials change operation with the following command: - -``` -ethdo validator credentials set --offline --genesis-validators=root=0x04…fb --fork-version=0x03…20 --validator=123 --execution-address=0x8f…9F --private-key=0x3b…9c -``` - -using your own execution address as explained earlier in the guide, and your own private key. This will produce output similar to the following: - -``` -{"message":{"validator_index":"123","from_bls_pubkey":"0xad1868210a0cff7aff22633c003c503d4c199c8dcca13bba5b3232fc784d39d3855936e94ce184c3ce27bf15d4347695","to_execution_address":"0x388ea662ef2c223ec0b047d41bf3c0f362142ad5"},"signature":"0x8fcc8ceb75cbea891540150efc7df3e482a74592f89f3fc62a2d034381c776fcd42faad82af7a4af7fb84168a74981ce0ec96cf059e134eaa979c67425138f1915d1a8b1b6056401a9f7a2e79ed673f4b0c6b6ae1f60cff5996318e4769d0642"} -``` - -#### Using an account -If you used `ethdo` to generate your validator deposit data you will likely have used a separate account to generate the withdrawal credentials. You can specify the account to generate the credentials change operation with the following command: - -``` -ethdo validator credentials set --offline --genesis-validators=root=0x04…fb --fork-version=0x03…20 --validator=123 --execution-address=0x8f…9F --account=Wallet/Account --passphrase=secret -``` - -setting the execution address, account and passphrase to your own values. This will produce output similar to the following: - -``` -{"message":{"validator_index":"123","from_bls_pubkey":"0xad1868210a0cff7aff22633c003c503d4c199c8dcca13bba5b3232fc784d39d3855936e94ce184c3ce27bf15d4347695","to_execution_address":"0x388ea662ef2c223ec0b047d41bf3c0f362142ad5"},"signature":"0x8fcc8ceb75cbea891540150efc7df3e482a74592f89f3fc62a2d034381c776fcd42faad82af7a4af7fb84168a74981ce0ec96cf059e134eaa979c67425138f1915d1a8b1b6056401a9f7a2e79ed673f4b0c6b6ae1f60cff5996318e4769d0642"} -``` - -### Broadcasting a previously-generated credentials change operation -An online server can broadcast the result of the previous step. Note that the data does not expose any sensitive information such as private keys, and as such is safe to be accessed by the online server. Broadcasting the operation is a simple case of supplying it to `ethdo`: - -``` -ethdo validator credentials set --signed-operation='{"message":{"validator_index":"123","from_bls_pubkey":"0xad1868210a0cff7aff22633c003c503d4c199c8dcca13bba5b3232fc784d39d3855936e94ce184c3ce27bf15d4347695","to_execution_address":"0x388ea662ef2c223ec0b047d41bf3c0f362142ad5"},"signature":"0x8fcc8ceb75cbea891540150efc7df3e482a74592f89f3fc62a2d034381c776fcd42faad82af7a4af7fb84168a74981ce0ec96cf059e134eaa979c67425138f1915d1a8b1b6056401a9f7a2e79ed673f4b0c6b6ae1f60cff5996318e4769d0642"}' -``` - -Alternatively, if the operation is stored on a filesystem, for example on a USB device from where it was copied from the offline server, it can be accessed with: - -``` -ethdo validator credentials set --signed-operation=/path/to/signed/operation -``` +replacing the parameters with your own values. ## Confirming the process has succeeded The final step is confirming the operation has taken place. To do so, run the following command on an online server: diff --git a/docs/images/credentials-change-offline.png b/docs/images/credentials-change-offline.png new file mode 100644 index 0000000..831a167 Binary files /dev/null and b/docs/images/credentials-change-offline.png differ diff --git a/docs/images/credentials-change-online.png b/docs/images/credentials-change-online.png new file mode 100644 index 0000000..15c6f72 Binary files /dev/null and b/docs/images/credentials-change-online.png differ diff --git a/docs/images/diagrams.svg b/docs/images/diagrams.svg new file mode 100644 index 0000000..a7000f2 --- /dev/null +++ b/docs/images/diagrams.svg @@ -0,0 +1,923 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Localconsensus node + Consensusnetwork + Consensusnetwork + User + + + + + + + + + + + + + + + + ethdo(with keys) + Online + + + diff --git a/services/chaintime/service.go b/services/chaintime/service.go index 911e3ac..6934f0d 100644 --- a/services/chaintime/service.go +++ b/services/chaintime/service.go @@ -54,4 +54,6 @@ type Service interface { AltairInitialEpoch() phase0.Epoch // AltairInitialSyncCommitteePeriod provides the sync committee period in which the Altair hard fork takes place. AltairInitialSyncCommitteePeriod() uint64 + // CapellaInitialEpoch provides the epoch at which the Capella hard fork takes place. + CapellaInitialEpoch() phase0.Epoch } diff --git a/util/account.go b/util/account.go index 78304c3..07a7c48 100644 --- a/util/account.go +++ b/util/account.go @@ -17,14 +17,11 @@ import ( "context" "encoding/hex" "fmt" - "regexp" "strings" "github.com/pkg/errors" - "github.com/tyler-smith/go-bip39" util "github.com/wealdtech/go-eth2-util" e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" - "golang.org/x/text/unicode/norm" ) // ParseAccount parses input to obtain an account. @@ -111,27 +108,11 @@ func ParseAccount(ctx context.Context, return account, nil } -// hdPathRegex is the regular expression that matches an HD path. -var hdPathRegex = regexp.MustCompile("^m/[0-9]+/[0-9]+(/[0-9+])+") - func accountFromMnemonicAndPath(mnemonic string, path string) (e2wtypes.Account, 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:], " ") + seed, err := SeedFromMnemonic(mnemonic) + if err != nil { + return nil, err } - // Normalise the input. - mnemonic = string(norm.NFKD.Bytes([]byte(mnemonic))) - mnemonicPassphrase = string(norm.NFKD.Bytes([]byte(mnemonicPassphrase))) - - if !bip39.IsMnemonicValid(mnemonic) { - return nil, errors.New("mnemonic is invalid") - } - - // Create seed from mnemonic and passphrase. - seed := bip39.NewSeed(mnemonic, mnemonicPassphrase) // Ensure the path is valid. match := hdPathRegex.Match([]byte(path)) diff --git a/util/mnemonic.go b/util/mnemonic.go new file mode 100644 index 0000000..2d8e98a --- /dev/null +++ b/util/mnemonic.go @@ -0,0 +1,47 @@ +// Copyright © 2020, 2022 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "regexp" + "strings" + + "github.com/pkg/errors" + "github.com/tyler-smith/go-bip39" + "golang.org/x/text/unicode/norm" +) + +// hdPathRegex is the regular expression that matches an HD path. +var hdPathRegex = regexp.MustCompile("^m/[0-9]+/[0-9]+(/[0-9+])+") + +// SeedFromMnemonic creates a seed from a mnemonic. +func SeedFromMnemonic(mnemonic string) ([]byte, 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 nil, errors.New("mnemonic is invalid") + } + + // Create seed from mnemonic and passphrase. + return bip39.NewSeed(mnemonic, mnemonicPassphrase), nil +}