From 9b2c07c118bb024999d67b8fe607a287ccbb4acb Mon Sep 17 00:00:00 2001 From: zah Date: Tue, 25 Apr 2023 09:44:01 +0300 Subject: [PATCH] Allow exiting multiple validators at once (#4855) --- beacon_chain/conf.nim | 13 +- beacon_chain/deposits.nim | 174 ++++++++++-------- .../validators/keystore_management.nim | 26 ++- docs/the_nimbus_book/src/voluntary-exit.md | 3 + 4 files changed, 123 insertions(+), 93 deletions(-) diff --git a/beacon_chain/conf.nim b/beacon_chain/conf.nim index 704e67a89..99051b44c 100644 --- a/beacon_chain/conf.nim +++ b/beacon_chain/conf.nim @@ -683,12 +683,19 @@ type name: "method" .}: ImportMethod of DepositsCmd.exit: - exitedValidator* {. - name: "validator" - desc: "Validator index, public key or a keystore path of the exited validator" .}: string + exitedValidators* {. + desc: "One or more validator index, public key or a keystore path of " & + "the exited validator(s)" + name: "validator" .}: seq[string] + + exitAllValidatorsFlag* {. + desc: "Exit all validators in the specified data directory or validators directory" + defaultValue: false + name: "all" .}: bool exitAtEpoch* {. name: "epoch" + defaultValueDesc: "immediately" desc: "The desired exit epoch" .}: Option[uint64] restUrlForExit* {. diff --git a/beacon_chain/deposits.nim b/beacon_chain/deposits.nim index 111d3b6da..cbe8a79e8 100644 --- a/beacon_chain/deposits.nim +++ b/beacon_chain/deposits.nim @@ -80,8 +80,8 @@ proc getSignedExitMessage( type ClientExitAction = enum - quiting = "q" - confirmation = "I understand the implications of submitting a voluntary exit" + abort = "q" + confirm = "I understand the implications of submitting a voluntary exit" proc askForExitConfirmation(): ClientExitAction = template ask(prompt: string): string = @@ -97,16 +97,6 @@ proc askForExitConfirmation(): ClientExitAction = echoP "Publishing a voluntary exit is an irreversible operation! " & "You won't be able to restart again with the same validator." - echoP "By requesting an exit now, you'll be exempt from penalties " & - "stemming from not performing your validator duties, but you " & - "won't be able to withdraw your deposited funds for the time " & - "being. This means that your funds will be effectively frozen " & - "until withdrawals are enabled in a future phase of Eth2." - - echoP "To understand more about the Eth2 roadmap, we recommend you " & - "have a look at\n" & - "https://ethereum.org/en/eth2/#roadmap" - echoP "You must keep your validator running for at least 5 epochs " & "(32 minutes) after requesting a validator exit, as you will " & "still be required to perform validator duties until your exit " & @@ -118,27 +108,28 @@ proc askForExitConfirmation(): ClientExitAction = var choice = "" - while not(choice == $ClientExitAction.confirmation or - choice == $ClientExitAction.quiting) : + while not(choice == $ClientExitAction.confirm or + choice == $ClientExitAction.abort) : echoP "To proceed to submitting your voluntary exit, please type '" & - $ClientExitAction.confirmation & + $ClientExitAction.confirm & "' (without the quotes) in the prompt below and " & "press ENTER or type 'q' to quit." echo "" choice = ask "Your choice" - if choice == $ClientExitAction.confirmation: - ClientExitAction.confirmation + if choice == $ClientExitAction.confirm: + ClientExitAction.confirm else: - ClientExitAction.quiting + ClientExitAction.abort -proc getValidator*(name: string): Result[ValidatorStorage, string] = +proc getValidator*(decryptor: var MultipleKeystoresDecryptor, + name: string): Result[ValidatorStorage, string] = let ident = ValidatorIdent.decodeString(name) if ident.isErr(): if not(isFile(name)): return err($ident.error) - let key = importKeystoreFromFile(name) + let key = decryptor.importKeystoreFromFile(name) if key.isErr(): return err(key.error()) ok(ValidatorStorage(kind: ValidatorStorageKind.Keystore, @@ -164,29 +155,24 @@ proc restValidatorExit(config: BeaconNodeConf) {.async.} = value: StateIdentType.Head) blockIdentHead = BlockIdent(kind: BlockQueryKind.Named, value: BlockIdentType.Head) - validator = getValidator(config.exitedValidator).valueOr: - fatal "Incorrect validator index, key or keystore path specified", - value = config.exitedValidator, reason = error - quit 1 - let restValidator = try: - let response = await client.getStateValidatorPlain(stateIdHead, - validator.getIdent()) - if response.status == 200: - let validatorInfo = decodeBytes(GetStateValidatorResponse, - response.data, response.contentType) - if validatorInfo.isErr(): - raise newException(RestError, $validatorInfo.error) - validatorInfo.get().data - else: - raiseGenericError(response) - except CatchableError as exc: - fatal "Failed to obtain information for validator", reason = exc.msg - quit 1 - - let - validatorIdx = restValidator.index.uint64 - validatorKey = restValidator.validator.pubkey + # Before making any REST requests, we'll make sure that the supplied + # inputs are correct: + var validators: seq[ValidatorStorage] + if config.exitAllValidatorsFlag: + var keystoreCache = KeystoreCacheRef.init() + for keystore in listLoadableKeystores(config, keystoreCache): + validators.add ValidatorStorage(kind: ValidatorStorageKind.Keystore, + privateKey: keystore.privateKey) + else: + var decryptor: MultipleKeystoresDecryptor + defer: dispose decryptor + for pubKey in config.exitedValidators: + let validatorStorage = decryptor.getValidator(pubkey).valueOr: + fatal "Incorrect validator index, key or keystore path specified", + value = pubKey, reason = error + quit 1 + validators.add validatorStorage let genesis = try: let response = await client.getGenesisPlain() @@ -231,67 +217,95 @@ proc restValidatorExit(config: BeaconNodeConf) {.async.} = reason = exc.msg quit 1 - let - genesis_validators_root = genesis.genesis_validators_root - validatorKeyAsStr = "0x" & $validatorKey - signedExit = getSignedExitMessage(config, - validator, - validatorKeyAsStr, - exitAtEpoch, - validatorIdx, - fork, - genesis_validators_root) + if not config.printData: + case askForExitConfirmation() + of ClientExitAction.abort: + quit 0 + of ClientExitAction.confirm: + discard - if config.printData: - let bytes = encodeBytes(signedExit, "application/json").valueOr: - fatal "Unable to serialize signed exit message", reason = error + var hadErrors = false + for validator in validators: + let restValidator = try: + let response = await client.getStateValidatorPlain(stateIdHead, validator.getIdent) + if response.status == 200: + let validatorInfo = decodeBytes(GetStateValidatorResponse, + response.data, response.contentType) + if validatorInfo.isErr(): + raise newException(RestError, $validatorInfo.error) + validatorInfo.get().data + else: + raiseGenericError(response) + except CatchableError as exc: + fatal "Failed to obtain information for validator", reason = exc.msg quit 1 - echoP "You can use following command to send voluntary exit message to " & - "remote beacon node host:\n" + let + validatorIdx = restValidator.index.uint64 + validatorKey = restValidator.validator.pubkey - echo "curl -X 'POST' \\" - echo " '" & config.restUrlForExit & - "/eth/v1/beacon/pool/voluntary_exits' \\" - echo " -H 'Accept: */*' \\" - echo " -H 'Content-Type: application/json' \\" - echo " -d '" & string.fromBytes(bytes) & "'" - quit 0 - else: - try: - let choice = askForExitConfirmation() - if choice == ClientExitAction.quiting: - quit 0 - elif choice == ClientExitAction.confirmation: + let + genesis_validators_root = genesis.genesis_validators_root + validatorKeyAsStr = "0x" & $validatorKey + signedExit = getSignedExitMessage(config, + validator, + validatorKeyAsStr, + exitAtEpoch, + validatorIdx, + fork, + genesis_validators_root) + + if config.printData: + let bytes = encodeBytes(signedExit, "application/json").valueOr: + error "Unable to serialize signed exit message", reason = error + hadErrors = true + continue + + echoP "You can use following command to send voluntary exit message to " & + "remote beacon node host:\n" + + echo "curl -X 'POST' \\" + echo " '" & config.restUrlForExit & + "/eth/v1/beacon/pool/voluntary_exits' \\" + echo " -H 'Accept: */*' \\" + echo " -H 'Content-Type: application/json' \\" + echo " -d '" & string.fromBytes(bytes) & "'" + quit 0 + else: + try: let + validatorDesc = $validatorIdx & "(" & validatorKeyAsStr[0..9] & ")" response = await client.submitPoolVoluntaryExit(signedExit) success = response.status == 200 if success: echo "Successfully published voluntary exit for validator " & - $validatorIdx & "(" & validatorKeyAsStr[0..9] & ")." - quit 0 + validatorDesc & "." else: + hadErrors = true let responseError = try: - Json.decode(response.data, RestErrorMessage) + Json.decode(response.data, RestErrorMessage) except CatchableError as exc: - fatal "Failed to decode invalid error server response on " & + error "Failed to decode invalid error server response on " & "`submitPoolVoluntaryExit` request", reason = exc.msg - quit 1 + continue let responseMessage = responseError.message responseStacktraces = responseError.stacktraces - echo "The voluntary exit was not submitted successfully." + echo "The voluntary exit for validator " & validatorDesc & + " was not submitted successfully." echo responseMessage & ":" for el in responseStacktraces.get(): echo el echoP "Please try again." - quit 1 + except CatchableError as err: + fatal "Failed to send the signed exit message", + signedExit, reason = err.msg + hadErrors = true - except CatchableError as err: - fatal "Failed to send the signed exit message", reason = err.msg - quit 1 + if hadErrors: + quit 1 proc handleValidatorExitCommand(config: BeaconNodeConf) {.async.} = await restValidatorExit(config) diff --git a/beacon_chain/validators/keystore_management.nim b/beacon_chain/validators/keystore_management.nim index 1ad3a15e6..e03a0f8b7 100644 --- a/beacon_chain/validators/keystore_management.nim +++ b/beacon_chain/validators/keystore_management.nim @@ -80,6 +80,9 @@ type getValidatorAndIdxFn*: ValidatorPubKeyToDataFn getBeaconTimeFn*: GetBeaconTimeFn + MultipleKeystoresDecryptor* = object + previouslyUsedPassword*: string + const minPasswordLen = 12 minPasswordEntropy = 60.0 @@ -89,6 +92,9 @@ const "passwords" / "10-million-password-list-top-100000.txt", minWordLen = minPasswordLen) +proc dispose*(decryptor: var MultipleKeystoresDecryptor) = + burnMem(decryptor.previouslyUsedPassword) + func init*(T: type KeymanagerHost, validatorPool: ref ValidatorPool, rng: ref HmacDrbgContext, @@ -1497,6 +1503,7 @@ proc saveWallet*(wallet: WalletPathPair): Result[void, string] = saveWallet(wallet.wallet, wallet.path) proc readPasswordInput(prompt: string, password: var string): bool = + burnMem password try: when defined(windows): # readPasswordFromStdin() on Windows always returns `false`. @@ -1533,11 +1540,9 @@ proc resetAttributesNoError() = except IOError: discard proc importKeystoreFromFile*( - fileName: string - ): Result[ValidatorPrivKey, string] = - var password: string # TODO consider using a SecretString type - defer: burnMem(password) - + decryptor: var MultipleKeystoresDecryptor, + fileName: string + ): Result[ValidatorPrivKey, string] = let data = readAllChars(fileName).valueOr: return err("Unable to read keystore file [" & ioErrorMsg(error) & "]") @@ -1551,9 +1556,10 @@ proc importKeystoreFromFile*( var firstDecryptionAttempt = true while true: var secret: seq[byte] - let status = decryptCryptoField(keystore.crypto, - KeystorePass.init(password), - secret) + let status = decryptCryptoField( + keystore.crypto, + KeystorePass.init(decryptor.previouslyUsedPassword), + secret) case status of DecryptionStatus.Success: let privateKey = ValidatorPrivKey.fromRaw(secret).valueOr: @@ -1572,9 +1578,9 @@ proc importKeystoreFromFile*( else: echo "The entered password was incorrect. Please try again." - if not(readPasswordInput("Password: ", password)): + if not(readPasswordInput("Password: ", decryptor.previouslyUsedPassword)): echo "System error while entering password. Please try again." - if len(password) == 0: break + if len(decryptor.previouslyUsedPassword) == 0: break proc importKeystoresFromDir*(rng: var HmacDrbgContext, meth: ImportMethod, importedDir, validatorsDir, secretsDir: string) = diff --git a/docs/the_nimbus_book/src/voluntary-exit.md b/docs/the_nimbus_book/src/voluntary-exit.md index 782d5a171..c39226465 100644 --- a/docs/the_nimbus_book/src/voluntary-exit.md +++ b/docs/the_nimbus_book/src/voluntary-exit.md @@ -19,6 +19,9 @@ To perform a voluntary exit, make sure your beacon node is running with the `--r !!! note In the command above, you must replace `` with the file-system path of an Ethereum [ERC-2335 Keystore](https://eips.ethereum.org/EIPS/eip-2335) created by a tool such as [staking-deposit-cli](https://github.com/ethereum/staking-deposit-cli) or [ethdo](https://github.com/wealdtech/ethdo). +!!! tip + You can perform multiple voluntary exits at once by supplying the `--validator` option multiple times on the command-line. This is typically more convenient when the provided keystores share the same password - you'll be asked to enter it only once. + ## `rest-url` parameter The `--rest-url` parameter can be used to point the exit command to a specific node for publishing the request, as long as it's compatible with the [REST API](./rest-api.md).