diff --git a/CHANGELOG.md b/CHANGELOG.md index b929d24..929d06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ -dev: +1.27.0: - use new build system - support S3 credentials + - update operation of validator exit to match validator credentials set 1.26.5: - provide validator information in "chain status" verbose output diff --git a/beacon/chaininfo.go b/beacon/chaininfo.go new file mode 100644 index 0000000..2e2c19d --- /dev/null +++ b/beacon/chaininfo.go @@ -0,0 +1,300 @@ +// Copyright © 2023 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 beacon + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/wealdtech/ethdo/services/chaintime" + "github.com/wealdtech/ethdo/util" +) + +type ChainInfo struct { + Version uint64 + Validators []*ValidatorInfo + GenesisValidatorsRoot phase0.Root + Epoch phase0.Epoch + GenesisForkVersion phase0.Version + CurrentForkVersion phase0.Version + BLSToExecutionChangeDomainType phase0.DomainType + VoluntaryExitDomainType phase0.DomainType +} + +type chainInfoJSON struct { + Version string `json:"version"` + Validators []*ValidatorInfo `json:"validators"` + GenesisValidatorsRoot string `json:"genesis_validators_root"` + Epoch string `json:"epoch"` + GenesisForkVersion string `json:"genesis_fork_version"` + CurrentForkVersion string `json:"current_fork_version"` + BLSToExecutionChangeDomainType string `json:"bls_to_execution_change_domain_type"` + VoluntaryExitDomainType string `json:"voluntary_exit_domain_type"` +} + +type chainInfoVersionJSON struct { + Version string `json:"version"` +} + +// MarshalJSON implements json.Marshaler. +func (c *ChainInfo) MarshalJSON() ([]byte, error) { + return json.Marshal(&chainInfoJSON{ + Version: fmt.Sprintf("%d", c.Version), + Validators: c.Validators, + GenesisValidatorsRoot: fmt.Sprintf("%#x", c.GenesisValidatorsRoot), + Epoch: fmt.Sprintf("%d", c.Epoch), + GenesisForkVersion: fmt.Sprintf("%#x", c.GenesisForkVersion), + CurrentForkVersion: fmt.Sprintf("%#x", c.CurrentForkVersion), + BLSToExecutionChangeDomainType: fmt.Sprintf("%#x", c.BLSToExecutionChangeDomainType), + VoluntaryExitDomainType: fmt.Sprintf("%#x", c.VoluntaryExitDomainType), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (c *ChainInfo) UnmarshalJSON(input []byte) error { + // See which version we are dealing with. + var metadata chainInfoVersionJSON + if err := json.Unmarshal(input, &metadata); err != nil { + return errors.Wrap(err, "invalid JSON") + } + if metadata.Version == "" { + return errors.New("version missing") + } + version, err := strconv.ParseUint(metadata.Version, 10, 64) + if err != nil { + return errors.Wrap(err, "version invalid") + } + if version < 2 { + return errors.New("outdated version; please regenerate your offline data") + } + c.Version = version + + var data chainInfoJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if len(data.Validators) == 0 { + return errors.New("validators missing") + } + c.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(c.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") + } + c.Epoch = phase0.Epoch(epoch) + + if data.GenesisForkVersion == "" { + return errors.New("genesis fork version missing") + } + genesisForkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(data.GenesisForkVersion, "0x")) + if err != nil { + return errors.Wrap(err, "genesis fork version invalid") + } + if len(genesisForkVersionBytes) != phase0.ForkVersionLength { + return errors.New("genesis fork version incorrect length") + } + copy(c.GenesisForkVersion[:], genesisForkVersionBytes) + + if data.CurrentForkVersion == "" { + return errors.New("current fork version missing") + } + currentForkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(data.CurrentForkVersion, "0x")) + if err != nil { + return errors.Wrap(err, "current fork version invalid") + } + if len(currentForkVersionBytes) != phase0.ForkVersionLength { + return errors.New("current fork version incorrect length") + } + copy(c.CurrentForkVersion[:], currentForkVersionBytes) + + if data.BLSToExecutionChangeDomainType == "" { + return errors.New("bls to execution domain type missing") + } + blsToExecutionChangeDomainType, err := hex.DecodeString(strings.TrimPrefix(data.BLSToExecutionChangeDomainType, "0x")) + if err != nil { + return errors.Wrap(err, "bls to execution domain type invalid") + } + if len(blsToExecutionChangeDomainType) != phase0.DomainTypeLength { + return errors.New("bls to execution domain type incorrect length") + } + copy(c.BLSToExecutionChangeDomainType[:], blsToExecutionChangeDomainType) + + if data.VoluntaryExitDomainType == "" { + return errors.New("voluntary exit domain type missing") + } + voluntaryExitDomainType, err := hex.DecodeString(strings.TrimPrefix(data.VoluntaryExitDomainType, "0x")) + if err != nil { + return errors.Wrap(err, "voluntary exit domain type invalid") + } + if len(voluntaryExitDomainType) != phase0.DomainTypeLength { + return errors.New("voluntary exit domain type incorrect length") + } + copy(c.VoluntaryExitDomainType[:], voluntaryExitDomainType) + + return nil +} + +// FetchValidatorInfo fetches validator info given a validator identifier. +func (c *ChainInfo) FetchValidatorInfo(ctx context.Context, id string) (*ValidatorInfo, error) { + var validatorInfo *ValidatorInfo + switch { + case id == "": + return nil, errors.New("no validator specified") + case strings.HasPrefix(id, "0x"): + // A public key. + for _, validator := range c.Validators { + if strings.EqualFold(id, fmt.Sprintf("%#x", validator.Pubkey)) { + validatorInfo = validator + break + } + } + case strings.Contains(id, "/"): + // An account. + _, account, err := util.WalletAndAccountFromPath(ctx, id) + 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.Validators { + if strings.EqualFold(pubkey, fmt.Sprintf("%#x", validator.Pubkey)) { + validatorInfo = validator + break + } + } + default: + // An index. + index, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "failed to parse validator index") + } + validatorIndex := phase0.ValidatorIndex(index) + for _, validator := range c.Validators { + if validator.Index == validatorIndex { + validatorInfo = validator + break + } + } + } + + if validatorInfo == nil { + return nil, errors.New("unknown validator") + } + + return validatorInfo, nil +} + +// ObtainChainInfoFromNode obtains the chain information from a node. +func ObtainChainInfoFromNode(ctx context.Context, + consensusClient consensusclient.Service, + chainTime chaintime.Service, +) ( + *ChainInfo, + error, +) { + res := &ChainInfo{ + Version: 2, + Validators: make([]*ValidatorInfo, 0), + Epoch: chainTime.CurrentEpoch(), + } + + // Obtain validators. + validators, err := consensusClient.(consensusclient.ValidatorsProvider).Validators(ctx, "head", nil) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain validators") + } + + for _, validator := range validators { + res.Validators = append(res.Validators, &ValidatorInfo{ + Index: validator.Index, + Pubkey: validator.Validator.PublicKey, + WithdrawalCredentials: validator.Validator.WithdrawalCredentials, + State: validator.Status, + }) + } + + // Genesis validators root obtained from beacon node. + genesis, err := consensusClient.(consensusclient.GenesisProvider).Genesis(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain genesis information") + } + res.GenesisValidatorsRoot = genesis.GenesisValidatorsRoot + + // Fetch the genesis fork version from the specification. + spec, err := consensusClient.(consensusclient.SpecProvider).Spec(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain spec") + } + tmp, exists := spec["GENESIS_FORK_VERSION"] + if !exists { + return nil, errors.New("capella fork version not known by chain") + } + var isForkVersion bool + res.GenesisForkVersion, isForkVersion = tmp.(phase0.Version) + if !isForkVersion { + return nil, errors.New("could not obtain GENESIS_FORK_VERSION") + } + + // Fetch the current fork version from the fork schedule. + forkSchedule, err := consensusClient.(consensusclient.ForkScheduleProvider).ForkSchedule(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to obtain fork schedule") + } + for i := range forkSchedule { + if forkSchedule[i].Epoch <= res.Epoch { + res.CurrentForkVersion = forkSchedule[i].CurrentVersion + } + } + + blsToExecutionChangeDomainType, exists := spec["DOMAIN_BLS_TO_EXECUTION_CHANGE"].(phase0.DomainType) + if !exists { + return nil, errors.New("failed to obtain DOMAIN_BLS_TO_EXECUTION_CHANGE") + } + copy(res.BLSToExecutionChangeDomainType[:], blsToExecutionChangeDomainType[:]) + + voluntaryExitDomainType, exists := spec["DOMAIN_VOLUNTARY_EXIT"].(phase0.DomainType) + if !exists { + return nil, errors.New("failed to obtain DOMAIN_VOLUNTARY_EXIT") + } + copy(res.VoluntaryExitDomainType[:], voluntaryExitDomainType[:]) + + return res, nil +} diff --git a/cmd/validator/credentials/set/validatorinfo.go b/beacon/validatorinfo.go similarity index 77% rename from cmd/validator/credentials/set/validatorinfo.go rename to beacon/validatorinfo.go index 1709b7d..23b1f61 100644 --- a/cmd/validator/credentials/set/validatorinfo.go +++ b/beacon/validatorinfo.go @@ -1,4 +1,4 @@ -// Copyright © 2022 Weald Technology Trading. +// Copyright © 2023 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 @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package validatorcredentialsset +package beacon import ( "encoding/hex" @@ -20,33 +20,37 @@ import ( "strconv" "strings" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) -type validatorInfo struct { +type ValidatorInfo struct { Index phase0.ValidatorIndex Pubkey phase0.BLSPubKey + State apiv1.ValidatorState WithdrawalCredentials []byte } type validatorInfoJSON struct { - Index string `json:"index"` - Pubkey string `json:"pubkey"` - WithdrawalCredentials string `json:"withdrawal_credentials"` + Index string `json:"index"` + Pubkey string `json:"pubkey"` + State apiv1.ValidatorState `json:"state"` + WithdrawalCredentials string `json:"withdrawal_credentials"` } // MarshalJSON implements json.Marshaler. -func (v *validatorInfo) MarshalJSON() ([]byte, error) { +func (v *ValidatorInfo) MarshalJSON() ([]byte, error) { return json.Marshal(&validatorInfoJSON{ Index: fmt.Sprintf("%d", v.Index), Pubkey: fmt.Sprintf("%#x", v.Pubkey), + State: v.State, WithdrawalCredentials: fmt.Sprintf("%#x", v.WithdrawalCredentials), }) } // UnmarshalJSON implements json.Unmarshaler. -func (v *validatorInfo) UnmarshalJSON(input []byte) error { +func (v *ValidatorInfo) UnmarshalJSON(input []byte) error { var data validatorInfoJSON if err := json.Unmarshal(input, &data); err != nil { return errors.Wrap(err, "invalid JSON") @@ -73,6 +77,11 @@ func (v *validatorInfo) UnmarshalJSON(input []byte) error { } copy(v.Pubkey[:], pubkey) + if data.State == apiv1.ValidatorStateUnknown { + return errors.New("state unknown") + } + v.State = data.State + if data.WithdrawalCredentials == "" { return errors.New("withdrawal credentials missing") } @@ -88,7 +97,7 @@ func (v *validatorInfo) UnmarshalJSON(input []byte) error { } // String implements the Stringer interface. -func (v *validatorInfo) String() string { +func (v *ValidatorInfo) String() string { data, err := json.Marshal(v) if err != nil { return fmt.Sprintf("Err: %v\n", err) diff --git a/cmd/attester/inclusion/process.go b/cmd/attester/inclusion/process.go index 9923deb..f3e7223 100644 --- a/cmd/attester/inclusion/process.go +++ b/cmd/attester/inclusion/process.go @@ -35,7 +35,6 @@ func process(ctx context.Context, data *dataIn) (*dataOut, error) { data.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(data.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(data.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(data.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/block/analyze/process.go b/cmd/block/analyze/process.go index bac18ef..4c25542 100644 --- a/cmd/block/analyze/process.go +++ b/cmd/block/analyze/process.go @@ -266,7 +266,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/chain/eth1votes/process.go b/cmd/chain/eth1votes/process.go index 0cb1ff2..37ffd56 100644 --- a/cmd/chain/eth1votes/process.go +++ b/cmd/chain/eth1votes/process.go @@ -121,7 +121,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/chain/queues/process.go b/cmd/chain/queues/process.go index c22b97f..0bf2a22 100644 --- a/cmd/chain/queues/process.go +++ b/cmd/chain/queues/process.go @@ -65,7 +65,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/chainstatus.go b/cmd/chainstatus.go index 43ec2ef..c4e6e47 100644 --- a/cmd/chainstatus.go +++ b/cmd/chainstatus.go @@ -46,7 +46,6 @@ In quiet mode this will return 0 if the chain status can be obtained, otherwise chainTime, err := standardchaintime.New(ctx, standardchaintime.WithGenesisTimeProvider(eth2Client.(eth2client.GenesisTimeProvider)), - standardchaintime.WithForkScheduleProvider(eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithSpecProvider(eth2Client.(eth2client.SpecProvider)), ) errCheck(err, "Failed to configure chaintime service") diff --git a/cmd/epoch/summary/process.go b/cmd/epoch/summary/process.go index 3782119..8f45dcb 100644 --- a/cmd/epoch/summary/process.go +++ b/cmd/epoch/summary/process.go @@ -335,7 +335,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/proposer/duties/process.go b/cmd/proposer/duties/process.go index 9fa6728..12887e3 100644 --- a/cmd/proposer/duties/process.go +++ b/cmd/proposer/duties/process.go @@ -53,7 +53,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/signaturesign.go b/cmd/signaturesign.go index 5c40f54..90256d2 100644 --- a/cmd/signaturesign.go +++ b/cmd/signaturesign.go @@ -1,4 +1,4 @@ -// Copyright © 2017-2020 Weald Technology Trading +// Copyright © 2017-2023 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 @@ -24,6 +24,7 @@ import ( "github.com/wealdtech/ethdo/util" "github.com/wealdtech/go-bytesutil" e2types "github.com/wealdtech/go-eth2-types/v2" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) // signatureSignCmd represents the signature sign command @@ -52,14 +53,20 @@ In quiet mode this will return 0 if the data can be signed, otherwise 1.`, } outputIf(debug, fmt.Sprintf("Domain is %#x", domain)) - assert(viper.GetString("account") != "", "--account is required") - _, account, err := walletAndAccountFromInput(ctx) + var account e2wtypes.Account + switch { + case viper.GetString("account") != "": + account, err = util.ParseAccount(ctx, viper.GetString("account"), util.GetPassphrases(), true) + case viper.GetString("private-key") != "": + account, err = util.ParseAccount(ctx, viper.GetString("private-key"), nil, true) + } errCheck(err, "Failed to obtain account") var specDomain spec.Domain copy(specDomain[:], domain) var fixedSizeData [32]byte copy(fixedSizeData[:], data) + fmt.Printf("Signing %#x with domain %#x by public key %#x\n", fixedSizeData, specDomain, account.PublicKey().Marshal()) signature, err := util.SignRoot(account, fixedSizeData, specDomain) errCheck(err, "Failed to sign") diff --git a/cmd/signatureverify.go b/cmd/signatureverify.go index 57104a6..2af0d2c 100644 --- a/cmd/signatureverify.go +++ b/cmd/signatureverify.go @@ -1,4 +1,4 @@ -// Copyright © 2017-2020 Weald Technology Trading +// Copyright © 2017-2023 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 @@ -15,13 +15,10 @@ package cmd import ( "context" - "encoding/hex" "fmt" "os" - "strings" spec "github.com/attestantio/go-eth2-client/spec/phase0" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/wealdtech/ethdo/util" @@ -43,6 +40,9 @@ var signatureVerifyCmd = &cobra.Command{ In quiet mode this will return 0 if the data can be signed, otherwise 1.`, Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithTimeout(context.Background(), viper.GetDuration("timeout")) + defer cancel() + assert(viper.GetString("signature-data") != "", "--data is required") data, err := bytesutil.FromHexString(viper.GetString("signature-data")) errCheck(err, "Failed to parse data") @@ -61,7 +61,15 @@ In quiet mode this will return 0 if the data can be signed, otherwise 1.`, assert(len(domain) == 32, "Domain data invalid") } - account, err := signatureVerifyAccount() + var account e2wtypes.Account + switch { + case viper.GetString("account") != "": + account, err = util.ParseAccount(ctx, viper.GetString("account"), nil, false) + case viper.GetString("private-key") != "": + account, err = util.ParseAccount(ctx, viper.GetString("private-key"), nil, false) + case viper.GetString("public-key") != "": + account, err = util.ParseAccount(ctx, viper.GetString("public-key"), nil, false) + } errCheck(err, "Failed to obtain account") outputIf(debug, fmt.Sprintf("Public key is %#x", account.PublicKey().Marshal())) @@ -78,29 +86,6 @@ In quiet mode this will return 0 if the data can be signed, otherwise 1.`, }, } -// signatureVerifyAccount obtains the account for the signature verify command. -func signatureVerifyAccount() (e2wtypes.Account, error) { - var account e2wtypes.Account - var err error - if viper.GetString("account") != "" { - ctx, cancel := context.WithTimeout(context.Background(), viper.GetDuration("timeout")) - defer cancel() - _, account, err = walletAndAccountFromPath(ctx, viper.GetString("account")) - if err != nil { - return nil, errors.Wrap(err, "failed to obtain account") - } - } else { - pubKeyBytes, err := hex.DecodeString(strings.TrimPrefix(signatureVerifySigner, "0x")) - if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("failed to decode public key %s", signatureVerifySigner)) - } - account, err = util.NewScratchAccount(nil, pubKeyBytes) - if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("invalid public key %s", signatureVerifySigner)) - } - } - return account, nil -} func init() { signatureCmd.AddCommand(signatureVerifyCmd) signatureFlags(signatureVerifyCmd) diff --git a/cmd/synccommittee/inclusion/process.go b/cmd/synccommittee/inclusion/process.go index fd4d836..4bd5f2d 100644 --- a/cmd/synccommittee/inclusion/process.go +++ b/cmd/synccommittee/inclusion/process.go @@ -114,7 +114,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/synccommittee/members/input.go b/cmd/synccommittee/members/input.go index 160158c..b83a4ab 100644 --- a/cmd/synccommittee/members/input.go +++ b/cmd/synccommittee/members/input.go @@ -59,7 +59,6 @@ func input(ctx context.Context) (*dataIn, error) { // Chain time. data.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithGenesisTimeProvider(data.eth2Client.(eth2client.GenesisTimeProvider)), - standardchaintime.WithForkScheduleProvider(data.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithSpecProvider(data.eth2Client.(eth2client.SpecProvider)), ) if err != nil { diff --git a/cmd/synccommittee/members/process_internal_test.go b/cmd/synccommittee/members/process_internal_test.go index d3bdda8..0053ac4 100644 --- a/cmd/synccommittee/members/process_internal_test.go +++ b/cmd/synccommittee/members/process_internal_test.go @@ -37,7 +37,6 @@ func TestProcess(t *testing.T) { chainTime, err := standardchaintime.New(context.Background(), standardchaintime.WithGenesisTimeProvider(eth2Client.(eth2client.GenesisTimeProvider)), - standardchaintime.WithForkScheduleProvider(eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithSpecProvider(eth2Client.(eth2client.SpecProvider)), ) require.NoError(t, err) diff --git a/cmd/validator/credentials/set/chaininfo.go b/cmd/validator/credentials/set/chaininfo.go index 8fa404a..b706304 100644 --- a/cmd/validator/credentials/set/chaininfo.go +++ b/cmd/validator/credentials/set/chaininfo.go @@ -1,4 +1,4 @@ -// Copyright © 2022 Weald Technology Trading. +// Copyright © 2022, 2023 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 @@ -14,114 +14,92 @@ package validatorcredentialsset import ( - "encoding/hex" + "context" "encoding/json" "fmt" - "strconv" - "strings" + "os" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" + "github.com/wealdtech/ethdo/beacon" ) -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") +// obtainChainInfo obtains the chain information required to create a withdrawal credentials change operation. +func (c *command) obtainChainInfo(ctx context.Context) error { + // Use the offline preparation file if present (and we haven't been asked to recreate it). + if !c.prepareOffline { + err := c.obtainChainInfoFromFile(ctx) + if err == nil { + return nil } - 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") + if c.offline { + return fmt.Errorf("%s is unavailable or outdated; this is required to have been previously generated using --offline-preparation on an online machine and be readable in the directory in which this command is being run", offlinePreparationFilename) } - genesisValidatorsRootBytes, err := hex.DecodeString(strings.TrimPrefix(data.GenesisValidatorsRoot, "0x")) - if err != nil { - return errors.Wrap(err, "genesis validators root invalid") + if err := c.obtainChainInfoFromNode(ctx); err != nil { + return err + } + + return nil +} + +// obtainChainInfoFromFile obtains chain information from a pre-generated file. +func (c *command) obtainChainInfoFromFile(_ context.Context) error { + _, err := os.Stat(offlinePreparationFilename) + if err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "Failed to read offline preparation file: %v\n", err) + } + return errors.Wrap(err, fmt.Sprintf("cannot find %s", offlinePreparationFilename)) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "%s found; loading chain state\n", offlinePreparationFilename) + } + data, err := os.ReadFile(offlinePreparationFilename) + if err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "failed to load chain state: %v\n", err) + } + return errors.Wrap(err, "failed to read offline preparation file") + } + c.chainInfo = &beacon.ChainInfo{} + if err := json.Unmarshal(data, c.chainInfo); err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "chain state invalid: %v\n", err) + } + return errors.Wrap(err, "failed to parse offline preparation file") + } + + return nil +} + +// obtainChainInfoFromNode obtains chain info from a beacon node. +func (c *command) obtainChainInfoFromNode(ctx context.Context) error { + if c.debug { + fmt.Fprintf(os.Stderr, "Populating chain info from beacon node\n") + } + + var err error + c.chainInfo, err = beacon.ObtainChainInfoFromNode(ctx, c.consensusClient, c.chainTime) + if err != nil { + return err + } + + return nil +} + +// writeChainInfoToFile prepares for an offline run of this command by dumping +// the chain information to a file. +func (c *command) writeChainInfoToFile(_ 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 } - 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 b4dc497..b8b7305 100644 --- a/cmd/validator/credentials/set/command.go +++ b/cmd/validator/credentials/set/command.go @@ -20,8 +20,10 @@ import ( consensusclient "github.com/attestantio/go-eth2-client" "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/beacon" "github.com/wealdtech/ethdo/services/chaintime" "github.com/wealdtech/ethdo/util" ) @@ -54,7 +56,8 @@ type command struct { // Information required to generate the operations. withdrawalAddress bellatrix.ExecutionAddress - chainInfo *chainInfo + chainInfo *beacon.ChainInfo + domain phase0.Domain // Processing. consensusClient consensusclient.Service diff --git a/cmd/validator/credentials/set/output.go b/cmd/validator/credentials/set/output.go index 88a1d60..4aab0d1 100644 --- a/cmd/validator/credentials/set/output.go +++ b/cmd/validator/credentials/set/output.go @@ -28,6 +28,10 @@ func (c *command) output(_ context.Context) (string, error) { return "", nil } + if c.prepareOffline { + return fmt.Sprintf("%s generated", offlinePreparationFilename), nil + } + if c.json || c.offline { data, err := json.Marshal(c.signedOperations) if err != nil { diff --git a/cmd/validator/credentials/set/process.go b/cmd/validator/credentials/set/process.go index 9b6b265..64990cc 100644 --- a/cmd/validator/credentials/set/process.go +++ b/cmd/validator/credentials/set/process.go @@ -1,4 +1,4 @@ -// Copyright © 2022 Weald Technology Trading. +// Copyright © 2022, 2023 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 @@ -21,7 +21,6 @@ import ( "fmt" "os" "regexp" - "strconv" "strings" consensusclient "github.com/attestantio/go-eth2-client" @@ -30,6 +29,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" "github.com/prysmaticlabs/go-ssz" + "github.com/wealdtech/ethdo/beacon" standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard" "github.com/wealdtech/ethdo/signing" "github.com/wealdtech/ethdo/util" @@ -49,15 +49,19 @@ func (c *command) process(ctx context.Context) error { return err } - if err := c.obtainRequiredInformation(ctx); err != nil { + if err := c.obtainChainInfo(ctx); err != nil { return err } if c.prepareOffline { - return c.dumpRequiredInformation(ctx) + return c.writeChainInfoToFile(ctx) } - if err := c.generateOperations(ctx); err != nil { + if err := c.generateDomain(ctx); err != nil { + return err + } + + if err := c.obtainOperations(ctx); err != nil { return err } @@ -76,204 +80,28 @@ func (c *command) process(ctx context.Context) error { return c.broadcastOperations(ctx) } -// 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), - } - - // 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 machine and be readable in the directory in which this command is being run", offlinePreparationFilename) - } - - if err := c.populateChainInfo(ctx); err != nil { - return err - } - - return nil -} - -// populateChainInfo populates chain info structure from a beacon node. -func (c *command) populateChainInfo(ctx context.Context) error { - if c.debug { - fmt.Fprintf(os.Stderr, "Populating chain info from beacon node\n") - } - - // 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. - if c.genesisValidatorsRoot != "" { - // Genesis validators root supplied manually. - genesisValidatorsRoot, err := hex.DecodeString(strings.TrimPrefix(c.genesisValidatorsRoot, "0x")) - if err != nil { - return errors.Wrap(err, "invalid genesis validators root supplied") - } - if len(genesisValidatorsRoot) != phase0.RootLength { - return errors.New("invalid length for genesis validators root") - } - copy(c.chainInfo.GenesisValidatorsRoot[:], genesisValidatorsRoot) - } else { - // Genesis validators root obtained from beacon node. - 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 - } - if c.debug { - fmt.Fprintf(os.Stderr, "Genesis validators root is %#x\n", c.chainInfo.GenesisValidatorsRoot) - } - - // Obtain epoch. - c.chainInfo.Epoch = c.chainTime.CurrentEpoch() - - // Obtain fork version. - if c.forkVersion != "" { - if err := c.populateChainInfoForkVersionFromInput(ctx); err != nil { - return err - } - } else { - if err := c.populateChainInfoForkVersionFromChain(ctx); err != nil { - return err - } - } - if c.debug { - fmt.Fprintf(os.Stderr, "Fork version is %#x\n", c.chainInfo.ForkVersion) - } - - // 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") - } - if c.debug { - fmt.Fprintf(os.Stderr, "Domain type is %#x\n", domainType) - } - copy(c.chainInfo.Domain[:], domainType[:]) - - root, err := (&phase0.ForkData{ - CurrentVersion: c.chainInfo.ForkVersion, - GenesisValidatorsRoot: c.chainInfo.GenesisValidatorsRoot, - }).HashTreeRoot() - if err != nil { - return errors.Wrap(err, "failed to calculate signature domain") - } - copy(c.chainInfo.Domain[4:], root[:]) - - if c.debug { - fmt.Fprintf(os.Stderr, "Domain is %#x\n", c.chainInfo.Domain) - } - - return nil -} - -func (c *command) populateChainInfoForkVersionFromInput(_ context.Context) error { - // Fork version supplied manually. - forkVersion, err := hex.DecodeString(strings.TrimPrefix(c.forkVersion, "0x")) - if err != nil { - return errors.Wrap(err, "invalid fork version supplied") - } - if len(forkVersion) != phase0.ForkVersionLength { - return errors.New("invalid length for fork version") - } - copy(c.chainInfo.ForkVersion[:], forkVersion) - - return nil -} - -func (c *command) populateChainInfoForkVersionFromChain(ctx context.Context) error { - // Fetch the capella fork version from the specification. - spec, err := c.consensusClient.(consensusclient.SpecProvider).Spec(ctx) - if err != nil { - return errors.Wrap(err, "failed to obtain spec") - } - tmp, exists := spec["CAPELLA_FORK_VERSION"] - if !exists { - return errors.New("capella fork version not known by chain") - } - capellaForkVersion, isForkVersion := tmp.(phase0.Version) - if !isForkVersion { - //nolint:revive - return errors.New("CAPELLA_FORK_VERSION is not a fork version!") - } - c.chainInfo.ForkVersion = capellaForkVersion - - // Work through the fork schedule to find the latest current fork post-Capella. - forkSchedule, err := c.consensusClient.(consensusclient.ForkScheduleProvider).ForkSchedule(ctx) - if err != nil { - return errors.Wrap(err, "failed to obtain fork schedule") - } - foundCapella := false - for i := range forkSchedule { - if foundCapella && forkSchedule[i].Epoch <= c.chainInfo.Epoch { - c.chainInfo.ForkVersion = forkSchedule[i].CurrentVersion - } - if bytes.Equal(forkSchedule[i].CurrentVersion[:], capellaForkVersion[:]) { - foundCapella = true - } - } - - return nil -} - -// dumpRequiredInformation prepares for an offline run of this command by dumping -// the chain information to a file. -func (c *command) dumpRequiredInformation(_ 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 { +func (c *command) obtainOperations(ctx context.Context) error { if c.account == "" && c.mnemonic == "" && c.privateKey == "" && c.validator == "" { // No input information; fetch the operations from a file. - err := c.loadOperations(ctx) + err := c.obtainOperationsFromFileOrInput(ctx) if err == nil { // Success. return nil } - return fmt.Errorf("no account, mnemonic or private key specified and no %s file loaded: %v", changeOperationsFilename, err) + if c.signedOperationsInput != "" { + return errors.Wrap(err, "failed to obtain supplied signed operations") + } + return errors.Wrap(err, fmt.Sprintf("no account, mnemonic or private key specified, and no %s file loaded", changeOperationsFilename)) } if c.mnemonic != "" { switch { case c.path != "": // Have a mnemonic and path. - return c.generateOperationsFromMnemonicAndPath(ctx) + return c.generateOperationFromMnemonicAndPath(ctx) case c.validator != "": // Have a mnemonic and validator. - return c.generateOperationsFromMnemonicAndValidator(ctx) + return c.generateOperationFromMnemonicAndValidator(ctx) case c.privateKey != "": // Have a mnemonic and a private key for the withdrawal address. return c.generateOperationsFromMnemonicAndPrivateKey(ctx) @@ -302,117 +130,82 @@ func (c *command) generateOperations(ctx context.Context) error { return errors.New("unsupported combination of inputs; see help for details of supported combinations") } -func (c *command) loadChainInfo(_ context.Context) error { - _, err := os.Stat(offlinePreparationFilename) +func (c *command) generateOperationFromMnemonicAndPath(ctx context.Context) error { + seed, err := util.SeedFromMnemonic(c.mnemonic) if err != nil { - if c.debug { - fmt.Fprintf(os.Stderr, "Failed to read offline preparation file: %v\n", err) + return err + } + + // Turn the validators in to a map for easy lookup. + validators := make(map[string]*beacon.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 for a validator", 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 +} + +func (c *command) generateOperationFromMnemonicAndValidator(ctx context.Context) error { + seed, err := util.SeedFromMnemonic(c.mnemonic) + if err != nil { + return err + } + + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, c.validator) + if err != nil { + return err + } + + // Scan the keys from the seed to find the path. + maxDistance := 1024 + // Start scanning the validator keys. + var withdrawalAccount e2wtypes.Account + for i := 0; ; i++ { + if i == maxDistance { + if c.debug { + fmt.Fprintf(os.Stderr, "Gone %d indices without finding the validator, not scanning any further\n", maxDistance) + } + break } - return errors.Wrap(err, fmt.Sprintf("cannot find %s", offlinePreparationFilename)) - } - - if c.debug { - fmt.Fprintf(os.Stderr, "%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 { - // Start off by attempting to use the provided signed operations. - if c.signedOperationsInput != "" { - return c.loadOperationsFromInput(ctx) - } - - // If not, read it from the file with the standard name. - _, err := os.Stat(changeOperationsFilename) - if err != nil { - return errors.Wrap(err, "failed to read change operations file") - } - if c.debug { - fmt.Fprintf(os.Stderr, "%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") - } - - for _, op := range c.signedOperations { - if err := c.verifyOperation(ctx, op); err != nil { - return err - } - } - - return nil -} - -func (c *command) verifyOperation(ctx context.Context, op *capella.SignedBLSToExecutionChange) error { - root, err := op.Message.HashTreeRoot() - if err != nil { - return errors.Wrap(err, "failed to generate message root") - } - - sigBytes := make([]byte, len(op.Signature)) - copy(sigBytes, op.Signature[:]) - sig, err := e2types.BLSSignatureFromBytes(sigBytes) - if err != nil { - return errors.Wrap(err, "invalid signature") - } - - container := &phase0.SigningData{ - ObjectRoot: root, - Domain: c.chainInfo.Domain, - } - signingRoot, err := ssz.HashTreeRoot(container) - if err != nil { - return errors.Wrap(err, "failed to generate signing root") - } - - pubkeyBytes := make([]byte, len(op.Message.FromBLSPubkey)) - copy(pubkeyBytes, op.Message.FromBLSPubkey[:]) - pubkey, err := e2types.BLSPublicKeyFromBytes(pubkeyBytes) - if err != nil { - return errors.Wrap(err, "invalid public key") - } - if !sig.Verify(signingRoot[:], pubkey) { - return errors.New("signature does not verify") - } - - return nil -} - -func (c *command) loadOperationsFromInput(_ context.Context) error { - if strings.HasPrefix(c.signedOperationsInput, "{") { - // This looks like a single entry; turn it in to an array. - c.signedOperationsInput = fmt.Sprintf("[%s]", c.signedOperationsInput) - } - - if !strings.HasPrefix(c.signedOperationsInput, "[") { - // This looks like a file; read it in. - data, err := os.ReadFile(c.signedOperationsInput) + validatorKeyPath := fmt.Sprintf("m/12381/3600/%d/0/0", i) + validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, validatorKeyPath) if err != nil { - return errors.Wrap(err, "failed to read input file") + return errors.Wrap(err, "failed to generate validator private key") } - c.signedOperationsInput = string(data) - } + validatorPubkey := validatorPrivkey.PublicKey().Marshal() + if bytes.Equal(validatorPubkey, validatorInfo.Pubkey[:]) { + withdrawalKeyPath := strings.TrimSuffix(validatorKeyPath, "/0") + withdrawalAccount, err = util.ParseAccount(ctx, c.mnemonic, []string{withdrawalKeyPath}, true) + if err != nil { + return errors.Wrap(err, "failed to create withdrawal account") + } - if err := json.Unmarshal([]byte(c.signedOperationsInput), &c.signedOperations); err != nil { - return errors.Wrap(err, "failed to parse change operations input") + err = c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount) + if err != nil { + return err + } + break + } } return nil } +func (c *command) generateOperationsFromMnemonicAndPrivateKey(ctx context.Context) error { + // Functionally identical to a simple scan, so use that. + return c.generateOperationsFromMnemonic(ctx) +} + func (c *command) generateOperationsFromMnemonic(ctx context.Context) error { seed, err := util.SeedFromMnemonic(c.mnemonic) if err != nil { @@ -420,7 +213,7 @@ func (c *command) generateOperationsFromMnemonic(ctx context.Context) error { } // Turn the validators in to a map for easy lookup. - validators := make(map[string]*validatorInfo, 0) + validators := make(map[string]*beacon.ValidatorInfo, 0) for _, validator := range c.chainInfo.Validators { validators[fmt.Sprintf("%#x", validator.Pubkey)] = validator } @@ -448,70 +241,141 @@ func (c *command) generateOperationsFromMnemonic(ctx context.Context) error { return nil } -func (c *command) generateOperationsFromMnemonicAndPrivateKey(ctx context.Context) error { - // Functionally identical to a simple scan, so use that. - return c.generateOperationsFromMnemonic(ctx) +func (c *command) generateOperationsFromAccountAndWithdrawalAccount(ctx context.Context) error { + validatorAccount, err := util.ParseAccount(ctx, c.account, nil, true) + if err != nil { + return err + } + + withdrawalAccount, err := util.ParseAccount(ctx, c.withdrawalAccount, c.passphrases, true) + if err != nil { + return err + } + + validatorPubkey, err := util.BestPublicKey(validatorAccount) + if err != nil { + return err + } + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, fmt.Sprintf("%#x", validatorPubkey.Marshal())) + if err != nil { + return errors.Wrap(err, "failed to obtain validator info") + } + + if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { + return err + } + + return nil } -func (c *command) generateOperationsFromMnemonicAndValidator(ctx context.Context) error { - seed, err := util.SeedFromMnemonic(c.mnemonic) +func (c *command) generateOperationsFromAccountAndPrivateKey(ctx context.Context) error { + validatorAccount, err := util.ParseAccount(ctx, c.account, nil, true) if err != nil { return err } - validator, err := c.fetchValidatorInfo(ctx) + withdrawalAccount, err := util.ParseAccount(ctx, c.privateKey, nil, true) if err != nil { return err } - // Scan the keys from the seed to find the path. - maxDistance := 1024 - // Start scanning the validator keys. - for i := 0; ; i++ { - if i == maxDistance { - if c.debug { - fmt.Fprintf(os.Stderr, "Gone %d indices without finding the validator, not scanning any further\n", maxDistance) - } - break - } - validatorKeyPath := fmt.Sprintf("m/12381/3600/%d/0/0", i) - validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, validatorKeyPath) - if err != nil { - return errors.Wrap(err, "failed to generate validator private key") - } - validatorPubkey := validatorPrivkey.PublicKey().Marshal() - if bytes.Equal(validatorPubkey, validator.Pubkey[:]) { - // Recreate the withdrawal credentials to ensure a match. - withdrawalKeyPath := strings.TrimSuffix(validatorKeyPath, "/0") - withdrawalPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, withdrawalKeyPath) - if err != nil { - return 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) { - return fmt.Errorf("validator %#x withdrawal credentials %#x do not match expected credentials, cannot update", validatorPubkey, validator.WithdrawalCredentials) - } + validatorPubkey, err := util.BestPublicKey(validatorAccount) + if err != nil { + return err + } + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, fmt.Sprintf("%#x", validatorPubkey.Marshal())) + if err != nil { + return errors.Wrap(err, "failed to obtain validator info") + } - withdrawalAccount, err := util.ParseAccount(ctx, c.mnemonic, []string{withdrawalKeyPath}, true) - if err != nil { - return errors.Wrap(err, "failed to create withdrawal account") - } + if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { + return err + } - err = c.generateOperationFromAccount(ctx, validator, withdrawalAccount) - if err != nil { - return err - } - break + return nil +} + +func (c *command) generateOperationsFromValidatorAndPrivateKey(ctx context.Context) error { + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, c.validator) + if err != nil { + return err + } + + withdrawalAccount, err := util.ParseAccount(ctx, c.privateKey, nil, true) + if err != nil { + return err + } + + if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { + return err + } + + return nil +} + +func (c *command) obtainOperationsFromFileOrInput(ctx context.Context) error { + // Start off by attempting to use the provided signed operations. + if c.signedOperationsInput != "" { + return c.obtainOperationsFromInput(ctx) + } + // If not, read it from the file with the standard name. + return c.obtainOperationsFromFile(ctx) +} + +func (c *command) obtainOperationsFromFile(ctx context.Context) error { + _, err := os.Stat(changeOperationsFilename) + if err != nil { + return errors.Wrap(err, "failed to read change operations file") + } + if c.debug { + fmt.Fprintf(os.Stderr, "%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") + } + + for _, op := range c.signedOperations { + if err := c.verifyOperation(ctx, op); err != nil { + return err } } return nil } +func (c *command) obtainOperationsFromInput(ctx context.Context) error { + if strings.HasPrefix(c.signedOperationsInput, "{") { + // This looks like a single entry; turn it in to an array. + c.signedOperationsInput = fmt.Sprintf("[%s]", c.signedOperationsInput) + } + + if !strings.HasPrefix(c.signedOperationsInput, "[") { + // This looks like a file; read it in. + data, err := os.ReadFile(c.signedOperationsInput) + if err != nil { + return errors.Wrap(err, "failed to read input file") + } + c.signedOperationsInput = string(data) + } + + if err := json.Unmarshal([]byte(c.signedOperationsInput), &c.signedOperations); err != nil { + return errors.Wrap(err, "failed to parse change operations input") + } + + for _, op := range c.signedOperations { + if err := c.verifyOperation(ctx, op); err != nil { + return err + } + } + return nil +} + func (c *command) generateOperationFromSeedAndPath(ctx context.Context, - validators map[string]*validatorInfo, + validators map[string]*beacon.ValidatorInfo, seed []byte, path string, ) ( @@ -587,7 +451,7 @@ func (c *command) generateOperationFromSeedAndPath(ctx context.Context, } func (c *command) generateOperationFromAccount(ctx context.Context, - validator *validatorInfo, + validator *beacon.ValidatorInfo, withdrawalAccount e2wtypes.Account, ) error { signedOperation, err := c.createSignedOperation(ctx, validator, withdrawalAccount) @@ -599,7 +463,7 @@ func (c *command) generateOperationFromAccount(ctx context.Context, } func (c *command) createSignedOperation(ctx context.Context, - validator *validatorInfo, + validator *beacon.ValidatorInfo, withdrawalAccount e2wtypes.Account, ) ( *capella.SignedBLSToExecutionChange, @@ -630,7 +494,10 @@ func (c *command) createSignedOperation(ctx context.Context, } // Sign the operation. - signature, err := signing.SignRoot(ctx, withdrawalAccount, nil, root, c.chainInfo.Domain) + if c.debug { + fmt.Fprintf(os.Stderr, "Signing %#x with domain %#x by public key %#x\n", root, c.domain, withdrawalAccount.PublicKey().Marshal()) + } + signature, err := signing.SignRoot(ctx, withdrawalAccount, nil, root, c.domain) if err != nil { return nil, errors.Wrap(err, "failed to sign credentials change operation") } @@ -661,7 +528,7 @@ func (c *command) parseWithdrawalAddress(_ context.Context) error { 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) + validators := make(map[phase0.ValidatorIndex]*beacon.ValidatorInfo, 0) for _, validator := range c.chainInfo.Validators { validators[validator.Index] = validator } @@ -674,8 +541,43 @@ func (c *command) validateOperations(ctx context.Context) (bool, string) { return true, "" } +func (c *command) verifyOperation(ctx context.Context, op *capella.SignedBLSToExecutionChange) error { + root, err := op.Message.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "failed to generate message root") + } + + sigBytes := make([]byte, len(op.Signature)) + copy(sigBytes, op.Signature[:]) + sig, err := e2types.BLSSignatureFromBytes(sigBytes) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + + container := &phase0.SigningData{ + ObjectRoot: root, + Domain: c.domain, + } + signingRoot, err := ssz.HashTreeRoot(container) + if err != nil { + return errors.Wrap(err, "failed to generate signing root") + } + + pubkeyBytes := make([]byte, len(op.Message.FromBLSPubkey)) + copy(pubkeyBytes, op.Message.FromBLSPubkey[:]) + pubkey, err := e2types.BLSPublicKeyFromBytes(pubkeyBytes) + if err != nil { + return errors.Wrap(err, "invalid public key") + } + if !sig.Verify(signingRoot[:], pubkey) { + return errors.New("signature does not verify") + } + + return nil +} + func (c *command) validateOperation(_ context.Context, - validators map[phase0.ValidatorIndex]*validatorInfo, + validators map[phase0.ValidatorIndex]*beacon.ValidatorInfo, signedOperation *capella.SignedBLSToExecutionChange, ) ( bool, @@ -725,7 +627,6 @@ func (c *command) setup(ctx context.Context) error { // Set up chaintime. c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithGenesisTimeProvider(c.consensusClient.(consensusclient.GenesisTimeProvider)), - standardchaintime.WithForkScheduleProvider(c.consensusClient.(consensusclient.ForkScheduleProvider)), standardchaintime.WithSpecProvider(c.consensusClient.(consensusclient.SpecProvider)), ) if err != nil { @@ -735,56 +636,88 @@ func (c *command) setup(ctx context.Context) error { return nil } -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 - } - } +func (c *command) generateDomain(ctx context.Context) error { + genesisValidatorsRoot, err := c.obtainGenesisValidatorsRoot(ctx) + if err != nil { + return err + } + forkVersion, err := c.obtainForkVersion(ctx) + if err != nil { + return err } - if validatorInfo == nil { - return nil, errors.New("unknown validator") + root, err := (&phase0.ForkData{ + CurrentVersion: forkVersion, + GenesisValidatorsRoot: genesisValidatorsRoot, + }).HashTreeRoot() + if err != nil { + return errors.Wrap(err, "failed to calculate signature domain") } - return validatorInfo, nil + copy(c.domain[:], c.chainInfo.BLSToExecutionChangeDomainType[:]) + copy(c.domain[4:], root[:]) + if c.debug { + fmt.Fprintf(os.Stderr, "Domain is %#x\n", c.domain) + } + + return nil +} + +func (c *command) obtainGenesisValidatorsRoot(ctx context.Context) (phase0.Root, error) { + genesisValidatorsRoot := phase0.Root{} + + if c.genesisValidatorsRoot != "" { + if c.debug { + fmt.Fprintf(os.Stderr, "Genesis validators root supplied on the command line\n") + } + root, err := hex.DecodeString(strings.TrimPrefix(c.genesisValidatorsRoot, "0x")) + if err != nil { + return phase0.Root{}, errors.Wrap(err, "invalid genesis validators root supplied") + } + if len(root) != phase0.RootLength { + return phase0.Root{}, errors.New("invalid length for genesis validators root") + } + copy(genesisValidatorsRoot[:], root) + } else { + if c.debug { + fmt.Fprintf(os.Stderr, "Genesis validators root obtained from chain info\n") + } + copy(genesisValidatorsRoot[:], c.chainInfo.GenesisValidatorsRoot[:]) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "Using genesis validators root %#x\n", genesisValidatorsRoot) + } + return genesisValidatorsRoot, nil +} + +func (c *command) obtainForkVersion(ctx context.Context) (phase0.Version, error) { + forkVersion := phase0.Version{} + + if c.forkVersion != "" { + if c.debug { + fmt.Fprintf(os.Stderr, "Fork version supplied on the command line\n") + } + version, err := hex.DecodeString(strings.TrimPrefix(c.forkVersion, "0x")) + if err != nil { + return phase0.Version{}, errors.Wrap(err, "invalid fork version supplied") + } + if len(version) != phase0.ForkVersionLength { + return phase0.Version{}, errors.New("invalid length for fork version") + } + copy(forkVersion[:], version) + } else { + if c.debug { + fmt.Fprintf(os.Stderr, "Fork version obtained from chain info\n") + } + // Use the genesis fork version for setting credentials as per the spec. + copy(forkVersion[:], c.chainInfo.GenesisForkVersion[:]) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "Using fork version %#x\n", forkVersion) + } + return forkVersion, nil } // addressBytesToEIP55 converts a byte array in to an EIP-55 string format. @@ -805,126 +738,3 @@ 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 -} - -func (c *command) generateOperationsFromAccountAndWithdrawalAccount(ctx context.Context) error { - validatorAccount, err := util.ParseAccount(ctx, c.account, nil, true) - if err != nil { - return err - } - - withdrawalAccount, err := util.ParseAccount(ctx, c.withdrawalAccount, c.passphrases, true) - if err != nil { - return err - } - - // Find the validator info given its account information. - validatorPubkey := validatorAccount.PublicKey().Marshal() - var validatorInfo *validatorInfo - for _, validator := range c.chainInfo.Validators { - if bytes.Equal(validator.Pubkey[:], validatorPubkey) { - // Found it. - validatorInfo = validator - } - } - if validatorInfo == nil { - return errors.New("could not find information for that validator on the chain") - } - - if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { - return err - } - - return nil -} - -func (c *command) generateOperationsFromAccountAndPrivateKey(ctx context.Context) error { - validatorAccount, err := util.ParseAccount(ctx, c.account, nil, true) - if err != nil { - return err - } - - withdrawalAccount, err := util.ParseAccount(ctx, c.privateKey, nil, true) - if err != nil { - return err - } - - // Find the validator info given its account information. - validatorPubkey := validatorAccount.PublicKey().Marshal() - var validatorInfo *validatorInfo - for _, validator := range c.chainInfo.Validators { - if bytes.Equal(validator.Pubkey[:], validatorPubkey) { - // Found it. - validatorInfo = validator - } - } - if validatorInfo == nil { - return errors.New("could not find information for that validator on the chain") - } - - if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { - return err - } - - return nil -} - -func (c *command) generateOperationsFromValidatorAndPrivateKey(ctx context.Context) error { - validator, err := c.fetchValidatorInfo(ctx) - if err != nil { - return err - } - - validatorAccount, err := util.ParseAccount(ctx, validator.Pubkey.String(), nil, false) - if err != nil { - return err - } - - withdrawalAccount, err := util.ParseAccount(ctx, c.privateKey, nil, true) - if err != nil { - return err - } - - // Find the validator info given its account information. - validatorPubkey := validatorAccount.PublicKey().Marshal() - var validatorInfo *validatorInfo - for _, validator := range c.chainInfo.Validators { - if bytes.Equal(validator.Pubkey[:], validatorPubkey) { - // Found it. - validatorInfo = validator - } - } - if validatorInfo == nil { - return errors.New("could not find information for that validator on the chain") - } - - if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil { - return err - } - - return nil -} diff --git a/cmd/validator/credentials/set/process_internal_test.go b/cmd/validator/credentials/set/process_internal_test.go new file mode 100644 index 0000000..74fa39b --- /dev/null +++ b/cmd/validator/credentials/set/process_internal_test.go @@ -0,0 +1,372 @@ +// 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 ( + "context" + "fmt" + "testing" + + "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/stretchr/testify/require" + "github.com/wealdtech/ethdo/beacon" + e2types "github.com/wealdtech/go-eth2-types/v2" +) + +func TestGenerateOperationFromMnemonicAndPath(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + + tests := []struct { + name string + command *command + expected []*capella.SignedBLSToExecutionChange + err string + }{ + { + name: "MnemonicInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + path: "m/12381/3600/0/0/0", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + err: "mnemonic is invalid", + }, + { + name: "PathInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + err: "path m/12381/3600/0/0 does not match EIP-2334 format for a validator", + }, + { + name: "Good", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0/0", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + expected: []*capella.SignedBLSToExecutionChange{ + { + Message: &capella.BLSToExecutionChange{ + ValidatorIndex: 0, + FromBLSPubkey: phase0.BLSPubKey{0x99, 0xb1, 0xf1, 0xd8, 0x4d, 0x76, 0x18, 0x54, 0x66, 0xd8, 0x6c, 0x34, 0xbd, 0xe1, 0x10, 0x13, 0x16, 0xaf, 0xdd, 0xae, 0x76, 0x21, 0x7a, 0xa8, 0x6c, 0xd0, 0x66, 0x97, 0x9b, 0x19, 0x85, 0x8c, 0x2c, 0x9d, 0x9e, 0x56, 0xee, 0xbc, 0x1e, 0x06, 0x7a, 0xc5, 0x42, 0x77, 0xa6, 0x17, 0x90, 0xdb}, + ToExecutionAddress: bellatrix.ExecutionAddress{0x8c, 0x1f, 0xf9, 0x78, 0x03, 0x6f, 0x2e, 0x9d, 0x7c, 0xc3, 0x82, 0xef, 0xf7, 0xb4, 0xc8, 0xc5, 0x3c, 0x22, 0xac, 0x15}, + }, + Signature: phase0.BLSSignature{0xb7, 0x8a, 0x05, 0xba, 0xd9, 0x27, 0xfc, 0x89, 0x6f, 0x14, 0x06, 0xb3, 0x2d, 0x64, 0x4a, 0xe1, 0x69, 0xce, 0xcd, 0x89, 0x86, 0xc1, 0xef, 0x8c, 0x0d, 0x03, 0x7d, 0x70, 0x86, 0xf8, 0x5f, 0x13, 0xe1, 0xe1, 0x88, 0xb4, 0x30, 0x96, 0x43, 0xa2, 0xc1, 0x3f, 0xfe, 0xfb, 0x0a, 0xe8, 0x05, 0x11, 0x09, 0x98, 0x53, 0xa0, 0x58, 0x1f, 0x4b, 0x2b, 0xd2, 0xe1, 0x45, 0x41, 0x04, 0x79, 0x01, 0xe2, 0x2a, 0x94, 0x0a, 0x9c, 0x7e, 0x3a, 0xc0, 0xa8, 0x82, 0xd1, 0xa8, 0xaf, 0x6b, 0xfa, 0xea, 0x81, 0x3a, 0x6a, 0x6b, 0xe7, 0x21, 0xf9, 0x26, 0x22, 0x04, 0xaa, 0x9d, 0xa4, 0xe4, 0x77, 0x27, 0xd0}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.generateOperationFromMnemonicAndPath(ctx) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + // fmt.Printf("%v\n", test.command.signedOperations) + require.Equal(t, test.expected, test.command.signedOperations) + } + }) + } +} + +func TestGenerateOperationFromMnemonicAndValidator(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + + tests := []struct { + name string + command *command + expected []*capella.SignedBLSToExecutionChange + err string + }{ + { + name: "MnemonicInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + validator: "0", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + err: "mnemonic is invalid", + }, + { + name: "ValidatorMissing", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + err: "no validator specified", + }, + { + name: "Good", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + validator: "0", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + expected: []*capella.SignedBLSToExecutionChange{ + { + Message: &capella.BLSToExecutionChange{ + ValidatorIndex: 0, + FromBLSPubkey: phase0.BLSPubKey{0x99, 0xb1, 0xf1, 0xd8, 0x4d, 0x76, 0x18, 0x54, 0x66, 0xd8, 0x6c, 0x34, 0xbd, 0xe1, 0x10, 0x13, 0x16, 0xaf, 0xdd, 0xae, 0x76, 0x21, 0x7a, 0xa8, 0x6c, 0xd0, 0x66, 0x97, 0x9b, 0x19, 0x85, 0x8c, 0x2c, 0x9d, 0x9e, 0x56, 0xee, 0xbc, 0x1e, 0x06, 0x7a, 0xc5, 0x42, 0x77, 0xa6, 0x17, 0x90, 0xdb}, + ToExecutionAddress: bellatrix.ExecutionAddress{0x8c, 0x1f, 0xf9, 0x78, 0x03, 0x6f, 0x2e, 0x9d, 0x7c, 0xc3, 0x82, 0xef, 0xf7, 0xb4, 0xc8, 0xc5, 0x3c, 0x22, 0xac, 0x15}, + }, + Signature: phase0.BLSSignature{0xb7, 0x8a, 0x05, 0xba, 0xd9, 0x27, 0xfc, 0x89, 0x6f, 0x14, 0x06, 0xb3, 0x2d, 0x64, 0x4a, 0xe1, 0x69, 0xce, 0xcd, 0x89, 0x86, 0xc1, 0xef, 0x8c, 0x0d, 0x03, 0x7d, 0x70, 0x86, 0xf8, 0x5f, 0x13, 0xe1, 0xe1, 0x88, 0xb4, 0x30, 0x96, 0x43, 0xa2, 0xc1, 0x3f, 0xfe, 0xfb, 0x0a, 0xe8, 0x05, 0x11, 0x09, 0x98, 0x53, 0xa0, 0x58, 0x1f, 0x4b, 0x2b, 0xd2, 0xe1, 0x45, 0x41, 0x04, 0x79, 0x01, 0xe2, 0x2a, 0x94, 0x0a, 0x9c, 0x7e, 0x3a, 0xc0, 0xa8, 0x82, 0xd1, 0xa8, 0xaf, 0x6b, 0xfa, 0xea, 0x81, 0x3a, 0x6a, 0x6b, 0xe7, 0x21, 0xf9, 0x26, 0x22, 0x04, 0xaa, 0x9d, 0xa4, 0xe4, 0x77, 0x27, 0xd0}, + }, + }, + }, + { + name: "GoodPubkey", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + validator: "0xb384f767d964e100c8a9b21018d08c25ffebae268b3ab6d610353897541971726dbfc3c7463884c68a531515aab94c87", + chainInfo: chainInfo, + signedOperations: make([]*capella.SignedBLSToExecutionChange, 0), + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + expected: []*capella.SignedBLSToExecutionChange{ + { + Message: &capella.BLSToExecutionChange{ + ValidatorIndex: 0, + FromBLSPubkey: phase0.BLSPubKey{0x99, 0xb1, 0xf1, 0xd8, 0x4d, 0x76, 0x18, 0x54, 0x66, 0xd8, 0x6c, 0x34, 0xbd, 0xe1, 0x10, 0x13, 0x16, 0xaf, 0xdd, 0xae, 0x76, 0x21, 0x7a, 0xa8, 0x6c, 0xd0, 0x66, 0x97, 0x9b, 0x19, 0x85, 0x8c, 0x2c, 0x9d, 0x9e, 0x56, 0xee, 0xbc, 0x1e, 0x06, 0x7a, 0xc5, 0x42, 0x77, 0xa6, 0x17, 0x90, 0xdb}, + ToExecutionAddress: bellatrix.ExecutionAddress{0x8c, 0x1f, 0xf9, 0x78, 0x03, 0x6f, 0x2e, 0x9d, 0x7c, 0xc3, 0x82, 0xef, 0xf7, 0xb4, 0xc8, 0xc5, 0x3c, 0x22, 0xac, 0x15}, + }, + Signature: phase0.BLSSignature{0xb7, 0x8a, 0x05, 0xba, 0xd9, 0x27, 0xfc, 0x89, 0x6f, 0x14, 0x06, 0xb3, 0x2d, 0x64, 0x4a, 0xe1, 0x69, 0xce, 0xcd, 0x89, 0x86, 0xc1, 0xef, 0x8c, 0x0d, 0x03, 0x7d, 0x70, 0x86, 0xf8, 0x5f, 0x13, 0xe1, 0xe1, 0x88, 0xb4, 0x30, 0x96, 0x43, 0xa2, 0xc1, 0x3f, 0xfe, 0xfb, 0x0a, 0xe8, 0x05, 0x11, 0x09, 0x98, 0x53, 0xa0, 0x58, 0x1f, 0x4b, 0x2b, 0xd2, 0xe1, 0x45, 0x41, 0x04, 0x79, 0x01, 0xe2, 0x2a, 0x94, 0x0a, 0x9c, 0x7e, 0x3a, 0xc0, 0xa8, 0x82, 0xd1, 0xa8, 0xaf, 0x6b, 0xfa, 0xea, 0x81, 0x3a, 0x6a, 0x6b, 0xe7, 0x21, 0xf9, 0x26, 0x22, 0x04, 0xaa, 0x9d, 0xa4, 0xe4, 0x77, 0x27, 0xd0}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.generateOperationFromMnemonicAndValidator(ctx) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, test.command.signedOperations) + } + }) + } +} + +func TestGenerateOperationFromSeedAndPath(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + { + Index: 2, + Pubkey: phase0.BLSPubKey{0xaf, 0x9c, 0xe4, 0x4f, 0x50, 0x14, 0x8d, 0xb4, 0x12, 0x19, 0x4a, 0xf0, 0xba, 0xf0, 0xba, 0xb3, 0x6b, 0xd5, 0xc3, 0xe0, 0xc4, 0x93, 0x89, 0x11, 0xa4, 0xe5, 0x02, 0xe3, 0x98, 0xb5, 0x9e, 0x5c, 0xca, 0x7c, 0x78, 0xe3, 0xfe, 0x03, 0x41, 0x95, 0x47, 0x88, 0x79, 0xee, 0xb2, 0x3d, 0xb0, 0xa6}, + WithdrawalCredentials: []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + { + Index: 3, + Pubkey: phase0.BLSPubKey{0x86, 0xd3, 0x30, 0xaf, 0x51, 0xfa, 0x59, 0x3f, 0xa9, 0xf9, 0x3e, 0xdb, 0x9d, 0x16, 0x64, 0x01, 0x86, 0xbe, 0x2e, 0x93, 0xea, 0x94, 0xd2, 0x59, 0x78, 0x1e, 0x1e, 0xb3, 0x4d, 0xeb, 0x84, 0x4c, 0x39, 0x68, 0xd7, 0x5e, 0xa9, 0x1d, 0x19, 0xf1, 0x59, 0xdb, 0xd0, 0x52, 0x3c, 0x6c, 0x5b, 0xa5}, + WithdrawalCredentials: []byte{0x00, 0x81, 0x68, 0x45, 0x6b, 0x6d, 0x9a, 0x32, 0x83, 0x93, 0x1f, 0xea, 0x52, 0x10, 0xda, 0x12, 0x2d, 0x1e, 0x65, 0xe8, 0xed, 0x50, 0xb8, 0xe8, 0xf5, 0x91, 0x11, 0x83, 0xb0, 0x2f, 0xd1, 0x25}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + validators := make(map[string]*beacon.ValidatorInfo, len(chainInfo.Validators)) + for i := range chainInfo.Validators { + validators[fmt.Sprintf("%#x", chainInfo.Validators[i].Pubkey)] = chainInfo.Validators[i] + } + + tests := []struct { + name string + command *command + seed []byte + path string + generated bool + err string + expected []*capella.SignedBLSToExecutionChange + }{ + { + name: "PathInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "invalid", + err: "failed to generate validator private key: not master at path component 0", + }, + { + name: "ValidatorUnknown", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/999/0/0", + }, + { + name: "ValidatorCredentialsAlreadySet", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/2/0/0", + }, + { + name: "PrivateKeyInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + privateKey: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/0/0/0", + err: "failed to create account from private key: invalid private key: err blsSecretKeyDeserialize ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + { + name: "Good", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/0/0/0", + generated: true, + expected: []*capella.SignedBLSToExecutionChange{ + { + Message: &capella.BLSToExecutionChange{ + ValidatorIndex: 0, + FromBLSPubkey: phase0.BLSPubKey{0x99, 0xb1, 0xf1, 0xd8, 0x4d, 0x76, 0x18, 0x54, 0x66, 0xd8, 0x6c, 0x34, 0xbd, 0xe1, 0x10, 0x13, 0x16, 0xaf, 0xdd, 0xae, 0x76, 0x21, 0x7a, 0xa8, 0x6c, 0xd0, 0x66, 0x97, 0x9b, 0x19, 0x85, 0x8c, 0x2c, 0x9d, 0x9e, 0x56, 0xee, 0xbc, 0x1e, 0x06, 0x7a, 0xc5, 0x42, 0x77, 0xa6, 0x17, 0x90, 0xdb}, + ToExecutionAddress: bellatrix.ExecutionAddress{0x8c, 0x1f, 0xf9, 0x78, 0x03, 0x6f, 0x2e, 0x9d, 0x7c, 0xc3, 0x82, 0xef, 0xf7, 0xb4, 0xc8, 0xc5, 0x3c, 0x22, 0xac, 0x15}, + }, + Signature: phase0.BLSSignature{0xb7, 0x8a, 0x05, 0xba, 0xd9, 0x27, 0xfc, 0x89, 0x6f, 0x14, 0x06, 0xb3, 0x2d, 0x64, 0x4a, 0xe1, 0x69, 0xce, 0xcd, 0x89, 0x86, 0xc1, 0xef, 0x8c, 0x0d, 0x03, 0x7d, 0x70, 0x86, 0xf8, 0x5f, 0x13, 0xe1, 0xe1, 0x88, 0xb4, 0x30, 0x96, 0x43, 0xa2, 0xc1, 0x3f, 0xfe, 0xfb, 0x0a, 0xe8, 0x05, 0x11, 0x09, 0x98, 0x53, 0xa0, 0x58, 0x1f, 0x4b, 0x2b, 0xd2, 0xe1, 0x45, 0x41, 0x04, 0x79, 0x01, 0xe2, 0x2a, 0x94, 0x0a, 0x9c, 0x7e, 0x3a, 0xc0, 0xa8, 0x82, 0xd1, 0xa8, 0xaf, 0x6b, 0xfa, 0xea, 0x81, 0x3a, 0x6a, 0x6b, 0xe7, 0x21, 0xf9, 0x26, 0x22, 0x04, 0xaa, 0x9d, 0xa4, 0xe4, 0x77, 0x27, 0xd0}, + }, + }, + }, + { + name: "GoodPrivateKey", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + withdrawalAddressStr: "0x8c1Ff978036F2e9d7CC382Eff7B4c8c53C22ac15", + privateKey: "0x67775f030068b4610d6e1bd04948f547305b2502423fcece4c1091d065b44638", + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/3/0/0", + generated: true, + expected: []*capella.SignedBLSToExecutionChange{ + { + Message: &capella.BLSToExecutionChange{ + ValidatorIndex: 3, + FromBLSPubkey: phase0.BLSPubKey{0x86, 0x71, 0x0a, 0xbb, 0x44, 0xb6, 0xcd, 0xa6, 0x66, 0x57, 0x7b, 0xbb, 0x25, 0x5e, 0x16, 0xd9, 0x8b, 0xf2, 0x52, 0x51, 0x76, 0x22, 0x3f, 0x35, 0x35, 0xc7, 0xdf, 0xf8, 0xe7, 0x0b, 0x3b, 0xc8, 0x92, 0xbb, 0x36, 0x11, 0x33, 0x95, 0x2b, 0x03, 0xd2, 0xb0, 0x78, 0xcd, 0x07, 0x18, 0xca, 0xf3}, + ToExecutionAddress: bellatrix.ExecutionAddress{0x8c, 0x1f, 0xf9, 0x78, 0x03, 0x6f, 0x2e, 0x9d, 0x7c, 0xc3, 0x82, 0xef, 0xf7, 0xb4, 0xc8, 0xc5, 0x3c, 0x22, 0xac, 0x15}, + }, + Signature: phase0.BLSSignature{0x8d, 0x92, 0xb9, 0x1c, 0x5d, 0xfd, 0x98, 0xc7, 0x98, 0xfc, 0x94, 0xe1, 0xe6, 0x69, 0xf3, 0xaa, 0xae, 0x72, 0xb2, 0x36, 0x47, 0xde, 0x88, 0x54, 0xea, 0x16, 0x74, 0x7f, 0xfe, 0xf0, 0x4d, 0x46, 0x5c, 0x07, 0x56, 0x34, 0x03, 0x30, 0x2f, 0xbc, 0x26, 0xa2, 0x6d, 0xec, 0x10, 0x20, 0xe7, 0x67, 0x10, 0xb0, 0x4a, 0x7e, 0x4e, 0x25, 0x89, 0x7e, 0x87, 0x88, 0xda, 0xaf, 0x2b, 0xb5, 0xb7, 0x73, 0x25, 0x64, 0x80, 0xc1, 0xba, 0xf3, 0x1d, 0x33, 0x8f, 0x17, 0xa5, 0x35, 0x74, 0x80, 0xf3, 0x37, 0x0e, 0xea, 0x19, 0x15, 0xd5, 0x69, 0x7e, 0xf6, 0x68, 0xaa, 0x9c, 0x3d, 0x47, 0x19, 0x75, 0xfc}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generated, err := test.command.generateOperationFromSeedAndPath(ctx, validators, test.seed, test.path) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.generated, generated) + if generated { + require.Equal(t, test.expected, test.command.signedOperations) + } + } + }) + } +} diff --git a/cmd/validator/exit/chaininfo.go b/cmd/validator/exit/chaininfo.go new file mode 100644 index 0000000..e632c20 --- /dev/null +++ b/cmd/validator/exit/chaininfo.go @@ -0,0 +1,105 @@ +// Copyright © 2023 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 validatorexit + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/wealdtech/ethdo/beacon" +) + +// obtainChainInfo obtains the chain information required to create an exit operation. +func (c *command) obtainChainInfo(ctx context.Context) error { + // Use the offline preparation file if present (and we haven't been asked to recreate it). + if !c.prepareOffline { + err := c.obtainChainInfoFromFile(ctx) + if err == nil { + return nil + } + } + + if c.offline { + return fmt.Errorf("%s is unavailable or outdated; this is required to have been previously generated using --offline-preparation on an online machine and be readable in the directory in which this command is being run", offlinePreparationFilename) + } + + if err := c.obtainChainInfoFromNode(ctx); err != nil { + return err + } + + return nil +} + +// obtainChainInfoFromFile obtains chain information from a pre-generated file. +func (c *command) obtainChainInfoFromFile(_ context.Context) error { + _, err := os.Stat(offlinePreparationFilename) + if err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "Failed to read offline preparation file: %v\n", err) + } + return errors.Wrap(err, fmt.Sprintf("cannot find %s", offlinePreparationFilename)) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "%s found; loading chain state\n", offlinePreparationFilename) + } + data, err := os.ReadFile(offlinePreparationFilename) + if err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "failed to load chain state: %v\n", err) + } + return errors.Wrap(err, "failed to read offline preparation file") + } + c.chainInfo = &beacon.ChainInfo{} + if err := json.Unmarshal(data, c.chainInfo); err != nil { + if c.debug { + fmt.Fprintf(os.Stderr, "chain state invalid: %v\n", err) + } + return errors.Wrap(err, "failed to parse offline preparation file") + } + + return nil +} + +// obtainChainInfoFromNode obtains chain info from a beacon node. +func (c *command) obtainChainInfoFromNode(ctx context.Context) error { + if c.debug { + fmt.Fprintf(os.Stderr, "Populating chain info from beacon node\n") + } + + var err error + c.chainInfo, err = beacon.ObtainChainInfoFromNode(ctx, c.consensusClient, c.chainTime) + if err != nil { + return err + } + + return nil +} + +// writeChainInfoToFile prepares for an offline run of this command by dumping +// the chain information to a file. +func (c *command) writeChainInfoToFile(_ 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 +} diff --git a/cmd/validator/exit/command.go b/cmd/validator/exit/command.go new file mode 100644 index 0000000..dfb2deb --- /dev/null +++ b/cmd/validator/exit/command.go @@ -0,0 +1,97 @@ +// Copyright © 2023 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 validatorexit + +import ( + "context" + "time" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + "github.com/spf13/viper" + "github.com/wealdtech/ethdo/beacon" + "github.com/wealdtech/ethdo/services/chaintime" + "github.com/wealdtech/ethdo/util" +) + +type command struct { + quiet bool + verbose bool + debug bool + offline bool + json bool + + // Input. + passphrases []string + mnemonic string + path string + privateKey string + validator string + forkVersion string + genesisValidatorsRoot string + prepareOffline bool + signedOperationInput string + + // Beacon node connection. + timeout time.Duration + connection string + allowInsecureConnections bool + + // Information required to generate the operations. + chainInfo *beacon.ChainInfo + domain phase0.Domain + + // Processing. + consensusClient consensusclient.Service + chainTime chaintime.Service + + // Output. + signedOperation *phase0.SignedVoluntaryExit +} + +func newCommand(_ 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"), + timeout: viper.GetDuration("timeout"), + connection: viper.GetString("connection"), + allowInsecureConnections: viper.GetBool("allow-insecure-connections"), + prepareOffline: viper.GetBool("prepare-offline"), + passphrases: util.GetPassphrases(), + mnemonic: viper.GetString("mnemonic"), + path: viper.GetString("path"), + privateKey: viper.GetString("private-key"), + signedOperationInput: viper.GetString("signed-operation"), + validator: viper.GetString("validator"), + forkVersion: viper.GetString("fork-version"), + genesisValidatorsRoot: viper.GetString("genesis-validators-root"), + } + + // Timeout is required. + if c.timeout == 0 { + return nil, errors.New("timeout is 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 + } + + return c, nil +} diff --git a/cmd/validator/exit/input.go b/cmd/validator/exit/input.go deleted file mode 100644 index afd507f..0000000 --- a/cmd/validator/exit/input.go +++ /dev/null @@ -1,149 +0,0 @@ -// 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 validatorexit - -import ( - "context" - "encoding/hex" - "encoding/json" - "strings" - "time" - - eth2client "github.com/attestantio/go-eth2-client" - spec "github.com/attestantio/go-eth2-client/spec/phase0" - "github.com/pkg/errors" - "github.com/spf13/viper" - "github.com/wealdtech/ethdo/util" - e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" -) - -type dataIn struct { - // System. - timeout time.Duration - quiet bool - verbose bool - debug bool - // Operation. - eth2Client eth2client.Service - jsonOutput bool - // Chain information. - fork *spec.Fork - currentEpoch spec.Epoch - // Exit information. - account e2wtypes.Account - passphrases []string - epoch spec.Epoch - domain spec.Domain - signedVoluntaryExit *spec.SignedVoluntaryExit -} - -func input(ctx context.Context) (*dataIn, error) { - data := &dataIn{} - - if viper.GetDuration("timeout") == 0 { - return nil, errors.New("timeout is required") - } - data.timeout = viper.GetDuration("timeout") - data.quiet = viper.GetBool("quiet") - data.verbose = viper.GetBool("verbose") - data.debug = viper.GetBool("debug") - data.passphrases = util.GetPassphrases() - data.jsonOutput = viper.GetBool("json") - - switch { - case viper.GetString("exit") != "": - return inputJSON(ctx, data) - case viper.GetString("account") != "": - return inputAccount(ctx, data) - case viper.GetString("key") != "": - return inputKey(ctx, data) - default: - return nil, errors.New("must supply account, key, or pre-constructed JSON") - } -} - -func inputJSON(ctx context.Context, data *dataIn) (*dataIn, error) { - validatorData := &util.ValidatorExitData{} - err := json.Unmarshal([]byte(viper.GetString("exit")), validatorData) - if err != nil { - return nil, err - } - data.signedVoluntaryExit = validatorData.Exit - return inputChainData(ctx, data) -} - -func inputAccount(ctx context.Context, data *dataIn) (*dataIn, error) { - var err error - _, data.account, err = util.WalletAndAccountFromInput(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to obtain acount") - } - return inputChainData(ctx, data) -} - -func inputKey(ctx context.Context, data *dataIn) (*dataIn, error) { - privKeyBytes, err := hex.DecodeString(strings.TrimPrefix(viper.GetString("key"), "0x")) - if err != nil { - return nil, errors.Wrap(err, "failed to decode key") - } - data.account, err = util.NewScratchAccount(privKeyBytes, nil) - if err != nil { - return nil, errors.Wrap(err, "failed to create acount from key") - } - if err := data.account.(e2wtypes.AccountLocker).Unlock(ctx, nil); err != nil { - return nil, errors.Wrap(err, "failed to unlock account") - } - return inputChainData(ctx, data) -} - -func inputChainData(ctx context.Context, data *dataIn) (*dataIn, error) { - var err error - data.eth2Client, err = util.ConnectToBeaconNode(ctx, viper.GetString("connection"), viper.GetDuration("timeout"), viper.GetBool("allow-insecure-connections")) - if err != nil { - return nil, errors.Wrap(err, "failed to connect to Ethereum 2 beacon node") - } - - // Current fork. - data.fork, err = data.eth2Client.(eth2client.ForkProvider).Fork(ctx, "head") - if err != nil { - return nil, errors.Wrap(err, "failed to connect to obtain fork information") - } - - // Calculate current epoch. - config, err := data.eth2Client.(eth2client.SpecProvider).Spec(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to connect to obtain configuration information") - } - genesis, err := data.eth2Client.(eth2client.GenesisProvider).Genesis(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to connect to obtain genesis information") - } - data.currentEpoch = spec.Epoch(uint64(time.Since(genesis.GenesisTime).Seconds()) / (uint64(config["SECONDS_PER_SLOT"].(time.Duration).Seconds()) * config["SLOTS_PER_EPOCH"].(uint64))) - - // Epoch. - if viper.GetInt64("epoch") == -1 { - data.epoch = data.currentEpoch - } else { - data.epoch = spec.Epoch(viper.GetUint64("epoch")) - } - - // Domain. - domain, err := data.eth2Client.(eth2client.DomainProvider).Domain(ctx, config["DOMAIN_VOLUNTARY_EXIT"].(spec.DomainType), data.epoch) - if err != nil { - return nil, errors.New("failed to calculate domain") - } - data.domain = domain - - return data, nil -} diff --git a/cmd/validator/exit/input_internal_test.go b/cmd/validator/exit/input_internal_test.go deleted file mode 100644 index aebaa42..0000000 --- a/cmd/validator/exit/input_internal_test.go +++ /dev/null @@ -1,193 +0,0 @@ -// 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 validatorexit - -import ( - "context" - "os" - "testing" - "time" - - "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{}{ - "account": "Test wallet", - "wallet-passphrase": "ce%NohGhah4ye5ra", - "type": "nd", - }, - err: "timeout is required", - }, - { - name: "NoMethod", - vars: map[string]interface{}{ - "timeout": "5s", - }, - err: "must supply account, key, or pre-constructed JSON", - }, - { - name: "KeyInvalid", - vars: map[string]interface{}{ - "timeout": "5s", - "key": "0xinvalid", - }, - err: "failed to decode key: encoding/hex: invalid byte: U+0069 'i'", - }, - { - name: "KeyBad", - vars: map[string]interface{}{ - "timeout": "5s", - "key": "0x00", - }, - err: "failed to create acount from key: private key must be 32 bytes", - }, - { - name: "KeyGood", - vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "allow-insecure-connections": true, - "timeout": "5s", - "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", - }, - res: &dataIn{ - timeout: 5 * time.Second, - }, - }, - { - name: "AccountUnknown", - vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "allow-insecure-connections": true, - "timeout": "5s", - "account": "Test wallet/unknown", - }, - res: &dataIn{ - timeout: 5 * time.Second, - }, - err: "failed to obtain acount: failed to obtain account: no account with name \"unknown\"", - }, - { - name: "AccountGood", - vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "allow-insecure-connections": true, - "timeout": "5s", - "account": "Test wallet/Interop 0", - }, - res: &dataIn{ - timeout: 5 * time.Second, - }, - }, - { - name: "JSONInvalid", - vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "allow-insecure-connections": true, - "timeout": "5s", - "exit": `invalid`, - }, - res: &dataIn{ - timeout: 5 * time.Second, - }, - err: "invalid character 'i' looking for beginning of value", - }, - { - name: "JSONGood", - vars: map[string]interface{}{ - "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, - }, - }, - { - name: "ClientBad", - vars: map[string]interface{}{ - "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 confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://localhost:1/eth/v1/beacon/genesis\": dial tcp 127.0.0.1:1: connect: connection refused", - }, - { - name: "EpochProvided", - vars: map[string]interface{}{ - "connection": os.Getenv("ETHDO_TEST_CONNECTION"), - "allow-insecure-connections": true, - "timeout": "5s", - "key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866", - "epoch": "123", - }, - res: &dataIn{ - timeout: 5 * time.Second, - }, - }, - } - - 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/exit/output.go b/cmd/validator/exit/output.go index 8c42308..440955a 100644 --- a/cmd/validator/exit/output.go +++ b/cmd/validator/exit/output.go @@ -1,4 +1,4 @@ -// Copyright © 2019, 2020 Weald Technology Trading +// Copyright © 2023 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 @@ -16,42 +16,35 @@ package validatorexit import ( "context" "encoding/json" + "fmt" + "os" - spec "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" - "github.com/wealdtech/ethdo/util" ) -type dataOut struct { - jsonOutput bool - forkVersion spec.Version - signedVoluntaryExit *spec.SignedVoluntaryExit -} - -func output(ctx context.Context, data *dataOut) (string, error) { - if data == nil { - return "", errors.New("no data") +//nolint:unparam +func (c *command) output(_ context.Context) (string, error) { + if c.quiet { + return "", nil } - if data.signedVoluntaryExit == nil { - return "", errors.New("no signed voluntary exit") + if c.prepareOffline { + return fmt.Sprintf("%s generated", offlinePreparationFilename), nil } - if data.jsonOutput { - return outputJSON(ctx, data) + if c.json || c.offline { + data, err := json.Marshal(c.signedOperation) + if err != nil { + return "", errors.Wrap(err, "failed to marshal signed operation") + } + if c.json { + return string(data), nil + } + if err := os.WriteFile(exitOperationFilename, data, 0600); err != nil { + return "", errors.Wrap(err, fmt.Sprintf("failed to write %s", exitOperationFilename)) + } + return "", nil } return "", nil } - -func outputJSON(ctx context.Context, data *dataOut) (string, error) { - validatorExitData := &util.ValidatorExitData{ - Exit: data.signedVoluntaryExit, - ForkVersion: data.forkVersion, - } - bytes, err := json.Marshal(validatorExitData) - if err != nil { - return "", errors.Wrap(err, "failed to generate JSON") - } - return string(bytes), nil -} diff --git a/cmd/validator/exit/output_internal_test.go b/cmd/validator/exit/output_internal_test.go deleted file mode 100644 index 246976d..0000000 --- a/cmd/validator/exit/output_internal_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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 validatorexit - -import ( - "context" - "testing" - - 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 - res string - err string - }{ - { - name: "Nil", - err: "no data", - }, - { - name: "SignedVoluntaryExitNil", - dataOut: &dataOut{ - jsonOutput: true, - }, - err: "no signed voluntary exit", - }, - { - name: "Good", - dataOut: &dataOut{ - forkVersion: spec.Version{0x01, 0x02, 0x03, 0x04}, - signedVoluntaryExit: &spec.SignedVoluntaryExit{ - Message: &spec.VoluntaryExit{ - Epoch: spec.Epoch(123), - ValidatorIndex: spec.ValidatorIndex(456), - }, - Signature: spec.BLSSignature{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, - 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, - 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, - }, - }, - }, - }, - { - name: "JSON", - dataOut: &dataOut{ - jsonOutput: true, - forkVersion: spec.Version{0x01, 0x02, 0x03, 0x04}, - signedVoluntaryExit: &spec.SignedVoluntaryExit{ - Message: &spec.VoluntaryExit{ - Epoch: spec.Epoch(123), - ValidatorIndex: spec.ValidatorIndex(456), - }, - Signature: spec.BLSSignature{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, - 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, - 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, - }, - }, - }, - res: `{"exit":{"message":{"epoch":"123","validator_index":"456"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"},"fork_version":"0x01020304"}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - res, err := output(context.Background(), test.dataOut) - if test.err != "" { - require.EqualError(t, err, test.err) - } else { - require.NoError(t, err) - require.Equal(t, test.res, res) - } - }) - } -} diff --git a/cmd/validator/exit/process.go b/cmd/validator/exit/process.go index 88e62ec..f54201d 100644 --- a/cmd/validator/exit/process.go +++ b/cmd/validator/exit/process.go @@ -1,4 +1,4 @@ -// Copyright © 2019, 2020 Weald Technology Trading +// Copyright © 2023 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 @@ -14,120 +14,526 @@ package validatorexit import ( + "bytes" "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" - 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" + consensusclient "github.com/attestantio/go-eth2-client" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" + "github.com/prysmaticlabs/go-ssz" + "github.com/wealdtech/ethdo/beacon" + standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard" "github.com/wealdtech/ethdo/signing" "github.com/wealdtech/ethdo/util" + e2types "github.com/wealdtech/go-eth2-types/v2" + ethutil "github.com/wealdtech/go-eth2-util" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) -// maxFutureEpochs is the farthest in the future for which an exit will be created. -var maxFutureEpochs = spec.Epoch(1024) +// validatorPath is the regular expression that matches a validator path. +var validatorPath = regexp.MustCompile("^m/12381/3600/[0-9]+/0/0$") -func process(ctx context.Context, data *dataIn) (*dataOut, error) { - if data == nil { - return nil, errors.New("no data") +var offlinePreparationFilename = "offline-preparation.json" +var exitOperationFilename = "exit-operation.json" + +func (c *command) process(ctx context.Context) error { + if err := c.setup(ctx); err != nil { + return err } - if data.epoch > data.currentEpoch { - if data.epoch-data.currentEpoch > maxFutureEpochs { - return nil, errors.New("not generating exit for an epoch in the far future") + if err := c.obtainChainInfo(ctx); err != nil { + return err + } + + if c.prepareOffline { + return c.writeChainInfoToFile(ctx) + } + + if err := c.generateDomain(ctx); err != nil { + return err + } + + if err := c.obtainOperation(ctx); err != nil { + return err + } + + if validated, reason := c.validateOperation(ctx); !validated { + return fmt.Errorf("operation failed validation: %s", reason) + } + + if c.json || c.offline { + if c.debug { + fmt.Fprintf(os.Stderr, "Not broadcasting credentials change operations\n") + } + // Want JSON output, or cannot broadcast. + return nil + } + + return c.broadcastOperation(ctx) +} + +func (c *command) obtainOperation(ctx context.Context) error { + if (c.mnemonic == "" || c.path == "") && c.privateKey == "" && c.validator == "" { + // No input information; fetch the operation from a file. + err := c.obtainOperationFromFileOrInput(ctx) + if err == nil { + // Success. + return nil + } + if c.signedOperationInput != "" { + return errors.Wrap(err, "failed to obtain supplied signed operation") + } + return errors.Wrap(err, fmt.Sprintf("no account, mnemonic or private key specified, and no %s file loaded", exitOperationFilename)) + } + + if c.mnemonic != "" { + switch { + case c.path != "": + // Have a mnemonic and path. + return c.generateOperationFromMnemonicAndPath(ctx) + case c.validator != "": + // Have a mnemonic and validator. + return c.generateOperationFromMnemonicAndValidator(ctx) + default: + return errors.New("mnemonic must be supplied with either a path or validator") } } - results := &dataOut{ - forkVersion: data.fork.CurrentVersion, - jsonOutput: data.jsonOutput, + + if c.privateKey != "" { + return c.generateOperationFromPrivateKey(ctx) } - validator, err := fetchValidator(ctx, data) + if c.validator != "" { + return c.generateOperationFromValidator(ctx) + } + + return errors.New("unsupported combination of inputs; see help for details of supported combinations") +} + +func (c *command) generateOperationFromMnemonicAndPath(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]*beacon.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 for a validator", 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 +} + +func (c *command) generateOperationFromMnemonicAndValidator(ctx context.Context) error { + seed, err := util.SeedFromMnemonic(c.mnemonic) + if err != nil { + return err + } + + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, c.validator) + if err != nil { + return err + } + + // Scan the keys from the seed to find the path. + maxDistance := 1024 + // Start scanning the validator keys. + for i := 0; ; i++ { + if i == maxDistance { + if c.debug { + fmt.Fprintf(os.Stderr, "Gone %d indices without finding the validator, not scanning any further\n", maxDistance) + } + break + } + validatorKeyPath := fmt.Sprintf("m/12381/3600/%d/0/0", i) + validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, validatorKeyPath) + if err != nil { + return errors.Wrap(err, "failed to generate validator private key") + } + validatorPubkey := validatorPrivkey.PublicKey().Marshal() + if bytes.Equal(validatorPubkey, validatorInfo.Pubkey[:]) { + validatorAccount, err := util.ParseAccount(ctx, c.mnemonic, []string{validatorKeyPath}, true) + if err != nil { + return errors.Wrap(err, "failed to create withdrawal account") + } + + err = c.generateOperationFromAccount(ctx, validatorInfo, validatorAccount, c.chainInfo.Epoch) + if err != nil { + return err + } + break + } + } + + return nil +} + +func (c *command) generateOperationFromPrivateKey(ctx context.Context) error { + validatorAccount, err := util.ParseAccount(ctx, c.privateKey, nil, true) + if err != nil { + return errors.Wrap(err, "failed to create validator account") + } + + validatorPubkey, err := util.BestPublicKey(validatorAccount) + if err != nil { + return err + } + + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, fmt.Sprintf("%#x", validatorPubkey.Marshal())) + if err != nil { + return err + } + + if c.verbose { + fmt.Fprintf(os.Stderr, "Validator %d found with public key %s\n", validatorInfo.Index, validatorPubkey) + } + + if err = c.generateOperationFromAccount(ctx, validatorInfo, validatorAccount, c.chainInfo.Epoch); err != nil { + return err + } + + return nil +} + +func (c *command) generateOperationFromValidator(ctx context.Context) error { + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, c.validator) + if err != nil { + return err + } + + validatorAccount, err := util.ParseAccount(ctx, c.validator, nil, true) + if err != nil { + return err + } + + if err := c.generateOperationFromAccount(ctx, validatorInfo, validatorAccount, c.chainInfo.Epoch); err != nil { + return err + } + + return nil +} + +func (c *command) obtainOperationFromFileOrInput(ctx context.Context) error { + // Start off by attempting to use the provided signed operation. + if c.signedOperationInput != "" { + return c.obtainOperationFromInput(ctx) + } + // If not, read it from the file with the standard name. + return c.obtainOperationFromFile(ctx) +} + +func (c *command) obtainOperationFromFile(ctx context.Context) error { + _, err := os.Stat(exitOperationFilename) + if err != nil { + return errors.Wrap(err, "failed to read exit operation file") + } + if c.debug { + fmt.Fprintf(os.Stderr, "%s found; loading operation\n", exitOperationFilename) + } + data, err := os.ReadFile(exitOperationFilename) + if err != nil { + return errors.Wrap(err, "failed to read exit operation file") + } + if err := json.Unmarshal(data, &c.signedOperation); err != nil { + return errors.Wrap(err, "failed to parse exit operation file") + } + + if err := c.verifySignedOperation(ctx, c.signedOperation); err != nil { + return err + } + + return nil +} + +func (c *command) obtainOperationFromInput(ctx context.Context) error { + if !strings.HasPrefix(c.signedOperationInput, "{") { + // This looks like a file; read it in. + data, err := os.ReadFile(c.signedOperationInput) + if err != nil { + return errors.Wrap(err, "failed to read input file") + } + c.signedOperationInput = string(data) + } + + if err := json.Unmarshal([]byte(c.signedOperationInput), &c.signedOperation); err != nil { + return errors.Wrap(err, "failed to parse exit operation input") + } + + if err := c.verifySignedOperation(ctx, c.signedOperation); err != nil { + return err + } + + return nil +} + +func (c *command) generateOperationFromSeedAndPath(ctx context.Context, + validators map[string]*beacon.ValidatorInfo, + seed []byte, + path string, +) error { + validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, path) + if err != nil { + return errors.Wrap(err, "failed to generate validator private key") + } + + c.privateKey = fmt.Sprintf("%#x", validatorPrivkey.Marshal()) + return c.generateOperationFromPrivateKey(ctx) +} + +func (c *command) generateOperationFromAccount(ctx context.Context, + validator *beacon.ValidatorInfo, + account e2wtypes.Account, + epoch phase0.Epoch, +) error { + var err error + c.signedOperation, err = c.createSignedOperation(ctx, validator, account, epoch) + return err +} + +func (c *command) createSignedOperation(ctx context.Context, + validator *beacon.ValidatorInfo, + account e2wtypes.Account, + epoch phase0.Epoch, +) ( + *phase0.SignedVoluntaryExit, + error, +) { + pubkey, err := util.BestPublicKey(account) if err != nil { return nil, err } - - exit, err := generateExit(ctx, data, validator) - if err != nil { - return nil, errors.Wrap(err, "failed to generate voluntary exit") - } - root, err := exit.HashTreeRoot() - if err != nil { - return nil, errors.Wrap(err, "failed to generate root for voluntary exit") + if c.debug { + fmt.Fprintf(os.Stderr, "Using %#x as best public key for %s\n", pubkey.Marshal(), account.Name()) } + blsPubkey := phase0.BLSPubKey{} + copy(blsPubkey[:], pubkey.Marshal()) - if data.account != nil { - signature, err := signing.SignRoot(ctx, data.account, data.passphrases, root, data.domain) - if err != nil { - return nil, errors.Wrap(err, "failed to sign voluntary exit") - } - - results.signedVoluntaryExit = &spec.SignedVoluntaryExit{ - Message: exit, - Signature: signature, - } - } else { - results.signedVoluntaryExit = data.signedVoluntaryExit - } - - if !data.jsonOutput { - if err := broadcastExit(ctx, data, results); err != nil { - return nil, errors.Wrap(err, "failed to broadcast voluntary exit") - } - } - - return results, nil -} - -func generateExit(ctx context.Context, data *dataIn, validator *api.Validator) (*spec.VoluntaryExit, error) { - if data == nil { - return nil, errors.New("no data") - } - - if data.signedVoluntaryExit != nil { - return data.signedVoluntaryExit.Message, nil - } - - if validator == nil { - return nil, errors.New("no validator") - } - - exit := &spec.VoluntaryExit{ - Epoch: data.epoch, + operation := &phase0.VoluntaryExit{ + Epoch: epoch, ValidatorIndex: validator.Index, } - return exit, nil -} - -func broadcastExit(ctx context.Context, data *dataIn, results *dataOut) error { - return data.eth2Client.(eth2client.VoluntaryExitSubmitter).SubmitVoluntaryExit(ctx, results.signedVoluntaryExit) -} - -func fetchValidator(ctx context.Context, data *dataIn) (*api.Validator, error) { - // Validator. - if data.account == nil { - return nil, nil - } - - var validator *api.Validator - validatorPubKeys := make([]spec.BLSPubKey, 1) - pubKey, err := util.BestPublicKey(data.account) + root, err := operation.HashTreeRoot() if err != nil { - return nil, errors.Wrap(err, "failed to obtain public key for account") + return nil, errors.Wrap(err, "failed to generate root for exit operation") } - copy(validatorPubKeys[0][:], pubKey.Marshal()) - validators, err := data.eth2Client.(eth2client.ValidatorsProvider).ValidatorsByPubKey(ctx, "head", validatorPubKeys) + + // Sign the operation. + if c.debug { + fmt.Fprintf(os.Stderr, "Signing %#x with domain %#x by public key %#x\n", root, c.domain, account.PublicKey().Marshal()) + } + signature, err := signing.SignRoot(ctx, account, nil, root, c.domain) if err != nil { - return nil, errors.Wrap(err, "failed to obtain validator from beacon node") + return nil, errors.Wrap(err, "failed to sign exit operation") } - if len(validators) == 0 { - return nil, errors.New("validator not known by beacon node") - } - for _, v := range validators { - validator = v - } - if validator.Status != api.ValidatorStateActiveOngoing { - return nil, errors.New("validator is not active; cannot exit") - } - return validator, nil + + return &phase0.SignedVoluntaryExit{ + Message: operation, + Signature: signature, + }, nil +} + +func (c *command) verifySignedOperation(ctx context.Context, op *phase0.SignedVoluntaryExit) error { + root, err := op.Message.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "failed to generate message root") + } + + sigBytes := make([]byte, len(op.Signature)) + copy(sigBytes, op.Signature[:]) + sig, err := e2types.BLSSignatureFromBytes(sigBytes) + if err != nil { + if c.verbose { + fmt.Fprintf(os.Stderr, "Invalid signature: %v\n", err.Error()) + } + return errors.New("invalid signature") + } + + container := &phase0.SigningData{ + ObjectRoot: root, + Domain: c.domain, + } + signingRoot, err := ssz.HashTreeRoot(container) + if err != nil { + return errors.Wrap(err, "failed to generate signing root") + } + + validatorInfo, err := c.chainInfo.FetchValidatorInfo(ctx, fmt.Sprintf("%d", op.Message.ValidatorIndex)) + if err != nil { + return err + } + + pubkeyBytes := make([]byte, len(validatorInfo.Pubkey[:])) + copy(pubkeyBytes, validatorInfo.Pubkey[:]) + pubkey, err := e2types.BLSPublicKeyFromBytes(pubkeyBytes) + if err != nil { + return errors.Wrap(err, "invalid public key") + } + + if !sig.Verify(signingRoot[:], pubkey) { + return errors.New("signature does not verify") + } + + return nil +} + +func (c *command) validateOperation(_ context.Context, +) ( + bool, + string, +) { + var validatorInfo *beacon.ValidatorInfo + for _, chainValidatorInfo := range c.chainInfo.Validators { + if chainValidatorInfo.Index == c.signedOperation.Message.ValidatorIndex { + validatorInfo = chainValidatorInfo + break + } + } + if validatorInfo == nil { + return false, "validator not known on chain" + } + if c.debug { + fmt.Fprintf(os.Stderr, "Validator exit operation: %v", c.signedOperation) + fmt.Fprintf(os.Stderr, "On-chain validator info: %v\n", validatorInfo) + } + + if validatorInfo.State == apiv1.ValidatorStateActiveExiting || + validatorInfo.State == apiv1.ValidatorStateActiveSlashed || + validatorInfo.State == apiv1.ValidatorStateExitedUnslashed || + validatorInfo.State == apiv1.ValidatorStateExitedSlashed || + validatorInfo.State == apiv1.ValidatorStateWithdrawalPossible || + validatorInfo.State == apiv1.ValidatorStateWithdrawalDone { + return false, fmt.Sprintf("validator is in state %v, not suitable to generate an exit", validatorInfo.State) + } + + return true, "" +} + +func (c *command) broadcastOperation(ctx context.Context) error { + return c.consensusClient.(consensusclient.VoluntaryExitSubmitter).SubmitVoluntaryExit(ctx, c.signedOperation) +} + +func (c *command) setup(ctx context.Context) error { + if c.offline { + return nil + } + + // 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") + } + + // Set up chaintime. + c.chainTime, err = standardchaintime.New(ctx, + standardchaintime.WithGenesisTimeProvider(c.consensusClient.(consensusclient.GenesisTimeProvider)), + standardchaintime.WithSpecProvider(c.consensusClient.(consensusclient.SpecProvider)), + ) + if err != nil { + return errors.Wrap(err, "failed to create chaintime service") + } + + return nil +} + +func (c *command) generateDomain(ctx context.Context) error { + genesisValidatorsRoot, err := c.obtainGenesisValidatorsRoot(ctx) + if err != nil { + return err + } + forkVersion, err := c.obtainForkVersion(ctx) + if err != nil { + return err + } + + root, err := (&phase0.ForkData{ + CurrentVersion: forkVersion, + GenesisValidatorsRoot: genesisValidatorsRoot, + }).HashTreeRoot() + if err != nil { + return errors.Wrap(err, "failed to calculate signature domain") + } + + copy(c.domain[:], c.chainInfo.BLSToExecutionChangeDomainType[:]) + copy(c.domain[4:], root[:]) + if c.debug { + fmt.Fprintf(os.Stderr, "Domain is %#x\n", c.domain) + } + + return nil +} + +func (c *command) obtainGenesisValidatorsRoot(ctx context.Context) (phase0.Root, error) { + genesisValidatorsRoot := phase0.Root{} + + if c.genesisValidatorsRoot != "" { + if c.debug { + fmt.Fprintf(os.Stderr, "Genesis validators root supplied on the command line\n") + } + root, err := hex.DecodeString(strings.TrimPrefix(c.genesisValidatorsRoot, "0x")) + if err != nil { + return phase0.Root{}, errors.Wrap(err, "invalid genesis validators root supplied") + } + if len(root) != phase0.RootLength { + return phase0.Root{}, errors.New("invalid length for genesis validators root") + } + copy(genesisValidatorsRoot[:], root) + } else { + if c.debug { + fmt.Fprintf(os.Stderr, "Genesis validators root obtained from chain info\n") + } + copy(genesisValidatorsRoot[:], c.chainInfo.GenesisValidatorsRoot[:]) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "Using genesis validators root %#x\n", genesisValidatorsRoot) + } + return genesisValidatorsRoot, nil +} + +func (c *command) obtainForkVersion(ctx context.Context) (phase0.Version, error) { + forkVersion := phase0.Version{} + + if c.forkVersion != "" { + if c.debug { + fmt.Fprintf(os.Stderr, "Fork version supplied on the command line\n") + } + version, err := hex.DecodeString(strings.TrimPrefix(c.forkVersion, "0x")) + if err != nil { + return phase0.Version{}, errors.Wrap(err, "invalid fork version supplied") + } + if len(version) != phase0.ForkVersionLength { + return phase0.Version{}, errors.New("invalid length for fork version") + } + copy(forkVersion[:], version) + } else { + if c.debug { + fmt.Fprintf(os.Stderr, "Fork version obtained from chain info\n") + } + // Use the current fork version for generating an exit as per the spec. + copy(forkVersion[:], c.chainInfo.CurrentForkVersion[:]) + } + + if c.debug { + fmt.Fprintf(os.Stderr, "Using fork version %#x\n", forkVersion) + } + return forkVersion, nil } diff --git a/cmd/validator/exit/process_internal_test.go b/cmd/validator/exit/process_internal_test.go index aeb2ec4..59d3a1b 100644 --- a/cmd/validator/exit/process_internal_test.go +++ b/cmd/validator/exit/process_internal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2019, 2020 Weald Technology Trading +// Copyright © 2023 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 @@ -15,215 +15,467 @@ package validatorexit import ( "context" - "os" + "fmt" "testing" - "time" - api "github.com/attestantio/go-eth2-client/api/v1" - "github.com/attestantio/go-eth2-client/auto" - spec "github.com/attestantio/go-eth2-client/spec/phase0" - "github.com/spf13/viper" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" - "github.com/wealdtech/ethdo/testutil" + "github.com/wealdtech/ethdo/beacon" 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 TestProcess(t *testing.T) { - if os.Getenv("ETHDO_TEST_CONNECTION") == "" { - t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests") - } +func TestGenerateOperationFromMnemonicAndPath(t *testing.T) { + ctx := context.Background() require.NoError(t, e2types.InitBLS()) - eth2Client, err := auto.New(context.Background(), - auto.WithAddress(os.Getenv("ETHDO_TEST_CONNECTION")), - ) - require.NoError(t, err) - 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") - interop0, err := testWallet.(e2wtypes.WalletAccountImporter).ImportAccount(context.Background(), - "Interop 0", - testutil.HexToBytes("0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866"), - []byte("pass"), - ) - require.NoError(t, err) - - // activeValidator := &api.Validator{ - // Index: 123, - // Balance: 32123456789, - // Status: api.ValidatorStateActiveOngoing, - // Validator: &spec.Validator{ - // PublicKey: testutil.HexToPubKey("0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c"), - // WithdrawalCredentials: nil, - // EffectiveBalance: 32000000000, - // Slashed: false, - // ActivationEligibilityEpoch: 0, - // ActivationEpoch: 0, - // ExitEpoch: 0, - // WithdrawableEpoch: 0, - // }, - // } - - epochFork := &spec.Fork{ - PreviousVersion: spec.Version{0x00, 0x00, 0x00, 0x00}, - CurrentVersion: spec.Version{0x00, 0x00, 0x00, 0x00}, - Epoch: 0, + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, } tests := []struct { - name string - dataIn *dataIn - err string + name string + command *command + expected *phase0.SignedVoluntaryExit + err string }{ { - name: "Nil", - err: "no data", - }, - { - name: "EpochTooLate", - dataIn: &dataIn{ - timeout: 5 * time.Second, - eth2Client: eth2Client, - fork: epochFork, - currentEpoch: 10, - account: interop0, - passphrases: []string{"pass"}, - epoch: 9999999, - domain: spec.Domain{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}, + name: "MnemonicInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + path: "m/12381/3600/0/0/0", + chainInfo: chainInfo, }, - err: "not generating exit for an epoch in the far future", + err: "mnemonic is invalid", }, { - name: "AccountUnknown", - dataIn: &dataIn{ - timeout: 5 * time.Second, - eth2Client: eth2Client, - fork: epochFork, - currentEpoch: 10, - account: interop0, - passphrases: []string{"pass"}, - epoch: 10, - domain: spec.Domain{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}, + name: "PathInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0", + chainInfo: chainInfo, }, - err: "validator not known by beacon node", - }, - // { - // name: "Good", - // dataIn: &dataIn{ - // timeout: 5 * time.Second, - // eth2Client: eth2Client, - // fork: epochFork, - // currentEpoch: 10, - // account: interop0, - // passphrases: []string{"pass"}, - // epoch: 10, - // domain: spec.Domain{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}, - // }, - // }, - } - - 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) - } - }) - } -} - -func TestGenerateExit(t *testing.T) { - activeValidator := &api.Validator{ - Index: 123, - Balance: 32123456789, - Status: api.ValidatorStateActiveOngoing, - Validator: &spec.Validator{ - PublicKey: testutil.HexToPubKey("0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c"), - WithdrawalCredentials: nil, - EffectiveBalance: 32000000000, - Slashed: false, - ActivationEligibilityEpoch: 0, - ActivationEpoch: 0, - ExitEpoch: 0, - WithdrawableEpoch: 0, - }, - } - - tests := []struct { - name string - validator *api.Validator - dataIn *dataIn - err string - }{ - { - name: "Nil", - err: "no data", - }, - { - name: "SignedVoluntaryExitGood", - dataIn: &dataIn{ - signedVoluntaryExit: &spec.SignedVoluntaryExit{ - Message: &spec.VoluntaryExit{ - Epoch: spec.Epoch(123), - ValidatorIndex: spec.ValidatorIndex(456), - }, - Signature: spec.BLSSignature{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, - 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, - 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, - }, - }, - }, - }, - { - name: "ValidatorMissing", - dataIn: &dataIn{}, - err: "no validator", - }, - { - name: "ValidatorGood", - dataIn: &dataIn{}, - validator: activeValidator, + err: "path m/12381/3600/0/0 does not match EIP-2334 format for a validator", }, { name: "Good", - dataIn: &dataIn{ - signedVoluntaryExit: &spec.SignedVoluntaryExit{ - Message: &spec.VoluntaryExit{ - Epoch: spec.Epoch(123), - ValidatorIndex: spec.ValidatorIndex(456), - }, - Signature: spec.BLSSignature{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, - 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, - 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, - }, - }, + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0/0", + chainInfo: chainInfo, + }, + expected: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x89, 0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, }, - validator: activeValidator, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := generateExit(context.Background(), test.dataIn, test.validator) + err := test.command.generateOperationFromMnemonicAndPath(ctx) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, test.command.signedOperation) + } + }) + } +} + +func TestGenerateOperationFromMnemonicAndValidator(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + + tests := []struct { + name string + command *command + expected *phase0.SignedVoluntaryExit + err string + }{ + { + name: "MnemonicInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + validator: "0", + chainInfo: chainInfo, + }, + err: "mnemonic is invalid", + }, + { + name: "ValidatorMissing", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + }, + err: "no validator specified", + }, + { + name: "Good", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + validator: "0", + chainInfo: chainInfo, + }, + expected: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x89, 0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, + }, + }, + { + name: "GoodPubkey", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + validator: "0xb384f767d964e100c8a9b21018d08c25ffebae268b3ab6d610353897541971726dbfc3c7463884c68a531515aab94c87", + chainInfo: chainInfo, + }, + expected: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x89, 0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.generateOperationFromMnemonicAndValidator(ctx) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, test.command.signedOperation) + } + }) + } +} + +func TestGenerateOperationFromSeedAndPath(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + { + Index: 2, + Pubkey: phase0.BLSPubKey{0xaf, 0x9c, 0xe4, 0x4f, 0x50, 0x14, 0x8d, 0xb4, 0x12, 0x19, 0x4a, 0xf0, 0xba, 0xf0, 0xba, 0xb3, 0x6b, 0xd5, 0xc3, 0xe0, 0xc4, 0x93, 0x89, 0x11, 0xa4, 0xe5, 0x02, 0xe3, 0x98, 0xb5, 0x9e, 0x5c, 0xca, 0x7c, 0x78, 0xe3, 0xfe, 0x03, 0x41, 0x95, 0x47, 0x88, 0x79, 0xee, 0xb2, 0x3d, 0xb0, 0xa6}, + WithdrawalCredentials: []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + { + Index: 3, + Pubkey: phase0.BLSPubKey{0x86, 0xd3, 0x30, 0xaf, 0x51, 0xfa, 0x59, 0x3f, 0xa9, 0xf9, 0x3e, 0xdb, 0x9d, 0x16, 0x64, 0x01, 0x86, 0xbe, 0x2e, 0x93, 0xea, 0x94, 0xd2, 0x59, 0x78, 0x1e, 0x1e, 0xb3, 0x4d, 0xeb, 0x84, 0x4c, 0x39, 0x68, 0xd7, 0x5e, 0xa9, 0x1d, 0x19, 0xf1, 0x59, 0xdb, 0xd0, 0x52, 0x3c, 0x6c, 0x5b, 0xa5}, + WithdrawalCredentials: []byte{0x00, 0x81, 0x68, 0x45, 0x6b, 0x6d, 0x9a, 0x32, 0x83, 0x93, 0x1f, 0xea, 0x52, 0x10, 0xda, 0x12, 0x2d, 0x1e, 0x65, 0xe8, 0xed, 0x50, 0xb8, 0xe8, 0xf5, 0x91, 0x11, 0x83, 0xb0, 0x2f, 0xd1, 0x25}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + validators := make(map[string]*beacon.ValidatorInfo, len(chainInfo.Validators)) + for i := range chainInfo.Validators { + validators[fmt.Sprintf("%#x", chainInfo.Validators[i].Pubkey)] = chainInfo.Validators[i] + } + + tests := []struct { + name string + command *command + seed []byte + path string + err string + expected *phase0.SignedVoluntaryExit + }{ + { + name: "PathInvalid", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + chainInfo: chainInfo, + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "invalid", + err: "failed to generate validator private key: not master at path component 0", + }, + { + name: "ValidatorUnknown", + command: &command{ + chainInfo: chainInfo, + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/999/0/0", + err: "unknown validator", + }, + // { + // name: "ValidatorAlreadyExited", + // command: &command{ + // chainInfo: chainInfo, + // }, + // seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + // path: "m/12381/3600/2/0/0", + // }, + { + name: "GoodPath0", + command: &command{ + chainInfo: chainInfo, + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/0/0/0", + expected: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x89, 0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, + }, + }, + { + name: "GoodPath3", + command: &command{ + chainInfo: chainInfo, + }, + seed: []byte{0x40, 0x8b, 0x28, 0x5c, 0x12, 0x38, 0x36, 0x00, 0x4f, 0x4b, 0x88, 0x42, 0xc8, 0x93, 0x24, 0xc1, 0xf0, 0x13, 0x82, 0x45, 0x0c, 0x0d, 0x43, 0x9a, 0xf3, 0x45, 0xba, 0x7f, 0xc4, 0x9a, 0xcf, 0x70, 0x54, 0x89, 0xc6, 0xfc, 0x77, 0xdb, 0xd4, 0xe3, 0xdc, 0x1d, 0xd8, 0xcc, 0x6b, 0xc9, 0xf0, 0x43, 0xdb, 0x8a, 0xda, 0x1e, 0x24, 0x3c, 0x4a, 0x0e, 0xaf, 0xb2, 0x90, 0xd3, 0x99, 0x48, 0x08, 0x40}, + path: "m/12381/3600/3/0/0", + expected: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 3, + }, + Signature: phase0.BLSSignature{0x99, 0x78, 0xb4, 0x9c, 0x21, 0x60, 0x3f, 0x04, 0xa3, 0x04, 0x4e, 0x4c, 0x49, 0x0c, 0xb4, 0x68, 0x7c, 0x6e, 0x14, 0xc2, 0xda, 0xed, 0x25, 0x92, 0xe0, 0x02, 0x2d, 0xcd, 0x63, 0xeb, 0xe7, 0x4a, 0xf1, 0x1a, 0xca, 0xba, 0xae, 0x50, 0xe1, 0x8a, 0x1d, 0xae, 0x96, 0xd9, 0xd2, 0x56, 0xbf, 0x9f, 0x02, 0x48, 0x85, 0x05, 0xc1, 0xfb, 0xb3, 0x4a, 0x0b, 0x68, 0xec, 0xc5, 0xb5, 0xf5, 0xea, 0x53, 0xdb, 0xd0, 0x09, 0x08, 0xe3, 0x1e, 0xa8, 0xca, 0x9d, 0x02, 0x08, 0x3b, 0x9e, 0xf1, 0xc7, 0xd2, 0x32, 0xf4, 0xba, 0xd9, 0xea, 0x56, 0x4b, 0xc5, 0x87, 0xd5, 0x27, 0xb7, 0x74, 0x97, 0x8a, 0xee}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.generateOperationFromSeedAndPath(ctx, validators, test.seed, test.path) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, test.command.signedOperation) + } + }) + } +} + +func TestVerifyOperation(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + + tests := []struct { + name string + command *command + err string + }{ + { + name: "SignatureMissing", + command: &command{ + chainInfo: chainInfo, + signedOperation: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + }, + }, + err: "invalid signature", + }, + { + name: "SignatureShort", + command: &command{ + chainInfo: chainInfo, + signedOperation: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, + }, + }, + err: "invalid signature", + }, + { + name: "SignatureIncorrect", + command: &command{ + chainInfo: chainInfo, + signedOperation: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x99, 0x78, 0xb4, 0x9c, 0x21, 0x60, 0x3f, 0x04, 0xa3, 0x04, 0x4e, 0x4c, 0x49, 0x0c, 0xb4, 0x68, 0x7c, 0x6e, 0x14, 0xc2, 0xda, 0xed, 0x25, 0x92, 0xe0, 0x02, 0x2d, 0xcd, 0x63, 0xeb, 0xe7, 0x4a, 0xf1, 0x1a, 0xca, 0xba, 0xae, 0x50, 0xe1, 0x8a, 0x1d, 0xae, 0x96, 0xd9, 0xd2, 0x56, 0xbf, 0x9f, 0x02, 0x48, 0x85, 0x05, 0xc1, 0xfb, 0xb3, 0x4a, 0x0b, 0x68, 0xec, 0xc5, 0xb5, 0xf5, 0xea, 0x53, 0xdb, 0xd0, 0x09, 0x08, 0xe3, 0x1e, 0xa8, 0xca, 0x9d, 0x02, 0x08, 0x3b, 0x9e, 0xf1, 0xc7, 0xd2, 0x32, 0xf4, 0xba, 0xd9, 0xea, 0x56, 0x4b, 0xc5, 0x87, 0xd5, 0x27, 0xb7, 0x74, 0x97, 0x8a, 0xee}, + }, + }, + err: "signature does not verify", + }, + { + name: "Good", + command: &command{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + validator: "0", + chainInfo: chainInfo, + signedOperation: &phase0.SignedVoluntaryExit{ + Message: &phase0.VoluntaryExit{ + Epoch: 1, + ValidatorIndex: 0, + }, + Signature: phase0.BLSSignature{0x89, 0xf5, 0xc4, 0x42, 0x88, 0xf9, 0x5e, 0x19, 0xb6, 0xc1, 0x39, 0xf2, 0x62, 0x30, 0x05, 0x66, 0x5b, 0x98, 0x34, 0x62, 0xa2, 0x28, 0x12, 0x09, 0x77, 0xd8, 0x1f, 0x2e, 0xf5, 0x47, 0x56, 0x0b, 0xe2, 0x24, 0x46, 0xde, 0x21, 0xa8, 0xa9, 0x37, 0xd9, 0xdd, 0xa4, 0xe2, 0xd2, 0xec, 0x41, 0x75, 0x19, 0x64, 0x96, 0xcd, 0xd1, 0x30, 0x6d, 0xec, 0x4a, 0x12, 0x5f, 0x8c, 0x86, 0x1f, 0x80, 0x61, 0x71, 0x50, 0x4a, 0x9d, 0x6a, 0x61, 0x0e, 0xc4, 0xe1, 0x35, 0x04, 0x7e, 0x4f, 0xb6, 0x70, 0x52, 0xec, 0xc4, 0x56, 0x13, 0x60, 0xd0, 0xc3, 0xde, 0x04, 0xb6, 0xfb, 0xc4, 0x47, 0x42, 0x23, 0xff}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.verifySignedOperation(ctx, test.command.signedOperation) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestObtainOperationFromInput(t *testing.T) { + ctx := context.Background() + + require.NoError(t, e2types.InitBLS()) + + chainInfo := &beacon.ChainInfo{ + Version: 1, + Validators: []*beacon.ValidatorInfo{ + { + Index: 0, + Pubkey: phase0.BLSPubKey{0xb3, 0x84, 0xf7, 0x67, 0xd9, 0x64, 0xe1, 0x00, 0xc8, 0xa9, 0xb2, 0x10, 0x18, 0xd0, 0x8c, 0x25, 0xff, 0xeb, 0xae, 0x26, 0x8b, 0x3a, 0xb6, 0xd6, 0x10, 0x35, 0x38, 0x97, 0x54, 0x19, 0x71, 0x72, 0x6d, 0xbf, 0xc3, 0xc7, 0x46, 0x38, 0x84, 0xc6, 0x8a, 0x53, 0x15, 0x15, 0xaa, 0xb9, 0x4c, 0x87}, + WithdrawalCredentials: []byte{0x00, 0x8b, 0xa1, 0xcc, 0x4b, 0x09, 0x1b, 0x91, 0xc1, 0x20, 0x2b, 0xba, 0x3f, 0x50, 0x80, 0x75, 0xd6, 0xff, 0x56, 0x5c, 0x77, 0xe5, 0x59, 0xf0, 0x80, 0x3c, 0x07, 0x92, 0xe0, 0x30, 0x2b, 0xf1}, + }, + { + Index: 1, + Pubkey: phase0.BLSPubKey{0xb3, 0xd8, 0x9e, 0x2f, 0x29, 0xc7, 0x12, 0xc6, 0xa9, 0xf8, 0xe5, 0xa2, 0x69, 0xb9, 0x76, 0x17, 0xc4, 0xa9, 0x4d, 0xd6, 0xf6, 0x66, 0x2a, 0xb3, 0xb0, 0x7c, 0xe9, 0xe5, 0x43, 0x45, 0x73, 0xf1, 0x5b, 0x5c, 0x98, 0x8c, 0xd1, 0x4b, 0xbd, 0x58, 0x04, 0xf7, 0x71, 0x56, 0xa8, 0xaf, 0x1c, 0xfa}, + WithdrawalCredentials: []byte{0x00, 0x78, 0x6c, 0xb0, 0x2e, 0xd2, 0x8e, 0x5f, 0xbb, 0x1f, 0x7f, 0x9e, 0x93, 0x1a, 0x2b, 0x72, 0x69, 0x29, 0x06, 0xe6, 0xb1, 0x2c, 0xe4, 0x64, 0x39, 0x75, 0xe3, 0x2b, 0x51, 0x76, 0x91, 0xf2}, + }, + }, + GenesisValidatorsRoot: phase0.Root{}, + Epoch: 1, + CurrentForkVersion: phase0.Version{}, + } + + tests := []struct { + name string + command *command + err string + }{ + { + name: "InvalidFilename", + command: &command{ + signedOperationInput: `[]`, + chainInfo: chainInfo, + }, + err: "failed to read input file: open []: no such file or directory", + }, + { + name: "InvalidJSON", + command: &command{ + signedOperationInput: `{invalid}`, + chainInfo: chainInfo, + }, + err: "failed to parse exit operation input: invalid character 'i' looking for beginning of object key string", + }, + { + name: "Unverifable", + command: &command{ + signedOperationInput: `{"message":{"epoch":"1","validator_index":"0"},"signature":"0x9978b49c21603f04a3044e4c490cb4687c6e14c2daed2592e0022dcd63ebe74af11acabaae50e18a1dae96d9d256bf9f02488505c1fbb34a0b68ecc5b5f5ea53dbd00908e31ea8ca9d02083b9ef1c7d232f4bad9ea564bc587d527b774978aee"}`, + chainInfo: chainInfo, + }, + err: "signature does not verify", + }, + { + name: "Good", + command: &command{ + signedOperationInput: `{"message":{"epoch":"1","validator_index":"0"},"signature":"0x89f5c44288f95e19b6c139f2623005665b983462a228120977d81f2ef547560be22446de21a8a937d9dda4e2d2ec4175196496cdd1306dec4a125f8c861f806171504a9d6a610ec4e135047e4fb67052ecc4561360d0c3de04b6fbc4474223ff"}`, + chainInfo: chainInfo, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.command.obtainOperationFromInput(ctx) if test.err != "" { require.EqualError(t, err, test.err) } else { diff --git a/cmd/validator/exit/run.go b/cmd/validator/exit/run.go index faad2af..0dec62a 100644 --- a/cmd/validator/exit/run.go +++ b/cmd/validator/exit/run.go @@ -1,4 +1,4 @@ -// Copyright © 2019, 2020 Weald Technology Trading +// Copyright © 2023 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 @@ -21,19 +21,19 @@ import ( "github.com/spf13/viper" ) -// Run runs the wallet create data command. +// Run runs the command. func Run(cmd *cobra.Command) (string, error) { ctx := context.Background() - dataIn, err := input(ctx) + + c, err := newCommand(ctx) if err != nil { - return "", errors.Wrap(err, "failed to obtain input") + return "", errors.Wrap(err, "failed to set up command") } // Further errors do not need a usage report. cmd.SilenceUsage = true - dataOut, err := process(ctx, dataIn) - if err != nil { + if err := c.process(ctx); err != nil { return "", errors.Wrap(err, "failed to process") } @@ -41,7 +41,7 @@ func Run(cmd *cobra.Command) (string, error) { return "", nil } - results, err := output(ctx, dataOut) + results, err := c.output(ctx) if err != nil { return "", errors.Wrap(err, "failed to obtain output") } diff --git a/cmd/validator/expectation/process.go b/cmd/validator/expectation/process.go index 9a316ab..9ab7c4b 100644 --- a/cmd/validator/expectation/process.go +++ b/cmd/validator/expectation/process.go @@ -130,7 +130,6 @@ func (c *command) setup(ctx context.Context) error { chainTime, err := standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/validator/summary/process.go b/cmd/validator/summary/process.go index c69b36d..3387acf 100644 --- a/cmd/validator/summary/process.go +++ b/cmd/validator/summary/process.go @@ -384,7 +384,6 @@ func (c *command) setup(ctx context.Context) error { c.chainTime, err = standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/validator/yield/process.go b/cmd/validator/yield/process.go index fb48df2..6ee8825 100644 --- a/cmd/validator/yield/process.go +++ b/cmd/validator/yield/process.go @@ -119,7 +119,6 @@ func (c *command) setup(ctx context.Context) error { if c.validators == "" { chainTime, err := standardchaintime.New(ctx, standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)), - standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)), standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)), ) if err != nil { diff --git a/cmd/validatorexit.go b/cmd/validatorexit.go index e1bebe2..f6f5f4c 100644 --- a/cmd/validatorexit.go +++ b/cmd/validatorexit.go @@ -26,9 +26,16 @@ var validatorExitCmd = &cobra.Command{ Short: "Send an exit request for a validator", Long: `Send an exit request for a validator. For example: - ethdo validator exit --account=primary/validator --passphrase=secret + ethdo validator exit --validator=12345 -In quiet mode this will return 0 if the transaction has been generated, otherwise 1.`, +The validator and key can be specified in one of a number of ways: + + - mnemonic and path to the validator using --mnemonic and --path + - mnemonic and validator index or public key using --mnemonic and --validator + - validator private key using --private-key + - validator account using --validator + +In quiet mode this will return 0 if the exit operation has been generated (and successfully broadcast if online), otherwise 1.`, RunE: func(cmd *cobra.Command, args []string) error { res, err := validatorexit.Run(cmd) if err != nil { @@ -48,22 +55,38 @@ func init() { validatorCmd.AddCommand(validatorExitCmd) validatorFlags(validatorExitCmd) validatorExitCmd.Flags().Int64("epoch", -1, "Epoch at which to exit (defaults to current epoch)") - validatorExitCmd.Flags().String("key", "", "Private key if validator not known by ethdo") - validatorExitCmd.Flags().String("exit", "", "Use pre-defined JSON data as created by --json to exit") - validatorExitCmd.Flags().Bool("json", false, "Generate JSON data for an exit; do not broadcast to network") + validatorExitCmd.Flags().Bool("prepare-offline", false, "Create files for offline use") + validatorExitCmd.Flags().String("validator", "", "Validator to exit") + validatorExitCmd.Flags().String("signed-operation", "", "Use pre-defined JSON signed operation as created by --json to transmit the exit operation (reads from exit-operations.json if not present)") + validatorExitCmd.Flags().Bool("json", false, "Generate JSON data containing a signed operation rather than broadcast it to the network (implied when offline)") + validatorExitCmd.Flags().Bool("offline", false, "Do not attempt to connect to a beacon node to obtain information for the operation") + validatorExitCmd.Flags().String("fork-version", "", "Fork version to use for signing (overrides fetching from beacon node)") + validatorExitCmd.Flags().String("genesis-validators-root", "", "Genesis validators root to use for signing (overrides fetching from beacon node)") } func validatorExitBindings() { if err := viper.BindPFlag("epoch", validatorExitCmd.Flags().Lookup("epoch")); err != nil { panic(err) } - if err := viper.BindPFlag("key", validatorExitCmd.Flags().Lookup("key")); err != nil { + if err := viper.BindPFlag("prepare-offline", validatorExitCmd.Flags().Lookup("prepare-offline")); err != nil { panic(err) } - if err := viper.BindPFlag("exit", validatorExitCmd.Flags().Lookup("exit")); err != nil { + if err := viper.BindPFlag("validator", validatorExitCmd.Flags().Lookup("validator")); err != nil { + panic(err) + } + if err := viper.BindPFlag("signed-operation", validatorExitCmd.Flags().Lookup("signed-operation")); err != nil { panic(err) } if err := viper.BindPFlag("json", validatorExitCmd.Flags().Lookup("json")); err != nil { panic(err) } + if err := viper.BindPFlag("offline", validatorExitCmd.Flags().Lookup("offline")); err != nil { + panic(err) + } + if err := viper.BindPFlag("fork-version", validatorExitCmd.Flags().Lookup("fork-version")); err != nil { + panic(err) + } + if err := viper.BindPFlag("genesis-validators-root", validatorExitCmd.Flags().Lookup("genesis-validators-root")); err != nil { + panic(err) + } } diff --git a/go.mod b/go.mod index ac259cb..ef5545e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/wealdtech/ethdo go 1.18 require ( - github.com/attestantio/go-eth2-client v0.15.1 + github.com/attestantio/go-eth2-client v0.15.2 github.com/ferranbt/fastssz v0.1.2 github.com/gofrs/uuid v4.2.0+incompatible github.com/google/uuid v1.3.0 @@ -21,6 +21,7 @@ require ( github.com/spf13/viper v1.13.0 github.com/stretchr/testify v1.8.1 github.com/tyler-smith/go-bip39 v1.1.0 + github.com/wealdtech/chaind v0.6.17 github.com/wealdtech/go-bytesutil v1.2.0 github.com/wealdtech/go-ecodec v1.1.2 github.com/wealdtech/go-eth2-types/v2 v2.8.0 @@ -50,7 +51,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/goccy/go-yaml v1.9.2 // indirect + github.com/goccy/go-yaml v1.9.5 // indirect github.com/golang/glog v1.0.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -73,7 +74,7 @@ require ( github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect github.com/protolambda/zssz v0.1.5 // indirect - github.com/r3labs/sse/v2 v2.7.4 // indirect + github.com/r3labs/sse/v2 v2.8.1 // indirect github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 // indirect github.com/spf13/afero v1.9.2 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index a63481f..fe488f9 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/attestantio/go-eth2-client v0.15.1 h1:LbtRcjc1eXbtsi9TeCYYFV7VtvLYIOrXQkXjFW0B1SE= -github.com/attestantio/go-eth2-client v0.15.1/go.mod h1:5kLLzdlyPGboWr8tAwnG/4Kpi43BHd/HWp++WmmP6Ws= +github.com/attestantio/go-eth2-client v0.15.2 h1:4EYeA5IBSBypkUMhkkFALzMddaFDdb5PvCl7ORXEl6w= +github.com/attestantio/go-eth2-client v0.15.2/go.mod h1:/Oh6YTuHmHhgLN/ZnQRKHGc7HdIzGlDkI2vjNZvOsvA= github.com/aws/aws-sdk-go v1.40.41/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.44.152 h1:L9aaepO8wHB67gwuGD8VgIYH/cmQDxieCt7FeLa0+fI= github.com/aws/aws-sdk-go v1.44.152/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= @@ -162,8 +162,9 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/goccy/go-yaml v1.9.2 h1:2Njwzw+0+pjU2gb805ZC1B/uBuAs2VcZ3K+ZgHwDs7w= github.com/goccy/go-yaml v1.9.2/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/goccy/go-yaml v1.9.5 h1:Eh/+3uk9kLxG4koCX6lRMAPS1OaMSAi+FJcya0INdB0= +github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -438,8 +439,9 @@ github.com/prysmaticlabs/go-ssz v0.0.0-20210121151755-f6208871c388 h1:4bD+ujqGfY github.com/prysmaticlabs/go-ssz v0.0.0-20210121151755-f6208871c388/go.mod h1:VecIJZrewdAuhVckySLFt2wAAHRME934bSDurP8ftkc= github.com/prysmaticlabs/gohashtree v0.0.1-alpha.0.20220714111606-acbb2962fb48 h1:cSo6/vk8YpvkLbk9v3FO97cakNmUoxwi2KMP8hd5WIw= github.com/prysmaticlabs/gohashtree v0.0.1-alpha.0.20220714111606-acbb2962fb48/go.mod h1:4pWaT30XoEx1j8KNJf3TV+E3mQkaufn7mf+jRNb/Fuk= -github.com/r3labs/sse/v2 v2.7.4 h1:pvCMswPDlXd/ZUFx1dry0LbXJNHXwWPulLcUGYwClc0= github.com/r3labs/sse/v2 v2.7.4/go.mod h1:hUrYMKfu9WquG9MyI0r6TKiNH+6Sw/QPKm2YbNbU5g8= +github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o= +github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= @@ -498,6 +500,8 @@ github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNG github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/wealdtech/chaind v0.6.17 h1:HBlmzKj9Egy9rnZKGGIwvM6mUHJ+64163hNhSwjg/FQ= +github.com/wealdtech/chaind v0.6.17/go.mod h1:g8XOXrrRtwjD6mlpn9TydRPJD+gy4iFZMlPkQrBxxQA= github.com/wealdtech/eth2-signer-api v1.7.1 h1:XdwFuv3VWCwcPPPrfa77sUXL1GSvxDtsUZxlByz//b0= github.com/wealdtech/eth2-signer-api v1.7.1/go.mod h1:fX8XtN9Svyjs+e7TgoOfOcwRTHeblR5SXftAVV3T1ZA= github.com/wealdtech/go-bytesutil v1.0.1/go.mod h1:jENeMqeTEU8FNZyDFRVc7KqBdRKSnJ9CCh26TcuNb9s= diff --git a/services/chaintime/standard/parameters.go b/services/chaintime/standard/parameters.go index 3459b36..6118f30 100644 --- a/services/chaintime/standard/parameters.go +++ b/services/chaintime/standard/parameters.go @@ -20,10 +20,9 @@ import ( ) type parameters struct { - logLevel zerolog.Level - genesisTimeProvider eth2client.GenesisTimeProvider - specProvider eth2client.SpecProvider - forkScheduleProvider eth2client.ForkScheduleProvider + logLevel zerolog.Level + genesisTimeProvider eth2client.GenesisTimeProvider + specProvider eth2client.SpecProvider } // Parameter is the interface for service parameters. @@ -58,13 +57,6 @@ func WithSpecProvider(provider eth2client.SpecProvider) Parameter { }) } -// WithForkScheduleProvider sets the fork schedule provider. -func WithForkScheduleProvider(provider eth2client.ForkScheduleProvider) Parameter { - return parameterFunc(func(p *parameters) { - p.forkScheduleProvider = provider - }) -} - // parseAndCheckParameters parses and checks parameters to ensure that mandatory parameters are present and correct. func parseAndCheckParameters(params ...Parameter) (*parameters, error) { parameters := parameters{ @@ -82,9 +74,6 @@ func parseAndCheckParameters(params ...Parameter) (*parameters, error) { if parameters.genesisTimeProvider == nil { return nil, errors.New("no genesis time provider specified") } - if parameters.forkScheduleProvider == nil { - return nil, errors.New("no fork schedule provider specified") - } return ¶meters, nil } diff --git a/services/chaintime/standard/service.go b/services/chaintime/standard/service.go index 2ab73a0..883d644 100644 --- a/services/chaintime/standard/service.go +++ b/services/chaintime/standard/service.go @@ -14,7 +14,6 @@ package standard import ( - "bytes" "context" "time" @@ -87,21 +86,21 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { epochsPerSyncCommitteePeriod = tmp2 } - altairForkEpoch, err := fetchAltairForkEpoch(ctx, parameters.forkScheduleProvider) + altairForkEpoch, err := fetchAltairForkEpoch(ctx, parameters.specProvider) if err != nil { // Set to far future epoch. altairForkEpoch = 0xffffffffffffffff } log.Trace().Uint64("epoch", uint64(altairForkEpoch)).Msg("Obtained Altair fork epoch") - bellatrixForkEpoch, err := fetchBellatrixForkEpoch(ctx, parameters.forkScheduleProvider) + bellatrixForkEpoch, err := fetchBellatrixForkEpoch(ctx, parameters.specProvider) if err != nil { // Set to far future epoch. bellatrixForkEpoch = 0xffffffffffffffff } log.Trace().Uint64("epoch", uint64(bellatrixForkEpoch)).Msg("Obtained Bellatrix fork epoch") - capellaForkEpoch, err := fetchCapellaForkEpoch(ctx, parameters.forkScheduleProvider) + capellaForkEpoch, err := fetchCapellaForkEpoch(ctx, parameters.specProvider) if err != nil { // Set to far future epoch. capellaForkEpoch = 0xffffffffffffffff @@ -217,19 +216,28 @@ func (s *Service) AltairInitialSyncCommitteePeriod() uint64 { return uint64(s.altairForkEpoch) / s.epochsPerSyncCommitteePeriod } -func fetchAltairForkEpoch(ctx context.Context, provider eth2client.ForkScheduleProvider) (phase0.Epoch, error) { - forkSchedule, err := provider.ForkSchedule(ctx) +func fetchAltairForkEpoch(ctx context.Context, + specProvider eth2client.SpecProvider, +) ( + phase0.Epoch, + error, +) { + // Fetch the fork version. + spec, err := specProvider.Spec(ctx) if err != nil { - return 0, err + return 0, errors.Wrap(err, "failed to obtain spec") } - for i := range forkSchedule { - if bytes.Equal(forkSchedule[i].CurrentVersion[:], forkSchedule[i].PreviousVersion[:]) { - // This is the genesis fork; ignore it. - continue - } - return forkSchedule[i].Epoch, nil + tmp, exists := spec["ALTAIR_FORK_EPOCH"] + if !exists { + return 0, errors.New("altair fork version not known by chain") } - return 0, errors.New("no altair fork obtained") + epoch, isEpoch := tmp.(uint64) + if !isEpoch { + //nolint:revive + return 0, errors.New("ALTAIR_FORK_EPOCH is not a uint64!") + } + + return phase0.Epoch(epoch), nil } // BellatrixInitialEpoch provides the epoch at which the Bellatrix hard fork takes place. @@ -237,24 +245,28 @@ func (s *Service) BellatrixInitialEpoch() phase0.Epoch { return s.bellatrixForkEpoch } -func fetchBellatrixForkEpoch(ctx context.Context, provider eth2client.ForkScheduleProvider) (phase0.Epoch, error) { - forkSchedule, err := provider.ForkSchedule(ctx) +func fetchBellatrixForkEpoch(ctx context.Context, + specProvider eth2client.SpecProvider, +) ( + phase0.Epoch, + error, +) { + // Fetch the fork version. + spec, err := specProvider.Spec(ctx) if err != nil { - return 0, err + return 0, errors.Wrap(err, "failed to obtain spec") } - count := 0 - for i := range forkSchedule { - count++ - if bytes.Equal(forkSchedule[i].CurrentVersion[:], forkSchedule[i].PreviousVersion[:]) { - // This is the genesis fork; ignore it. - continue - } - if count == 1 { - return forkSchedule[i].Epoch, nil - } - count++ + tmp, exists := spec["BELLATRIX_FORK_EPOCH"] + if !exists { + return 0, errors.New("bellatrix fork version not known by chain") } - return 0, errors.New("no bellatrix fork obtained") + epoch, isEpoch := tmp.(uint64) + if !isEpoch { + //nolint:revive + return 0, errors.New("BELLATRIX_FORK_EPOCH is not a uint64!") + } + + return phase0.Epoch(epoch), nil } // CapellaInitialEpoch provides the epoch at which the Capella hard fork takes place. @@ -262,22 +274,26 @@ func (s *Service) CapellaInitialEpoch() phase0.Epoch { return s.capellaForkEpoch } -func fetchCapellaForkEpoch(ctx context.Context, provider eth2client.ForkScheduleProvider) (phase0.Epoch, error) { - forkSchedule, err := provider.ForkSchedule(ctx) +func fetchCapellaForkEpoch(ctx context.Context, + specProvider eth2client.SpecProvider, +) ( + phase0.Epoch, + error, +) { + // Fetch the fork version. + spec, err := specProvider.Spec(ctx) if err != nil { - return 0, err + return 0, errors.Wrap(err, "failed to obtain spec") } - count := 0 - for i := range forkSchedule { - count++ - if bytes.Equal(forkSchedule[i].CurrentVersion[:], forkSchedule[i].PreviousVersion[:]) { - // This is the genesis fork; ignore it. - continue - } - if count == 2 { - return forkSchedule[i].Epoch, nil - } - count++ + tmp, exists := spec["CAPELLAELLATRIX_FORK_EPOCH"] + if !exists { + return 0, errors.New("capella fork version not known by chain") } - return 0, errors.New("no capella fork obtained") + epoch, isEpoch := tmp.(uint64) + if !isEpoch { + //nolint:revive + return 0, errors.New("CAPELLAELLATRIX_FORK_EPOCH is not a uint64!") + } + + return phase0.Epoch(epoch), nil } diff --git a/services/chaintime/standard/service_test.go b/services/chaintime/standard/service_test.go index 3d576be..30e37f2 100644 --- a/services/chaintime/standard/service_test.go +++ b/services/chaintime/standard/service_test.go @@ -31,22 +31,9 @@ func TestService(t *testing.T) { slotDuration := 12 * time.Second slotsPerEpoch := uint64(32) epochsPerSyncCommitteePeriod := uint64(256) - forkSchedule := []*phase0.Fork{ - { - PreviousVersion: phase0.Version{0x01, 0x02, 0x03, 0x04}, - CurrentVersion: phase0.Version{0x01, 0x02, 0x03, 0x04}, - Epoch: 0, - }, - { - PreviousVersion: phase0.Version{0x01, 0x02, 0x03, 0x04}, - CurrentVersion: phase0.Version{0x05, 0x06, 0x07, 0x08}, - Epoch: 10, - }, - } mockGenesisTimeProvider := mock.NewGenesisTimeProvider(genesisTime) mockSpecProvider := mock.NewSpecProvider(slotDuration, slotsPerEpoch, epochsPerSyncCommitteePeriod) - mockForkScheduleProvider := mock.NewForkScheduleProvider(forkSchedule) tests := []struct { name string @@ -58,7 +45,6 @@ func TestService(t *testing.T) { params: []standard.Parameter{ standard.WithLogLevel(zerolog.Disabled), standard.WithSpecProvider(mockSpecProvider), - standard.WithForkScheduleProvider(mockForkScheduleProvider), }, err: "problem with parameters: no genesis time provider specified", }, @@ -67,26 +53,15 @@ func TestService(t *testing.T) { params: []standard.Parameter{ standard.WithLogLevel(zerolog.Disabled), standard.WithGenesisTimeProvider(mockGenesisTimeProvider), - standard.WithForkScheduleProvider(mockForkScheduleProvider), }, err: "problem with parameters: no spec provider specified", }, - { - name: "ForkScheduleProviderMissing", - params: []standard.Parameter{ - standard.WithLogLevel(zerolog.Disabled), - standard.WithGenesisTimeProvider(mockGenesisTimeProvider), - standard.WithSpecProvider(mockSpecProvider), - }, - err: "problem with parameters: no fork schedule provider specified", - }, { name: "Good", params: []standard.Parameter{ standard.WithLogLevel(zerolog.Disabled), standard.WithGenesisTimeProvider(mockGenesisTimeProvider), standard.WithSpecProvider(mockSpecProvider), - standard.WithForkScheduleProvider(mockForkScheduleProvider), }, }, } @@ -123,11 +98,9 @@ func createService(genesisTime time.Time) (chaintime.Service, time.Duration, uin mockGenesisTimeProvider := mock.NewGenesisTimeProvider(genesisTime) mockSpecProvider := mock.NewSpecProvider(slotDuration, slotsPerEpoch, epochsPerSyncCommitteePeriod) - mockForkScheduleProvider := mock.NewForkScheduleProvider(forkSchedule) s, err := standard.New(context.Background(), standard.WithGenesisTimeProvider(mockGenesisTimeProvider), standard.WithSpecProvider(mockSpecProvider), - standard.WithForkScheduleProvider(mockForkScheduleProvider), ) return s, slotDuration, slotsPerEpoch, epochsPerSyncCommitteePeriod, forkSchedule, err } diff --git a/util/account.go b/util/account.go index 07a7c48..6b84e0b 100644 --- a/util/account.go +++ b/util/account.go @@ -61,7 +61,7 @@ func ParseAccount(ctx context.Context, // Private key. account, err = newScratchAccountFromPrivKey(data) if err != nil { - return nil, errors.Wrap(err, "failed to create account from public key") + return nil, errors.Wrap(err, "failed to create account from private key") } if unlock { _, err = UnlockAccount(ctx, account, nil) diff --git a/util/epoch.go b/util/epoch.go index e2ccb1a..18880b7 100644 --- a/util/epoch.go +++ b/util/epoch.go @@ -24,11 +24,15 @@ import ( // ParseEpoch parses input to calculate the desired epoch. func ParseEpoch(ctx context.Context, chainTime chaintime.Service, epochStr string) (phase0.Epoch, error) { + currentEpoch := chainTime.CurrentEpoch() switch epochStr { - case "", "current": - return chainTime.CurrentEpoch(), nil + case "", "current", "-0": + return currentEpoch, nil case "last": - return chainTime.CurrentEpoch() - 1, nil + if currentEpoch > 0 { + currentEpoch-- + } + return currentEpoch, nil default: val, err := strconv.ParseInt(epochStr, 10, 64) if err != nil { @@ -37,6 +41,9 @@ func ParseEpoch(ctx context.Context, chainTime chaintime.Service, epochStr strin if val >= 0 { return phase0.Epoch(val), nil } - return chainTime.CurrentEpoch() + phase0.Epoch(val), nil + if phase0.Epoch(-val) > currentEpoch { + return 0, nil + } + return currentEpoch + phase0.Epoch(val), nil } } diff --git a/util/epoch_test.go b/util/epoch_test.go new file mode 100644 index 0000000..73990c4 --- /dev/null +++ b/util/epoch_test.go @@ -0,0 +1,105 @@ +// Copyright © 2032 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_test + +import ( + "context" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard" + "github.com/wealdtech/ethdo/testing/mock" + "github.com/wealdtech/ethdo/util" +) + +func TestParseEpoch(t *testing.T) { + ctx := context.Background() + + // genesis is 1 day ago. + genesisTime := time.Now().AddDate(0, 0, -1) + slotDuration := 12 * time.Second + slotsPerEpoch := uint64(32) + epochsPerSyncCommitteePeriod := uint64(256) + mockGenesisTimeProvider := mock.NewGenesisTimeProvider(genesisTime) + mockSpecProvider := mock.NewSpecProvider(slotDuration, slotsPerEpoch, epochsPerSyncCommitteePeriod) + chainTime, err := standardchaintime.New(context.Background(), + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisTimeProvider(mockGenesisTimeProvider), + standardchaintime.WithSpecProvider(mockSpecProvider), + ) + require.NoError(t, err) + + tests := []struct { + name string + input string + err string + expected phase0.Epoch + }{ + { + name: "Genesis", + input: "0", + expected: 0, + }, + { + name: "Invalid", + input: "invalid", + err: `failed to parse epoch: strconv.ParseInt: parsing "invalid": invalid syntax`, + }, + { + name: "Absolute", + input: "15", + expected: 15, + }, + { + name: "Current", + input: "current", + expected: 225, + }, + { + name: "Last", + input: "last", + expected: 224, + }, + { + name: "RelativeZero", + input: "-0", + expected: 225, + }, + { + name: "Relative", + input: "-5", + expected: 220, + }, + { + name: "RelativeFar", + input: "-500", + expected: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + epoch, err := util.ParseEpoch(ctx, chainTime, test.input) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, epoch) + } + }) + } +}