From e99de7726ded50437189be019acba6a60f6f8406 Mon Sep 17 00:00:00 2001 From: Mike Neuder Date: Wed, 24 Aug 2022 12:57:03 -0400 Subject: [PATCH] Wallet recover CLI Manager migration (#11278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Wallet recover CLI Manager migration * bazel run //:gazelle -- fix * fix lint and build errors * add TODO to remove duplicate code Co-authored-by: james-prysm <90280386+james-prysm@users.noreply.github.com> Co-authored-by: Radosław Kapka --- cmd/validator/wallet/BUILD.bazel | 11 +- cmd/validator/wallet/recover.go | 175 ++++++++++++++++++++ cmd/validator/wallet/recover_test.go | 113 +++++++++++++ cmd/validator/wallet/wallet.go | 10 +- validator/accounts/BUILD.bazel | 2 - validator/accounts/cli_manager.go | 7 +- validator/accounts/cli_options.go | 40 +++++ validator/accounts/wallet_create.go | 33 ++++ validator/accounts/wallet_recover.go | 187 +--------------------- validator/accounts/wallet_recover_test.go | 101 ------------ validator/rpc/wallet.go | 19 ++- 11 files changed, 401 insertions(+), 297 deletions(-) create mode 100644 cmd/validator/wallet/recover.go create mode 100644 cmd/validator/wallet/recover_test.go diff --git a/cmd/validator/wallet/BUILD.bazel b/cmd/validator/wallet/BUILD.bazel index 1d60af021a..ee70f765a2 100644 --- a/cmd/validator/wallet/BUILD.bazel +++ b/cmd/validator/wallet/BUILD.bazel @@ -4,6 +4,7 @@ go_library( name = "go_default_library", srcs = [ "edit.go", + "recover.go", "wallet.go", ], importpath = "github.com/prysmaticlabs/prysm/v3/cmd/validator/wallet", @@ -12,6 +13,7 @@ go_library( "//cmd:go_default_library", "//cmd/validator/flags:go_default_library", "//config/features:go_default_library", + "//io/prompt:go_default_library", "//runtime/tos:go_default_library", "//validator/accounts:go_default_library", "//validator/accounts/userprompt:go_default_library", @@ -20,13 +22,18 @@ go_library( "//validator/keymanager/remote:go_default_library", "@com_github_pkg_errors//:go_default_library", "@com_github_sirupsen_logrus//:go_default_library", + "@com_github_tyler_smith_go_bip39//:go_default_library", + "@com_github_tyler_smith_go_bip39//wordlists:go_default_library", "@com_github_urfave_cli_v2//:go_default_library", ], ) go_test( name = "go_default_test", - srcs = ["edit_test.go"], + srcs = [ + "edit_test.go", + "recover_test.go", + ], embed = [":go_default_library"], deps = [ "//cmd/validator/flags:go_default_library", @@ -34,8 +41,10 @@ go_test( "//testing/assert:go_default_library", "//testing/require:go_default_library", "//validator/accounts:go_default_library", + "//validator/accounts/iface:go_default_library", "//validator/accounts/wallet:go_default_library", "//validator/keymanager:go_default_library", + "//validator/keymanager/derived:go_default_library", "//validator/keymanager/remote:go_default_library", "@com_github_urfave_cli_v2//:go_default_library", ], diff --git a/cmd/validator/wallet/recover.go b/cmd/validator/wallet/recover.go new file mode 100644 index 0000000000..7f6bbeedf3 --- /dev/null +++ b/cmd/validator/wallet/recover.go @@ -0,0 +1,175 @@ +package wallet + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/prysmaticlabs/prysm/v3/cmd/validator/flags" + "github.com/prysmaticlabs/prysm/v3/io/prompt" + "github.com/prysmaticlabs/prysm/v3/validator/accounts" + "github.com/prysmaticlabs/prysm/v3/validator/accounts/userprompt" + "github.com/prysmaticlabs/prysm/v3/validator/accounts/wallet" + "github.com/tyler-smith/go-bip39" + "github.com/tyler-smith/go-bip39/wordlists" + "github.com/urfave/cli/v2" +) + +const ( + // #nosec G101 -- Not sensitive data + mnemonicPassphraseYesNoText = "(Advanced) Do you have an optional '25th word' passphrase for your mnemonic? [y/n]" + // #nosec G101 -- Not sensitive data + mnemonicPassphrasePromptText = "(Advanced) Enter the '25th word' passphrase for your mnemonic" +) + +func walletRecover(c *cli.Context) error { + mnemonic, err := inputMnemonic(c) + if err != nil { + return errors.Wrap(err, "could not get mnemonic phrase") + } + opts := []accounts.Option{ + accounts.WithMnemonic(mnemonic), + } + + skipMnemonic25thWord := c.IsSet(flags.SkipMnemonic25thWordCheckFlag.Name) + has25thWordFile := c.IsSet(flags.Mnemonic25thWordFileFlag.Name) + if !skipMnemonic25thWord && !has25thWordFile { + resp, err := prompt.ValidatePrompt( + os.Stdin, mnemonicPassphraseYesNoText, prompt.ValidateYesOrNo, + ) + if err != nil { + return errors.Wrap(err, "could not validate choice") + } + if strings.EqualFold(resp, "y") { + mnemonicPassphrase, err := prompt.InputPassword( + c, + flags.Mnemonic25thWordFileFlag, + mnemonicPassphrasePromptText, + "Confirm mnemonic passphrase", + false, /* Should confirm password */ + func(input string) error { + if strings.TrimSpace(input) == "" { + return errors.New("input cannot be empty") + } + return nil + }, + ) + if err != nil { + return err + } + opts = append(opts, accounts.WithMnemonic25thWord(mnemonicPassphrase)) + } + } + walletDir, err := userprompt.InputDirectory(c, userprompt.WalletDirPromptText, flags.WalletDirFlag) + if err != nil { + return err + } + walletPassword, err := prompt.InputPassword( + c, + flags.WalletPasswordFileFlag, + wallet.NewWalletPasswordPromptText, + wallet.ConfirmPasswordPromptText, + true, /* Should confirm password */ + prompt.ValidatePasswordInput, + ) + if err != nil { + return err + } + numAccounts, err := inputNumAccounts(c) + if err != nil { + return errors.Wrap(err, "could not get number of accounts to recover") + } + opts = append(opts, accounts.WithWalletDir(walletDir)) + opts = append(opts, accounts.WithWalletPassword(walletPassword)) + opts = append(opts, accounts.WithNumAccounts(int(numAccounts))) + + acc, err := accounts.NewCLIManager(opts...) + if err != nil { + return err + } + if _, err = acc.WalletRecover(c.Context); err != nil { + return err + } + log.Infof( + "Successfully recovered HD wallet with accounts and saved configuration to disk", + ) + return nil +} + +func inputMnemonic(cliCtx *cli.Context) (mnemonicPhrase string, err error) { + if cliCtx.IsSet(flags.MnemonicFileFlag.Name) { + mnemonicFilePath := cliCtx.String(flags.MnemonicFileFlag.Name) + data, err := os.ReadFile(mnemonicFilePath) // #nosec G304 -- ReadFile is safe + if err != nil { + return "", err + } + enteredMnemonic := string(data) + if err := accounts.ValidateMnemonic(enteredMnemonic); err != nil { + return "", errors.Wrap(err, "mnemonic phrase did not pass validation") + } + return enteredMnemonic, nil + } + allowedLanguages := map[string][]string{ + "chinese_simplified": wordlists.ChineseSimplified, + "chinese_traditional": wordlists.ChineseTraditional, + "czech": wordlists.Czech, + "english": wordlists.English, + "french": wordlists.French, + "japanese": wordlists.Japanese, + "korean": wordlists.Korean, + "italian": wordlists.Italian, + "spanish": wordlists.Spanish, + } + languages := make([]string, 0) + for k := range allowedLanguages { + languages = append(languages, k) + } + sort.Strings(languages) + selectedLanguage, err := prompt.ValidatePrompt( + os.Stdin, + fmt.Sprintf("Enter the language of your seed phrase: %s", strings.Join(languages, ", ")), + func(input string) error { + if _, ok := allowedLanguages[input]; !ok { + return errors.New("input not in the list of allowed languages") + } + return nil + }, + ) + if err != nil { + return "", fmt.Errorf("could not get mnemonic language: %w", err) + } + bip39.SetWordList(allowedLanguages[selectedLanguage]) + mnemonicPhrase, err = prompt.ValidatePrompt( + os.Stdin, + "Enter the seed phrase for the wallet you would like to recover", + accounts.ValidateMnemonic) + if err != nil { + return "", fmt.Errorf("could not get mnemonic phrase: %w", err) + } + return mnemonicPhrase, nil +} + +func inputNumAccounts(cliCtx *cli.Context) (int64, error) { + if cliCtx.IsSet(flags.NumAccountsFlag.Name) { + numAccounts := cliCtx.Int64(flags.NumAccountsFlag.Name) + if numAccounts <= 0 { + return 0, errors.New("must recover at least 1 account") + } + return numAccounts, nil + } + numAccounts, err := prompt.ValidatePrompt(os.Stdin, "Enter how many accounts you would like to generate from the mnemonic", prompt.ValidateNumber) + if err != nil { + return 0, err + } + numAccountsInt, err := strconv.Atoi(numAccounts) + if err != nil { + return 0, err + } + if numAccountsInt <= 0 { + return 0, errors.New("must recover at least 1 account") + } + return int64(numAccountsInt), nil +} diff --git a/cmd/validator/wallet/recover_test.go b/cmd/validator/wallet/recover_test.go new file mode 100644 index 0000000000..03b2f6294e --- /dev/null +++ b/cmd/validator/wallet/recover_test.go @@ -0,0 +1,113 @@ +package wallet + +import ( + "context" + "flag" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/prysmaticlabs/prysm/v3/cmd/validator/flags" + "github.com/prysmaticlabs/prysm/v3/testing/assert" + "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/accounts/iface" + "github.com/prysmaticlabs/prysm/v3/validator/accounts/wallet" + "github.com/prysmaticlabs/prysm/v3/validator/keymanager" + "github.com/prysmaticlabs/prysm/v3/validator/keymanager/derived" + "github.com/urfave/cli/v2" +) + +const ( + walletDirName = "wallet" + mnemonicFileName = "mnemonic.txt" + mnemonic = "garage car helmet trade salmon embrace market giant movie wet same champion dawn chair shield drill amazing panther accident puzzle garden mosquito kind arena" +) + +type recoverCfgStruct struct { + walletDir string + passwordFilePath string + mnemonicFilePath string + numAccounts int64 +} + +func setupRecoverCfg(t *testing.T) *recoverCfgStruct { + testDir := t.TempDir() + walletDir := filepath.Join(testDir, walletDirName) + passwordFilePath := filepath.Join(testDir, passwordFileName) + require.NoError(t, os.WriteFile(passwordFilePath, []byte(password), os.ModePerm)) + mnemonicFilePath := filepath.Join(testDir, mnemonicFileName) + require.NoError(t, os.WriteFile(mnemonicFilePath, []byte(mnemonic), os.ModePerm)) + + return &recoverCfgStruct{ + walletDir: walletDir, + passwordFilePath: passwordFilePath, + mnemonicFilePath: mnemonicFilePath, + } +} + +func createRecoverCliCtx(t *testing.T, cfg *recoverCfgStruct) *cli.Context { + app := cli.App{} + set := flag.NewFlagSet("test", 0) + set.String(flags.WalletDirFlag.Name, cfg.walletDir, "") + set.String(flags.WalletPasswordFileFlag.Name, cfg.passwordFilePath, "") + set.String(flags.KeymanagerKindFlag.Name, keymanager.Derived.String(), "") + set.String(flags.MnemonicFileFlag.Name, cfg.mnemonicFilePath, "") + set.Bool(flags.SkipMnemonic25thWordCheckFlag.Name, true, "") + set.Int64(flags.NumAccountsFlag.Name, cfg.numAccounts, "") + assert.NoError(t, set.Set(flags.SkipMnemonic25thWordCheckFlag.Name, "true")) + assert.NoError(t, set.Set(flags.WalletDirFlag.Name, cfg.walletDir)) + assert.NoError(t, set.Set(flags.WalletPasswordFileFlag.Name, cfg.passwordFilePath)) + assert.NoError(t, set.Set(flags.KeymanagerKindFlag.Name, keymanager.Derived.String())) + assert.NoError(t, set.Set(flags.MnemonicFileFlag.Name, cfg.mnemonicFilePath)) + assert.NoError(t, set.Set(flags.NumAccountsFlag.Name, strconv.Itoa(int(cfg.numAccounts)))) + return cli.NewContext(&app, set, nil) +} + +func TestRecoverDerivedWallet(t *testing.T) { + cfg := setupRecoverCfg(t) + cfg.numAccounts = 4 + cliCtx := createRecoverCliCtx(t, cfg) + require.NoError(t, walletRecover(cliCtx)) + + ctx := context.Background() + w, err := wallet.OpenWallet(cliCtx.Context, &wallet.Config{ + WalletDir: cfg.walletDir, + WalletPassword: password, + }) + assert.NoError(t, err) + + km, err := w.InitializeKeymanager(cliCtx.Context, iface.InitKeymanagerConfig{ListenForChanges: false}) + require.NoError(t, err) + derivedKM, ok := km.(*derived.Keymanager) + if !ok { + t.Fatal("not a derived keymanager") + } + names, err := derivedKM.ValidatingAccountNames(ctx) + assert.NoError(t, err) + require.Equal(t, len(names), int(cfg.numAccounts)) +} + +// TestRecoverDerivedWallet_OneAccount is a test for regression in cases where the number of accounts recovered is 1 +func TestRecoverDerivedWallet_OneAccount(t *testing.T) { + cfg := setupRecoverCfg(t) + cfg.numAccounts = 1 + cliCtx := createRecoverCliCtx(t, cfg) + require.NoError(t, walletRecover(cliCtx)) + + _, err := wallet.OpenWallet(cliCtx.Context, &wallet.Config{ + WalletDir: cfg.walletDir, + WalletPassword: password, + }) + assert.NoError(t, err) +} + +func TestRecoverDerivedWallet_AlreadyExists(t *testing.T) { + cfg := setupRecoverCfg(t) + cfg.numAccounts = 4 + cliCtx := createRecoverCliCtx(t, cfg) + require.NoError(t, walletRecover(cliCtx)) + + // Trying to recover an HD wallet into a directory that already exists should give an error + require.ErrorContains(t, "a wallet already exists at this location", walletRecover(cliCtx)) +} diff --git a/cmd/validator/wallet/wallet.go b/cmd/validator/wallet/wallet.go index cf834b7d94..5122190b15 100644 --- a/cmd/validator/wallet/wallet.go +++ b/cmd/validator/wallet/wallet.go @@ -108,13 +108,13 @@ var Commands = &cli.Command{ if err := cmd.LoadFlagsFromConfig(cliCtx, cliCtx.Command.Flags); err != nil { return err } - return tos.VerifyTosAcceptedOrPrompt(cliCtx) - }, - Action: func(cliCtx *cli.Context) error { - if err := features.ConfigureValidator(cliCtx); err != nil { + if err := tos.VerifyTosAcceptedOrPrompt(cliCtx); err != nil { return err } - if err := accounts.RecoverWalletCli(cliCtx); err != nil { + return features.ConfigureBeaconChain(cliCtx) + }, + Action: func(cliCtx *cli.Context) error { + if err := walletRecover(cliCtx); err != nil { log.WithError(err).Fatal("Could not recover wallet") } return nil diff --git a/validator/accounts/BUILD.bazel b/validator/accounts/BUILD.bazel index 7e227ae4b3..c80f98d667 100644 --- a/validator/accounts/BUILD.bazel +++ b/validator/accounts/BUILD.bazel @@ -52,8 +52,6 @@ go_library( "@com_github_manifoldco_promptui//:go_default_library", "@com_github_pkg_errors//:go_default_library", "@com_github_sirupsen_logrus//:go_default_library", - "@com_github_tyler_smith_go_bip39//:go_default_library", - "@com_github_tyler_smith_go_bip39//wordlists:go_default_library", "@com_github_urfave_cli_v2//:go_default_library", "@com_github_wealdtech_go_eth2_wallet_encryptor_keystorev4//:go_default_library", "@org_golang_google_grpc//:go_default_library", diff --git a/validator/accounts/cli_manager.go b/validator/accounts/cli_manager.go index 321687de38..990a09e12f 100644 --- a/validator/accounts/cli_manager.go +++ b/validator/accounts/cli_manager.go @@ -25,7 +25,7 @@ func NewCLIManager(opts ...Option) (*AccountsCLIManager, error) { } // AccountsCLIManager defines a struct capable of performing various validator -// wallet account operations via the command line. +// wallet & account operations via the command line. type AccountsCLIManager struct { wallet *wallet.Wallet keymanager keymanager.IKeymanager @@ -48,6 +48,11 @@ type AccountsCLIManager struct { filteredPubKeys []bls.PublicKey rawPubKeys [][]byte formattedPubKeys []string + walletDir string + walletPassword string + mnemonic string + numAccounts int + mnemonic25thWord string } func (acm *AccountsCLIManager) prepareBeaconClients(ctx context.Context) (*ethpb.BeaconNodeValidatorClient, *ethpb.NodeClient, error) { diff --git a/validator/accounts/cli_options.go b/validator/accounts/cli_options.go index 1c8ee55727..15ccef9ba8 100644 --- a/validator/accounts/cli_options.go +++ b/validator/accounts/cli_options.go @@ -178,3 +178,43 @@ func WithFormattedPubKeys(formattedPubKeys []string) Option { return nil } } + +// WithWalletDir specifies the password for backups. +func WithWalletDir(walletDir string) Option { + return func(acc *AccountsCLIManager) error { + acc.walletDir = walletDir + return nil + } +} + +// WithWalletPassword specifies the password for backups. +func WithWalletPassword(walletPassword string) Option { + return func(acc *AccountsCLIManager) error { + acc.walletPassword = walletPassword + return nil + } +} + +// WithMnemonic specifies the password for backups. +func WithMnemonic(mnemonic string) Option { + return func(acc *AccountsCLIManager) error { + acc.mnemonic = mnemonic + return nil + } +} + +// WithMnemonic25thWord specifies the password for backups. +func WithMnemonic25thWord(mnemonic25thWord string) Option { + return func(acc *AccountsCLIManager) error { + acc.mnemonic25thWord = mnemonic25thWord + return nil + } +} + +// WithMnemonic25thWord specifies the password for backups. +func WithNumAccounts(numAccounts int) Option { + return func(acc *AccountsCLIManager) error { + acc.numAccounts = numAccounts + return nil + } +} diff --git a/validator/accounts/wallet_create.go b/validator/accounts/wallet_create.go index cc3c55e9db..62f366cd6d 100644 --- a/validator/accounts/wallet_create.go +++ b/validator/accounts/wallet_create.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "github.com/manifoldco/promptui" @@ -22,6 +23,14 @@ import ( "github.com/urfave/cli/v2" ) +const ( + // #nosec G101 -- Not sensitive data + newMnemonicPassphraseYesNoText = "(Advanced) Do you want to setup a '25th word' passphrase for your mnemonic? [y/n]" + // #nosec G101 -- Not sensitive data + newMnemonicPassphrasePromptText = "(Advanced) Setup a passphrase '25th word' for your mnemonic " + + "(WARNING: You cannot recover your keys from your mnemonic if you forget this passphrase!)" +) + // CreateWalletConfig defines the parameters needed to call the create wallet functions. type CreateWalletConfig struct { SkipMnemonicConfirm bool @@ -278,3 +287,27 @@ func inputKeymanagerKind(cliCtx *cli.Context) (keymanager.Kind, error) { } return keymanager.Kind(selection), nil } + +// TODO(mikeneuder): Remove duplicate function when migration wallet create +// to cmd/validator/wallet. +func inputNumAccounts(cliCtx *cli.Context) (int64, error) { + if cliCtx.IsSet(flags.NumAccountsFlag.Name) { + numAccounts := cliCtx.Int64(flags.NumAccountsFlag.Name) + if numAccounts <= 0 { + return 0, errors.New("must recover at least 1 account") + } + return numAccounts, nil + } + numAccounts, err := prompt.ValidatePrompt(os.Stdin, "Enter how many accounts you would like to generate from the mnemonic", prompt.ValidateNumber) + if err != nil { + return 0, err + } + numAccountsInt, err := strconv.Atoi(numAccounts) + if err != nil { + return 0, err + } + if numAccountsInt <= 0 { + return 0, errors.New("must recover at least 1 account") + } + return int64(numAccountsInt), nil +} diff --git a/validator/accounts/wallet_recover.go b/validator/accounts/wallet_recover.go index 22092ce7c9..c753910985 100644 --- a/validator/accounts/wallet_recover.go +++ b/validator/accounts/wallet_recover.go @@ -2,35 +2,16 @@ package accounts import ( "context" - "fmt" - "os" - "sort" - "strconv" "strings" "github.com/pkg/errors" - "github.com/prysmaticlabs/prysm/v3/cmd/validator/flags" - "github.com/prysmaticlabs/prysm/v3/io/prompt" - "github.com/prysmaticlabs/prysm/v3/validator/accounts/userprompt" "github.com/prysmaticlabs/prysm/v3/validator/accounts/wallet" "github.com/prysmaticlabs/prysm/v3/validator/keymanager" "github.com/prysmaticlabs/prysm/v3/validator/keymanager/derived" - "github.com/tyler-smith/go-bip39" - "github.com/tyler-smith/go-bip39/wordlists" - "github.com/urfave/cli/v2" ) const ( phraseWordCount = 24 - // #nosec G101 -- Not sensitive data - newMnemonicPassphraseYesNoText = "(Advanced) Do you want to setup a '25th word' passphrase for your mnemonic? [y/n]" - // #nosec G101 -- Not sensitive data - newMnemonicPassphrasePromptText = "(Advanced) Setup a passphrase '25th word' for your mnemonic " + - "(WARNING: You cannot recover your keys from your mnemonic if you forget this passphrase!)" - // #nosec G101 -- Not sensitive data - mnemonicPassphraseYesNoText = "(Advanced) Do you have an optional '25th word' passphrase for your mnemonic? [y/n]" - // #nosec G101 -- Not sensitive data - mnemonicPassphrasePromptText = "(Advanced) Enter the '25th word' passphrase for your mnemonic" ) var ( @@ -38,89 +19,10 @@ var ( ErrEmptyMnemonic = errors.New("phrase cannot be empty") ) -// RecoverWalletConfig to run the recover wallet function. -type RecoverWalletConfig struct { - WalletDir string - WalletPassword string - Mnemonic string - NumAccounts int - Mnemonic25thWord string -} - -// RecoverWalletCli uses a menmonic seed phrase to recover a wallet into the path provided. This -// uses the CLI to extract necessary values to run the function. -func RecoverWalletCli(cliCtx *cli.Context) error { - mnemonic, err := inputMnemonic(cliCtx) - if err != nil { - return errors.Wrap(err, "could not get mnemonic phrase") - } - config := &RecoverWalletConfig{ - Mnemonic: mnemonic, - } - skipMnemonic25thWord := cliCtx.IsSet(flags.SkipMnemonic25thWordCheckFlag.Name) - has25thWordFile := cliCtx.IsSet(flags.Mnemonic25thWordFileFlag.Name) - if !skipMnemonic25thWord && !has25thWordFile { - resp, err := prompt.ValidatePrompt( - os.Stdin, mnemonicPassphraseYesNoText, prompt.ValidateYesOrNo, - ) - if err != nil { - return errors.Wrap(err, "could not validate choice") - } - if strings.EqualFold(resp, "y") { - mnemonicPassphrase, err := prompt.InputPassword( - cliCtx, - flags.Mnemonic25thWordFileFlag, - mnemonicPassphrasePromptText, - "Confirm mnemonic passphrase", - false, /* Should confirm password */ - func(input string) error { - if strings.TrimSpace(input) == "" { - return errors.New("input cannot be empty") - } - return nil - }, - ) - if err != nil { - return err - } - config.Mnemonic25thWord = mnemonicPassphrase - } - } - walletDir, err := userprompt.InputDirectory(cliCtx, userprompt.WalletDirPromptText, flags.WalletDirFlag) - if err != nil { - return err - } - walletPassword, err := prompt.InputPassword( - cliCtx, - flags.WalletPasswordFileFlag, - wallet.NewWalletPasswordPromptText, - wallet.ConfirmPasswordPromptText, - true, /* Should confirm password */ - prompt.ValidatePasswordInput, - ) - if err != nil { - return err - } - numAccounts, err := inputNumAccounts(cliCtx) - if err != nil { - return errors.Wrap(err, "could not get number of accounts to recover") - } - config.WalletDir = walletDir - config.WalletPassword = walletPassword - config.NumAccounts = int(numAccounts) - if _, err = RecoverWallet(cliCtx.Context, config); err != nil { - return err - } - log.Infof( - "Successfully recovered HD wallet with accounts and saved configuration to disk", - ) - return nil -} - -// RecoverWallet uses a menmonic seed phrase to recover a wallet into the path provided. -func RecoverWallet(ctx context.Context, cfg *RecoverWalletConfig) (*wallet.Wallet, error) { +// WalletRecover uses a menmonic seed phrase to recover a wallet into the path provided. +func (acm *AccountsCLIManager) WalletRecover(ctx context.Context) (*wallet.Wallet, error) { // Ensure that the wallet directory does not contain a wallet already - dirExists, err := wallet.Exists(cfg.WalletDir) + dirExists, err := wallet.Exists(acm.walletDir) if err != nil { return nil, err } @@ -129,9 +31,9 @@ func RecoverWallet(ctx context.Context, cfg *RecoverWalletConfig) (*wallet.Walle " alternative location for the new wallet or remove the current wallet") } w := wallet.New(&wallet.Config{ - WalletDir: cfg.WalletDir, + WalletDir: acm.walletDir, KeymanagerKind: keymanager.Derived, - WalletPassword: cfg.WalletPassword, + WalletPassword: acm.walletPassword, }) if err := w.SaveWallet(); err != nil { return nil, errors.Wrap(err, "could not save wallet to disk") @@ -143,91 +45,16 @@ func RecoverWallet(ctx context.Context, cfg *RecoverWalletConfig) (*wallet.Walle if err != nil { return nil, errors.Wrap(err, "could not make keymanager for given phrase") } - if err := km.RecoverAccountsFromMnemonic(ctx, cfg.Mnemonic, cfg.Mnemonic25thWord, cfg.NumAccounts); err != nil { + if err := km.RecoverAccountsFromMnemonic(ctx, acm.mnemonic, acm.mnemonic25thWord, acm.numAccounts); err != nil { return nil, err } log.WithField("wallet-path", w.AccountsDir()).Infof( "Successfully recovered HD wallet with %d accounts. Please use `accounts list` to view details for your accounts", - cfg.NumAccounts, + acm.numAccounts, ) return w, nil } -func inputMnemonic(cliCtx *cli.Context) (mnemonicPhrase string, err error) { - if cliCtx.IsSet(flags.MnemonicFileFlag.Name) { - mnemonicFilePath := cliCtx.String(flags.MnemonicFileFlag.Name) - data, err := os.ReadFile(mnemonicFilePath) // #nosec G304 -- ReadFile is safe - if err != nil { - return "", err - } - enteredMnemonic := string(data) - if err := ValidateMnemonic(enteredMnemonic); err != nil { - return "", errors.Wrap(err, "mnemonic phrase did not pass validation") - } - return enteredMnemonic, nil - } - allowedLanguages := map[string][]string{ - "chinese_simplified": wordlists.ChineseSimplified, - "chinese_traditional": wordlists.ChineseTraditional, - "czech": wordlists.Czech, - "english": wordlists.English, - "french": wordlists.French, - "japanese": wordlists.Japanese, - "korean": wordlists.Korean, - "italian": wordlists.Italian, - "spanish": wordlists.Spanish, - } - languages := make([]string, 0) - for k := range allowedLanguages { - languages = append(languages, k) - } - sort.Strings(languages) - selectedLanguage, err := prompt.ValidatePrompt( - os.Stdin, - fmt.Sprintf("Enter the language of your seed phrase: %s", strings.Join(languages, ", ")), - func(input string) error { - if _, ok := allowedLanguages[input]; !ok { - return errors.New("input not in the list of allowed languages") - } - return nil - }, - ) - if err != nil { - return "", fmt.Errorf("could not get mnemonic language: %w", err) - } - bip39.SetWordList(allowedLanguages[selectedLanguage]) - mnemonicPhrase, err = prompt.ValidatePrompt( - os.Stdin, - "Enter the seed phrase for the wallet you would like to recover", - ValidateMnemonic) - if err != nil { - return "", fmt.Errorf("could not get mnemonic phrase: %w", err) - } - return mnemonicPhrase, nil -} - -func inputNumAccounts(cliCtx *cli.Context) (int64, error) { - if cliCtx.IsSet(flags.NumAccountsFlag.Name) { - numAccounts := cliCtx.Int64(flags.NumAccountsFlag.Name) - if numAccounts <= 0 { - return 0, errors.New("must recover at least 1 account") - } - return numAccounts, nil - } - numAccounts, err := prompt.ValidatePrompt(os.Stdin, "Enter how many accounts you would like to generate from the mnemonic", prompt.ValidateNumber) - if err != nil { - return 0, err - } - numAccountsInt, err := strconv.Atoi(numAccounts) - if err != nil { - return 0, err - } - if numAccountsInt <= 0 { - return 0, errors.New("must recover at least 1 account") - } - return int64(numAccountsInt), nil -} - // ValidateMnemonic ensures that it is not empty and that the count of the words are // as specified(currently 24). func ValidateMnemonic(mnemonic string) error { diff --git a/validator/accounts/wallet_recover_test.go b/validator/accounts/wallet_recover_test.go index 71e6968827..e9153c76fc 100644 --- a/validator/accounts/wallet_recover_test.go +++ b/validator/accounts/wallet_recover_test.go @@ -1,112 +1,11 @@ package accounts import ( - "context" - "flag" - "os" - "path/filepath" - "strconv" "testing" "github.com/pkg/errors" - "github.com/prysmaticlabs/prysm/v3/cmd/validator/flags" - "github.com/prysmaticlabs/prysm/v3/testing/assert" - "github.com/prysmaticlabs/prysm/v3/testing/require" - "github.com/prysmaticlabs/prysm/v3/validator/accounts/iface" - "github.com/prysmaticlabs/prysm/v3/validator/accounts/wallet" - "github.com/prysmaticlabs/prysm/v3/validator/keymanager" - "github.com/prysmaticlabs/prysm/v3/validator/keymanager/derived" - "github.com/urfave/cli/v2" ) -type recoverCfgStruct struct { - walletDir string - passwordFilePath string - mnemonicFilePath string - numAccounts int64 -} - -func setupRecoverCfg(t *testing.T) *recoverCfgStruct { - testDir := t.TempDir() - walletDir := filepath.Join(testDir, walletDirName) - passwordFilePath := filepath.Join(testDir, passwordFileName) - require.NoError(t, os.WriteFile(passwordFilePath, []byte(password), os.ModePerm)) - mnemonicFilePath := filepath.Join(testDir, mnemonicFileName) - require.NoError(t, os.WriteFile(mnemonicFilePath, []byte(mnemonic), os.ModePerm)) - - return &recoverCfgStruct{ - walletDir: walletDir, - passwordFilePath: passwordFilePath, - mnemonicFilePath: mnemonicFilePath, - } -} - -func createRecoverCliCtx(t *testing.T, cfg *recoverCfgStruct) *cli.Context { - app := cli.App{} - set := flag.NewFlagSet("test", 0) - set.String(flags.WalletDirFlag.Name, cfg.walletDir, "") - set.String(flags.WalletPasswordFileFlag.Name, cfg.passwordFilePath, "") - set.String(flags.KeymanagerKindFlag.Name, keymanager.Derived.String(), "") - set.String(flags.MnemonicFileFlag.Name, cfg.mnemonicFilePath, "") - set.Bool(flags.SkipMnemonic25thWordCheckFlag.Name, true, "") - set.Int64(flags.NumAccountsFlag.Name, cfg.numAccounts, "") - assert.NoError(t, set.Set(flags.SkipMnemonic25thWordCheckFlag.Name, "true")) - assert.NoError(t, set.Set(flags.WalletDirFlag.Name, cfg.walletDir)) - assert.NoError(t, set.Set(flags.WalletPasswordFileFlag.Name, cfg.passwordFilePath)) - assert.NoError(t, set.Set(flags.KeymanagerKindFlag.Name, keymanager.Derived.String())) - assert.NoError(t, set.Set(flags.MnemonicFileFlag.Name, cfg.mnemonicFilePath)) - assert.NoError(t, set.Set(flags.NumAccountsFlag.Name, strconv.Itoa(int(cfg.numAccounts)))) - return cli.NewContext(&app, set, nil) -} - -func TestRecoverDerivedWallet(t *testing.T) { - cfg := setupRecoverCfg(t) - cfg.numAccounts = 4 - cliCtx := createRecoverCliCtx(t, cfg) - require.NoError(t, RecoverWalletCli(cliCtx)) - - ctx := context.Background() - w, err := wallet.OpenWallet(cliCtx.Context, &wallet.Config{ - WalletDir: cfg.walletDir, - WalletPassword: password, - }) - assert.NoError(t, err) - - km, err := w.InitializeKeymanager(cliCtx.Context, iface.InitKeymanagerConfig{ListenForChanges: false}) - require.NoError(t, err) - derivedKM, ok := km.(*derived.Keymanager) - if !ok { - t.Fatal("not a derived keymanager") - } - names, err := derivedKM.ValidatingAccountNames(ctx) - assert.NoError(t, err) - require.Equal(t, len(names), int(cfg.numAccounts)) -} - -// TestRecoverDerivedWallet_OneAccount is a test for regression in cases where the number of accounts recovered is 1 -func TestRecoverDerivedWallet_OneAccount(t *testing.T) { - cfg := setupRecoverCfg(t) - cfg.numAccounts = 1 - cliCtx := createRecoverCliCtx(t, cfg) - require.NoError(t, RecoverWalletCli(cliCtx)) - - _, err := wallet.OpenWallet(cliCtx.Context, &wallet.Config{ - WalletDir: cfg.walletDir, - WalletPassword: password, - }) - assert.NoError(t, err) -} - -func TestRecoverDerivedWallet_AlreadyExists(t *testing.T) { - cfg := setupRecoverCfg(t) - cfg.numAccounts = 4 - cliCtx := createRecoverCliCtx(t, cfg) - require.NoError(t, RecoverWalletCli(cliCtx)) - - // Trying to recover an HD wallet into a directory that already exists should give an error - require.ErrorContains(t, "a wallet already exists at this location", RecoverWalletCli(cliCtx)) -} - func TestValidateMnemonic(t *testing.T) { tests := []struct { name string diff --git a/validator/rpc/wallet.go b/validator/rpc/wallet.go index 03d0e1da98..67e7c09564 100644 --- a/validator/rpc/wallet.go +++ b/validator/rpc/wallet.go @@ -187,13 +187,18 @@ func (s *Server) RecoverWallet(ctx context.Context, req *pb.RecoverWalletRequest return nil, status.Error(codes.InvalidArgument, "password did not pass validation") } - if _, err := accounts.RecoverWallet(ctx, &accounts.RecoverWalletConfig{ - WalletDir: walletDir, - WalletPassword: walletPassword, - Mnemonic: mnemonic, - NumAccounts: numAccounts, - Mnemonic25thWord: req.Mnemonic25ThWord, - }); err != nil { + opts := []accounts.Option{ + accounts.WithWalletDir(walletDir), + accounts.WithWalletPassword(walletPassword), + accounts.WithMnemonic(mnemonic), + accounts.WithMnemonic25thWord(req.Mnemonic25ThWord), + accounts.WithNumAccounts(numAccounts), + } + acc, err := accounts.NewCLIManager(opts...) + if err != nil { + return nil, err + } + if _, err := acc.WalletRecover(ctx); err != nil { return nil, err } if err := s.initializeWallet(ctx, &wallet.Config{