diff --git a/.gitignore b/.gitignore index d8c216b7..cb87dee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -validator_keys bls_to_execution_changes +exit_transactions +validator_keys # Python testing & linting: build/ diff --git a/README.md b/README.md index 32fd21a6..6aaba134 100644 --- a/README.md +++ b/README.md @@ -5,75 +5,76 @@ -- [staking-deposit-cli](#staking-deposit-cli) - - [Introduction](#introduction) - - [Tutorial for users](#tutorial-for-users) - - [Build requirements](#build-requirements) - - [For Linux or MacOS users](#for-linux-or-macos-users) - - [File Permissions](#file-permissions) - - [Option 1. Download binary executable file](#option-1-download-binary-executable-file) - - [Step 1. Installation](#step-1-installation) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json) - - [`language` Argument](#language-argument) - - [`--non_interactive` flag](#--non_interactive-flag) - - [Commands](#commands) - - [`new-mnemonic` Arguments](#new-mnemonic-arguments) - - [`existing-mnemonic` Arguments](#existing-mnemonic-arguments) - - [Successful message](#successful-message) - - [`generate-bls-to-execution-change` Arguments](#generate-bls-to-execution-change-arguments) - - [Option 2. Build `deposit-cli` with native Python](#option-2-build-deposit-cli-with-native-python) - - [Step 0. Python version checking](#step-0-python-version-checking) - - [Step 1. Installation](#step-1-installation-1) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-1) - - [Language Argument](#language-argument-1) - - [Commands](#commands-1) - - [Arguments](#arguments) - - [Successful message](#successful-message-1) - - [Option 3. Build `deposit-cli` with `virtualenv`](#option-3-build-deposit-cli-with-virtualenv) - - [Step 0. Python version checking](#step-0-python-version-checking-1) - - [Step 1. Installation](#step-1-installation-2) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-2) - - [Language Argument](#language-argument-2) - - [Commands](#commands-2) - - [Arguments](#arguments-1) - - [Option 4. Use Docker image](#option-4-use-docker-image) - - [Step 1. Build the docker image](#step-1-build-the-docker-image) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-3) - - [Arguments](#arguments-2) - - [Successful message](#successful-message-2) - - [For Windows users](#for-windows-users) - - [Option 1. Download binary executable file](#option-1-download-binary-executable-file-1) - - [Step 1. Installation](#step-1-installation-3) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-4) - - [Language Argument](#language-argument-3) - - [Commands](#commands-3) - - [Arguments](#arguments-3) - - [Option 2. Build `deposit-cli` with native Python](#option-2-build-deposit-cli-with-native-python-1) - - [Step 0. Python version checking](#step-0-python-version-checking-2) - - [Step 1. Installation](#step-1-installation-4) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-5) - - [Language Argument](#language-argument-4) - - [Commands](#commands-4) - - [Arguments](#arguments-4) - - [Option 3. Build `deposit-cli` with `virtualenv`](#option-3-build-deposit-cli-with-virtualenv-1) - - [Step 0. Python version checking](#step-0-python-version-checking-3) - - [Step 1. Installation](#step-1-installation-5) - - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-6) - - [Language Argument](#language-argument-5) - - [Commands](#commands-5) - - [Arguments](#arguments-5) - - [Development](#development) - - [Install basic requirements](#install-basic-requirements) - - [Install testing requirements](#install-testing-requirements) - - [Run tests](#run-tests) - - [Building Binaries](#building-binaries) - - [Mac M1 Binaries](#mac-m1-binaries) +- [Introduction](#introduction) +- [Tutorial for users](#tutorial-for-users) + - [Build requirements](#build-requirements) + - [For Linux or MacOS users](#for-linux-or-macos-users) + - [File Permissions](#file-permissions) + - [Option 1. Download binary executable file](#option-1-download-binary-executable-file) + - [Step 1. Installation](#step-1-installation) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json) + - [`language` Argument](#language-argument) + - [`--non_interactive` flag](#--non_interactive-flag) + - [Commands](#commands) + - [`new-mnemonic` Arguments](#new-mnemonic-arguments) + - [`existing-mnemonic` Arguments](#existing-mnemonic-arguments) + - [Successful message](#successful-message) + - [`generate-bls-to-execution-change` Arguments](#generate-bls-to-execution-change-arguments) + - [`exit-transaction-keystore` Arguments](#exit-transaction-keystore-arguments) + - [`exit-transaction-mnemonic` Arguments](#exit-transaction-mnemonic-arguments) + - [Option 2. Build `deposit-cli` with native Python](#option-2-build-deposit-cli-with-native-python) + - [Step 0. Python version checking](#step-0-python-version-checking) + - [Step 1. Installation](#step-1-installation-1) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-1) + - [Language Argument](#language-argument) + - [Commands](#commands-1) + - [Arguments](#arguments) + - [Successful message](#successful-message-1) + - [Option 3. Build `deposit-cli` with `virtualenv`](#option-3-build-deposit-cli-with-virtualenv) + - [Step 0. Python version checking](#step-0-python-version-checking-1) + - [Step 1. Installation](#step-1-installation-2) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-2) + - [Language Argument](#language-argument-1) + - [Commands](#commands-2) + - [Arguments](#arguments-1) + - [Option 4. Use Docker image](#option-4-use-docker-image) + - [Step 1. Build the docker image](#step-1-build-the-docker-image) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-3) + - [Arguments](#arguments-2) + - [Successful message](#successful-message-2) + - [For Windows users](#for-windows-users) + - [Option 1. Download binary executable file](#option-1-download-binary-executable-file-1) + - [Step 1. Installation](#step-1-installation-3) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-4) + - [Language Argument](#language-argument-2) + - [Commands](#commands-3) + - [Arguments](#arguments-3) + - [Option 2. Build `deposit-cli` with native Python](#option-2-build-deposit-cli-with-native-python-1) + - [Step 0. Python version checking](#step-0-python-version-checking-2) + - [Step 1. Installation](#step-1-installation-4) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-5) + - [Language Argument](#language-argument-3) + - [Commands](#commands-4) + - [Arguments](#arguments-4) + - [Option 3. Build `deposit-cli` with `virtualenv`](#option-3-build-deposit-cli-with-virtualenv-1) + - [Step 0. Python version checking](#step-0-python-version-checking-3) + - [Step 1. Installation](#step-1-installation-5) + - [Step 2. Create keys and `deposit_data-*.json`](#step-2-create-keys-and-deposit_data-json-6) + - [Language Argument](#language-argument-4) + - [Commands](#commands-5) + - [Arguments](#arguments-5) +- [Development](#development) + - [Install basic requirements](#install-basic-requirements) + - [Install testing requirements](#install-testing-requirements) + - [Run tests](#run-tests) + - [Building Binaries](#building-binaries) + - [Mac M1 Binaries](#mac-m1-binaries) ## Introduction -`deposit-cli` is a tool for creating [EIP-2335 format](https://eips.ethereum.org/EIPS/eip-2335) BLS12-381 keystores and a corresponding `deposit_data*.json` file for [Ethereum Staking Launchpad](https://github.com/ethereum/staking-launchpad). +`deposit-cli` is a tool for creating [EIP-2335 format](https://eips.ethereum.org/EIPS/eip-2335) BLS12-381 keystores and a corresponding `deposit_data*.json` file for [Ethereum Staking Launchpad](https://github.com/ethereum/staking-launchpad). One can also provide a keystore file to generate a `signed_exit_transaction*.json` file to be broadcast at a later date to exit a validator. - **Warning: Please generate your keystores on your own safe, completely offline device.** - **Warning: Please backup your mnemonic, keystores, and password securely.** @@ -179,7 +180,7 @@ Success! Your keys can be found at: ``` -###### `generate-bls-to-execution-change` Arguments +###### `generate-bls-to-execution-change` Arguments You can use `bls-to-execution-change --help` to see all arguments. Note that if there are missing arguments that the CLI needs, it will ask you for them. @@ -193,7 +194,34 @@ You can use `bls-to-execution-change --help` to see all arguments. Note that if | `--validator_indices` | String of integer(s) | A list of the chosen validator index number(s) as identified on the beacon chain. Split multiple items with whitespaces or commas. | | `--bls_withdrawal_credentials_list` | String of hexstring(s). | A list of the old BLS withdrawal credentials of the given validator(s). It is for confirming you are using the correct keys. Split multiple items with whitespaces or commas. | | `--execution_address` (or `--eth1_withdrawal_address`) | String. Eth1 address in hexadecimal encoded form | If this field is set and valid, the given Eth1 address will be used to create the withdrawal credentials. Otherwise, it will generate withdrawal credentials with the mnemonic-derived withdrawal public key in [ERC-2334 format](https://eips.ethereum.org/EIPS/eip-2334#eth2-specific-parameters). | -| `--devnet_chain_setting` | String. JSON string `'{"network_name": "", "genesis_fork_version": "", "genesis_validator_root": ""}'` | The custom chain setting of a devnet or testnet. Note that it will override your `--chain` choice. | +| `--devnet_chain_setting` | String. JSON string `'{"network_name": "", "genesis_fork_version": "", "exit_fork_version": "", "genesis_validator_root": ""}'` | The custom chain setting of a devnet or testnet. Note that it will override your `--chain` choice. | + +###### `exit-transaction-keystore` Arguments + +You can use `exit-transaction-keystore --help` to see all arguments. Note that if there are missing arguments that the CLI needs, it will ask you for them. + +| Argument | Type | Description | +| -------- | -------- | -------- | +| `--chain` | String. `mainnet` by default | The chain setting for the signing domain. | +| `--keystore` | File | The keystore file associating with the validator you wish to exit. | +| `--keystore_password` | String | The password that is used to encrypt the provided keystore. Note: It's not your mnemonic password. | +| `--validator_index` | Integer | The validator index corresponding to the provided keystore. | +| `--epoch` | Optional integer. 0 by default | The epoch of when the exit transaction will be valid. The transaction will always be valid by default. | +| `--output_folder` | String. Pointing to `./exit_transaction` by default | The folder path for the `signed_exit_transaction-*` JSON file | + +###### `exit-transaction-mnemonic` Arguments + +You can use `exit-transaction-mnemonic --help` to see all arguments. Note that if there are missing arguments that the CLI needs, it will ask you for them. + +| Argument | Type | Description | +| -------- | -------- | -------- | +| `--chain` | String. `mainnet` by default | The chain setting for the signing domain. | +| `--mnemonic` | String. mnemonic split by space. | The mnemonic you used during key generation. | +| `--mnemonic_password` | Optional string. Empty by default. | The mnemonic password you used in your key generation. Note: It's not the keystore password. | +| `--validator_start_index` | Non-negative integer | The index position for the keys to start generating keystores in [ERC-2334 format](https://eips.ethereum.org/EIPS/eip-2334#eth2-specific-parameters). | +| `--validator_indices` | String of integer(s) | A list of the chosen validator index number(s) as identified on the beacon chain. Split multiple items with whitespaces or commas. | +| `--epoch` | Optional integer. 0 by default | The epoch of when the exit transaction will be valid. The transaction will always be valid by default. | +| `--output_folder` | String. Pointing to `./exit_transaction` by default | The folder path for the `signed_exit_transaction-*` JSON file | #### Option 2. Build `deposit-cli` with native Python @@ -252,9 +280,11 @@ See [here](#commands) ###### Arguments -See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments -See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments -See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments +See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments\ +See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments\ +See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments\ +See [here](#exit-transaction-keystore-arguments) for `exit-transaction-keystore` arguments\ +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments ###### Successful message See [here](#successful-message) @@ -319,9 +349,11 @@ See [here](#commands) ###### Arguments -See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments -See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments -See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments +See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments\ +See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments\ +See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments\ +See [here](#exit-transaction-keystore-arguments) for `exit-transaction-keystore` arguments\ +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments #### Option 4. Use Docker image @@ -403,9 +435,11 @@ See [here](#commands) ###### Arguments -See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments -See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments -See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments +See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments\ +See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments\ +See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments\ +See [here](#exit-transaction-keystore-arguments) for `exit-transaction-keystore` arguments\ +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments #### Option 2. Build `deposit-cli` with native Python @@ -529,9 +563,11 @@ See [here](#commands) ###### Arguments -See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments -See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments -See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments +See [here](#new-mnemonic-arguments) for `new-mnemonic` arguments\ +See [here](#existing-mnemonic-arguments) for `existing-mnemonic` arguments\ +See [here](#generate-bls-to-execution-change-arguments) for `generate-bls-to-execution-change` arguments\ +See [here](#exit-transaction-keystore-arguments) for `exit-transaction-keystore` arguments\ +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments ## Development diff --git a/staking_deposit/cli/existing_mnemonic.py b/staking_deposit/cli/existing_mnemonic.py index 8aa87394..ec4de992 100644 --- a/staking_deposit/cli/existing_mnemonic.py +++ b/staking_deposit/cli/existing_mnemonic.py @@ -47,7 +47,7 @@ def load_mnemonic_arguments_decorator(function: Callable[..., Any]) -> Callable[ default='', help=lambda: load_text(['arg_mnemonic_password', 'help'], func='existing_mnemonic'), hidden=True, - param_decls='--mnemonic-password', + param_decls='--mnemonic_password', prompt=False, ), ] diff --git a/staking_deposit/cli/exit_transaction_keystore.py b/staking_deposit/cli/exit_transaction_keystore.py new file mode 100644 index 00000000..029ac2f2 --- /dev/null +++ b/staking_deposit/cli/exit_transaction_keystore.py @@ -0,0 +1,126 @@ +import click +import os + +from typing import Any +from staking_deposit.exit_transaction import exit_transaction_generation, export_exit_transaction_json +from staking_deposit.key_handling.keystore import Keystore +from staking_deposit.settings import ( + MAINNET, + NON_PRATER_CHAIN_KEYS, + get_chain_setting, +) +from staking_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from staking_deposit.utils.constants import DEFAULT_EXIT_TRANSACTION_FOLDER_NAME +from staking_deposit.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.utils.validation import validate_int_range, validate_keystore_file, verify_signed_exit_json + + +FUNC_NAME = 'exit_transaction_keystore' + + +@click.command( + help=load_text(['arg_exit_transaction_keystore', 'help'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: closest_match(x, NON_PRATER_CHAIN_KEYS), + choice_prompt_func( + lambda: load_text(['arg_exit_transaction_keystore_chain', 'prompt'], func=FUNC_NAME), + NON_PRATER_CHAIN_KEYS + ), + ), + default=MAINNET, + help=lambda: load_text(['arg_exit_transaction_keystore_chain', 'help'], func=FUNC_NAME), + param_decls='--chain', + prompt=choice_prompt_func( + lambda: load_text(['arg_exit_transaction_keystore_chain', 'prompt'], func=FUNC_NAME), + NON_PRATER_CHAIN_KEYS + ), +) +@jit_option( + callback=captive_prompt_callback( + lambda file: validate_keystore_file(file), + lambda: load_text(['arg_exit_transaction_keystore_keystore', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_exit_transaction_keystore_keystore', 'help'], func=FUNC_NAME), + param_decls='--keystore', + prompt=lambda: load_text(['arg_exit_transaction_keystore_keystore', 'prompt'], func=FUNC_NAME), + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: x, + lambda: load_text(['arg_exit_transaction_keystore_keystore_password', 'prompt'], func=FUNC_NAME), + None, + lambda: load_text(['arg_exit_transaction_keystore_keystore_password', 'invalid'], func=FUNC_NAME), + True, + ), + help=lambda: load_text(['arg_exit_transaction_keystore_keystore_password', 'help'], func=FUNC_NAME), + hide_input=True, + param_decls='--keystore_password', + prompt=lambda: load_text(['arg_exit_transaction_keystore_keystore_password', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda num: validate_int_range(num, 0, 2**32), + lambda: load_text(['arg_validator_index', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_validator_index', 'help'], func=FUNC_NAME), + param_decls='--validator_index', + prompt=lambda: load_text(['arg_validator_index', 'prompt'], func=FUNC_NAME), +) +@jit_option( + default=0, + help=lambda: load_text(['arg_exit_transaction_keystore_epoch', 'help'], func=FUNC_NAME), + param_decls='--epoch', +) +@jit_option( + default=os.getcwd(), + help=lambda: load_text(['arg_exit_transaction_keystore_output_folder', 'help'], func=FUNC_NAME), + param_decls='--output_folder', + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.pass_context +def exit_transaction_keystore( + ctx: click.Context, + chain: str, + keystore: Keystore, + keystore_password: str, + validator_index: int, + epoch: int, + output_folder: str, + **kwargs: Any) -> None: + try: + secret_bytes = keystore.decrypt(keystore_password) + except ValueError: + click.echo(load_text(['arg_exit_transaction_keystore_keystore_password', 'mismatch'])) + exit(1) + + signing_key = int.from_bytes(secret_bytes, 'big') + chain_settings = get_chain_setting(chain) + + signed_exit = exit_transaction_generation( + chain_settings=chain_settings, + signing_key=signing_key, + validator_index=validator_index, + epoch=epoch, + ) + + folder = os.path.join(output_folder, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + + click.echo(load_text(['msg_exit_transaction_creation'])) + saved_folder = export_exit_transaction_json(folder=folder, signed_exit=signed_exit) + + click.echo(load_text(['msg_verify_exit_transaction'])) + if (not verify_signed_exit_json(saved_folder, keystore.pubkey, chain_settings)): + click.echo(['err_verify_exit_transaction']) + + click.echo(load_text(['msg_creation_success']) + saved_folder) + click.pause(load_text(['msg_pause'])) diff --git a/staking_deposit/cli/exit_transaction_mnemonic.py b/staking_deposit/cli/exit_transaction_mnemonic.py new file mode 100644 index 00000000..3a7f8376 --- /dev/null +++ b/staking_deposit/cli/exit_transaction_mnemonic.py @@ -0,0 +1,132 @@ +import click +import os + +from typing import Any, Sequence +from staking_deposit.cli.existing_mnemonic import load_mnemonic_arguments_decorator +from staking_deposit.credentials import Credential +from staking_deposit.exceptions import ValidationError +from staking_deposit.settings import ( + MAINNET, + NON_PRATER_CHAIN_KEYS, + get_chain_setting, +) +from staking_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from staking_deposit.utils.constants import DEFAULT_EXIT_TRANSACTION_FOLDER_NAME +from staking_deposit.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.utils.validation import validate_int_range, validate_validator_indices, verify_signed_exit_json + + +FUNC_NAME = 'exit_transaction_mnemonic' + + +@click.command( + help=load_text(['arg_exit_transaction_mnemonic', 'help'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: closest_match(x, NON_PRATER_CHAIN_KEYS), + choice_prompt_func( + lambda: load_text(['arg_exit_transaction_mnemonic_chain', 'prompt'], func=FUNC_NAME), + NON_PRATER_CHAIN_KEYS + ), + ), + default=MAINNET, + help=lambda: load_text(['arg_exit_transaction_mnemonic_chain', 'help'], func=FUNC_NAME), + param_decls='--chain', + prompt=choice_prompt_func( + lambda: load_text(['arg_exit_transaction_mnemonic_chain', 'prompt'], func=FUNC_NAME), + NON_PRATER_CHAIN_KEYS + ), +) +@load_mnemonic_arguments_decorator +@jit_option( + callback=captive_prompt_callback( + lambda num: validate_int_range(num, 0, 2**32), + lambda: load_text(['arg_exit_transaction_mnemonic_start_index', 'prompt'], func=FUNC_NAME), + ), + default=0, + help=lambda: load_text(['arg_exit_transaction_mnemonic_start_index', 'help'], func=FUNC_NAME), + param_decls="--validator_start_index", + prompt=lambda: load_text(['arg_exit_transaction_mnemonic_start_index', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda validator_indices: validate_validator_indices(validator_indices), + lambda: load_text(['arg_exit_transaction_mnemonic_indices', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_exit_transaction_mnemonic_indices', 'help'], func=FUNC_NAME), + param_decls='--validator_indices', + prompt=lambda: load_text(['arg_exit_transaction_mnemonic_indices', 'prompt'], func=FUNC_NAME), +) +@jit_option( + default=0, + help=lambda: load_text(['arg_exit_transaction_mnemonic_epoch', 'help'], func=FUNC_NAME), + param_decls='--epoch', +) +@jit_option( + default=os.getcwd(), + help=lambda: load_text(['arg_exit_transaction_mnemonic_output_folder', 'help'], func=FUNC_NAME), + param_decls='--output_folder', + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.pass_context +def exit_transaction_mnemonic( + ctx: click.Context, + chain: str, + mnemonic: str, + mnemonic_password: str, + validator_start_index: int, + validator_indices: Sequence[int], + epoch: int, + output_folder: str, + **kwargs: Any) -> None: + + folder = os.path.join(output_folder, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + chain_settings = get_chain_setting(chain) + num_keys = len(validator_indices) + key_indices = range(validator_start_index, validator_start_index + num_keys) + + # We are not using CredentialList because from_mnemonic assumes key generation flow + credentials = [ + Credential( + mnemonic=mnemonic, + mnemonic_password=mnemonic_password, + index=key_index, + amount=0, # Unneeded for this purpose + chain_setting=chain_settings, + hex_eth1_withdrawal_address=None + ) for key_index in key_indices + ] + + with click.progressbar(zip(credentials, validator_indices), + label=load_text(['msg_exit_transaction_creation']), + show_percent=False, + length=num_keys, + show_pos=True) as items: + transaction_filefolders = [ + credential.save_exit_transaction(validator_index=validator_index, epoch=epoch, folder=folder) + for credential, validator_index in items + ] + + with click.progressbar(zip(transaction_filefolders, credentials), + label=load_text(['msg_verify_exit_transaction']), + show_percent=False, + length=num_keys, + show_pos=True) as items: + if not all( + verify_signed_exit_json(file_folder=file, + pubkey=credential.signing_pk.hex(), + chain_settings=credential.chain_setting) + for file, credential in items + ): + raise ValidationError(load_text(['err_verify_exit_transactions'])) + + click.echo(load_text(['msg_creation_success']) + folder) + click.pause(load_text(['msg_pause'])) diff --git a/staking_deposit/cli/generate_bls_to_execution_change.py b/staking_deposit/cli/generate_bls_to_execution_change.py index 0b3b0f49..fa497305 100644 --- a/staking_deposit/cli/generate_bls_to_execution_change.py +++ b/staking_deposit/cli/generate_bls_to_execution_change.py @@ -34,9 +34,8 @@ load_text, ) from staking_deposit.settings import ( - ALL_CHAINS, MAINNET, - PRATER, + NON_PRATER_CHAIN_KEYS, get_chain_setting, get_devnet_chain_setting, ) @@ -63,10 +62,10 @@ def get_password(text: str) -> str: ) @jit_option( callback=captive_prompt_callback( - lambda x: closest_match(x, list(ALL_CHAINS.keys())), + lambda x: closest_match(x, NON_PRATER_CHAIN_KEYS), choice_prompt_func( lambda: load_text(['arg_chain', 'prompt'], func=FUNC_NAME), - list(ALL_CHAINS.keys()) + NON_PRATER_CHAIN_KEYS ), ), default=MAINNET, @@ -74,8 +73,7 @@ def get_password(text: str) -> str: param_decls='--chain', prompt=choice_prompt_func( lambda: load_text(['arg_chain', 'prompt'], func=FUNC_NAME), - # Since `prater` is alias of `goerli`, do not show `prater` in the prompt message. - list(key for key in ALL_CHAINS.keys() if key != PRATER) + NON_PRATER_CHAIN_KEYS ), ) @load_mnemonic_arguments_decorator @@ -155,6 +153,7 @@ def generate_bls_to_execution_change( chain_setting = get_devnet_chain_setting( network_name=devnet_chain_setting_dict['network_name'], genesis_fork_version=devnet_chain_setting_dict['genesis_fork_version'], + exit_fork_version=devnet_chain_setting_dict['exit_fork_version'], genesis_validator_root=devnet_chain_setting_dict['genesis_validator_root'], ) diff --git a/staking_deposit/cli/generate_keys.py b/staking_deposit/cli/generate_keys.py index b79e03ec..1e5206c6 100644 --- a/staking_deposit/cli/generate_keys.py +++ b/staking_deposit/cli/generate_keys.py @@ -31,9 +31,8 @@ load_text, ) from staking_deposit.settings import ( - ALL_CHAINS, MAINNET, - PRATER, + NON_PRATER_CHAIN_KEYS, get_chain_setting, ) @@ -65,10 +64,10 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ ), jit_option( callback=captive_prompt_callback( - lambda x: closest_match(x, list(ALL_CHAINS.keys())), + lambda x: closest_match(x, NON_PRATER_CHAIN_KEYS), choice_prompt_func( lambda: load_text(['chain', 'prompt'], func='generate_keys_arguments_decorator'), - list(ALL_CHAINS.keys()) + NON_PRATER_CHAIN_KEYS ), default=MAINNET, ), @@ -77,8 +76,7 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ param_decls='--chain', prompt=choice_prompt_func( lambda: load_text(['chain', 'prompt'], func='generate_keys_arguments_decorator'), - # Since `prater` is alias of `goerli`, do not show `prater` in the prompt message. - list(key for key in ALL_CHAINS.keys() if key != PRATER) + NON_PRATER_CHAIN_KEYS ), ), jit_option( diff --git a/staking_deposit/credentials.py b/staking_deposit/credentials.py index 6d0fdc9c..1f59ceee 100644 --- a/staking_deposit/credentials.py +++ b/staking_deposit/credentials.py @@ -10,6 +10,7 @@ from py_ecc.bls import G2ProofOfPossession as bls from staking_deposit.exceptions import ValidationError +from staking_deposit.exit_transaction import exit_transaction_generation, export_exit_transaction_json from staking_deposit.key_handling.key_derivation.path import mnemonic_and_path_to_key from staking_deposit.key_handling.keystore import ( Keystore, @@ -210,6 +211,18 @@ def get_bls_to_execution_change_dict(self, validator_index: int) -> Dict[str, by result_dict.update({'metadata': metadata}) return result_dict + def save_exit_transaction(self, validator_index: int, epoch: int, folder: str) -> str: + signing_key = self.signing_sk + + signed_voluntary_exit = exit_transaction_generation( + chain_settings=self.chain_setting, + signing_key=signing_key, + validator_index=validator_index, + epoch=epoch + ) + + return export_exit_transaction_json(folder=folder, signed_exit=signed_voluntary_exit) + class CredentialList: """ diff --git a/staking_deposit/deposit.py b/staking_deposit/deposit.py index dac45388..d45eaa3f 100644 --- a/staking_deposit/deposit.py +++ b/staking_deposit/deposit.py @@ -2,6 +2,8 @@ import sys from staking_deposit.cli.existing_mnemonic import existing_mnemonic +from staking_deposit.cli.exit_transaction_keystore import exit_transaction_keystore +from staking_deposit.cli.exit_transaction_mnemonic import exit_transaction_mnemonic from staking_deposit.cli.generate_bls_to_execution_change import generate_bls_to_execution_change from staking_deposit.cli.new_mnemonic import new_mnemonic from staking_deposit.utils.click import ( @@ -56,6 +58,8 @@ def cli(ctx: click.Context, language: str, non_interactive: bool) -> None: cli.add_command(existing_mnemonic) cli.add_command(new_mnemonic) cli.add_command(generate_bls_to_execution_change) +cli.add_command(exit_transaction_keystore) +cli.add_command(exit_transaction_mnemonic) def run() -> None: diff --git a/staking_deposit/exit_transaction.py b/staking_deposit/exit_transaction.py new file mode 100644 index 00000000..c73376fd --- /dev/null +++ b/staking_deposit/exit_transaction.py @@ -0,0 +1,65 @@ +import json +import os +import time +from typing import Any, Dict +from py_ecc.bls import G2ProofOfPossession as bls + +from staking_deposit.settings import BaseChainSetting +from staking_deposit.utils.ssz import ( + SignedVoluntaryExit, + VoluntaryExit, + compute_signing_root, + compute_voluntary_exit_domain, +) + + +def exit_transaction_generation( + chain_settings: BaseChainSetting, + signing_key: int, + validator_index: int, + epoch: int) -> SignedVoluntaryExit: + message = VoluntaryExit( # type: ignore[no-untyped-call] + epoch=epoch, + validator_index=validator_index + ) + + domain = compute_voluntary_exit_domain( + fork_version=chain_settings.EXIT_FORK_VERSION, + genesis_validators_root=chain_settings.GENESIS_VALIDATORS_ROOT + ) + + signing_root = compute_signing_root(message, domain) + signature = bls.Sign(signing_key, signing_root) + + signed_exit = SignedVoluntaryExit( # type: ignore[no-untyped-call] + message=message, + signature=signature, + ) + + return signed_exit + + +def export_exit_transaction_json(folder: str, signed_exit: SignedVoluntaryExit) -> str: + signed_exit_json: Dict[str, Any] = {} + message = { + 'epoch': str(signed_exit.message.epoch), # type: ignore[attr-defined] + 'validator_index': str(signed_exit.message.validator_index), # type: ignore[attr-defined] + } + signed_exit_json.update({'message': message}) + signed_exit_json.update({'signature': '0x' + signed_exit.signature.hex()}) # type: ignore[attr-defined] + + if not os.path.exists(folder): + os.mkdir(folder) + + filefolder = os.path.join( + folder, + 'signed_exit_transaction-%s-%i.json' % ( + signed_exit.message.validator_index, time.time() # type: ignore[attr-defined] + ) + ) + + with open(filefolder, 'w') as f: + json.dump(signed_exit_json, f) + if os.name == 'posix': + os.chmod(filefolder, int('440', 8)) # Read for owner & group + return filefolder diff --git a/staking_deposit/intl/en/cli/exit_transaction_keystore.json b/staking_deposit/intl/en/cli/exit_transaction_keystore.json new file mode 100644 index 00000000..91f33b85 --- /dev/null +++ b/staking_deposit/intl/en/cli/exit_transaction_keystore.json @@ -0,0 +1,35 @@ +{ + "exit_transaction_keystore": { + "arg_exit_transaction_keystore" :{ + "help": "Generate an exit transaction that can be used to exit validators on Ethereum Beacon Chain." + }, + "arg_exit_transaction_keystore_chain": { + "help": "The name of the Ethereum PoS chain your validator is running on. \"mainnet\" is the default.", + "prompt": "Please choose the (mainnet or testnet) network/chain name" + }, + "arg_exit_transaction_keystore_epoch": { + "help": "The epoch of when the exit transaction will be valid. The transaction will always be valid by default." + }, + "arg_exit_transaction_keystore_keystore": { + "help": "The keystore file associated with the validator you wish to exit.", + "prompt": "Please enter the location of your keystore file." + }, + "arg_exit_transaction_keystore_keystore_password": { + "help": "The password that is used to encrypt the provided keystore. Note: It's not your mnemonic password. (It is recommended not to use this argument, and wait for the CLI to ask you for your password as otherwise it will appear in your shell history.)", + "prompt": "Enter the password that is used to encrypt the provided keystore.", + "mismatch": "Error: The provided keystore password was unable to decrypt this keystore file. Make sure you have the correct password and try again." + }, + "arg_exit_transaction_keystore_output_folder": { + "help": "The folder path where the exit transactions will be saved to. Pointing to `./exit_transactions` by default." + }, + "arg_validator_index": { + "help": "The validator index corresponding to the provided keystore.", + "prompt": "Please enter the validator index of your validator that corresponds to the provided keystore as identified on the beacon chain." + }, + "msg_exit_transaction_creation": "\nCreating your exit transaction...", + "msg_verify_exit_transaction": "\nVerifying your exit transaction...", + "err_verify_exit_transaction": "\nThere was a problem verifying your exit transaction.\nPlease try again", + "msg_creation_success": "\nSuccess!\nYour exit transaction file can be found at: ", + "msg_pause": "\n\nPress any key." + } +} diff --git a/staking_deposit/intl/en/cli/exit_transaction_mnemonic.json b/staking_deposit/intl/en/cli/exit_transaction_mnemonic.json new file mode 100644 index 00000000..722bfa69 --- /dev/null +++ b/staking_deposit/intl/en/cli/exit_transaction_mnemonic.json @@ -0,0 +1,30 @@ +{ + "exit_transaction_mnemonic": { + "arg_exit_transaction_mnemonic" :{ + "help": "Generate an exit transaction that can be used to exit validators on Ethereum Beacon Chain." + }, + "arg_exit_transaction_mnemonic_chain": { + "help": "The name of the Ethereum PoS chain your validator is running on. \"mainnet\" is the default.", + "prompt": "Please choose the (mainnet or testnet) network/chain name" + }, + "arg_exit_transaction_mnemonic_start_index": { + "help": "Enter the index (key number) which you used when you created your keys. The default value is 0.", + "prompt": "Enter the index (key number) which you used when you created your keys. The default value is 0." + }, + "arg_exit_transaction_mnemonic_indices": { + "help": "A list of the validator index number(s) of the certain validator(s)", + "prompt": "Please enter a list of the validator index number(s) of your validator(s) as identified on the beacon chain. Split multiple items with whitespaces or commas." + }, + "arg_exit_transaction_mnemonic_epoch": { + "help": "The epoch of when the exit transaction will be valid. The transaction will always be valid by default." + }, + "arg_exit_transaction_mnemonic_output_folder": { + "help": "The folder path where the exit transactions will be saved to. Pointing to `./exit_transactions` by default." + }, + "msg_exit_transaction_creation": "Creating your exit transactions:\t", + "msg_verify_exit_transaction": "Verifying your exit transactions:\t", + "err_verify_exit_transactions": "\nThere was a problem verifying your exit transactions.\nPlease try again", + "msg_creation_success": "\nSuccess!\nYour exit transaction files can be found at: ", + "msg_pause": "\n\nPress any key." + } +} diff --git a/staking_deposit/intl/en/utils/validation.json b/staking_deposit/intl/en/utils/validation.json index 3674fd71..ad54eef2 100644 --- a/staking_deposit/intl/en/utils/validation.json +++ b/staking_deposit/intl/en/utils/validation.json @@ -17,7 +17,7 @@ "msg_ECDSA_hex_addr_withdrawal": "**[Warning] you are setting an Eth1 address as your withdrawal address. Please ensure that you have control over this address.**" }, "validate_bls_withdrawal_credentials": { - "err_is_already_eth1_form": "The given withdrawal credentials is already in ETH1_ADDRESS_WITHDRAWAL_PREFIX form. Have you already set the EL (eth1) withdrawal addresss?", + "err_is_already_eth1_form": "The given withdrawal credentials is already in ETH1_ADDRESS_WITHDRAWAL_PREFIX form. Have you already set the EL (eth1) withdrawal address?", "err_not_bls_form": "The given withdrawal credentials is not in BLS_WITHDRAWAL_PREFIX form." }, "validate_bls_withdrawal_credentials_matching": { @@ -28,9 +28,12 @@ }, "normalize_bls_withdrawal_credentials_to_bytes" :{ "err_incorrect_hex_form": "The given input is not in hexadecimal encoded form." - }, "normalize_input_list": { "err_incorrect_list": "The given input should be a list of the old BLS withdrawal credentials of your validator(s). Split multiple items with whitespaces or commas." + }, + "validate_keystore_file": { + "err_file_not_found": "No file was found. Please verify the provided path and try again.", + "err_invalid_keystore_file": "The discovered file is not the correct keystore file format. Please verify the provided path is to a keystore file and try again." } } diff --git a/staking_deposit/key_handling/keystore.py b/staking_deposit/key_handling/keystore.py index b71e2e16..cc8949e3 100644 --- a/staking_deposit/key_handling/keystore.py +++ b/staking_deposit/key_handling/keystore.py @@ -113,7 +113,7 @@ def from_json(cls, json_dict: Dict[Any, Any]) -> 'Keystore': @classmethod def from_file(cls, path: str) -> 'Keystore': - with open(path, 'r') as f: + with open(path, 'r', encoding='utf-8') as f: return cls.from_json(json.load(f)) @staticmethod diff --git a/staking_deposit/settings.py b/staking_deposit/settings.py index d883a04c..1dbb2c46 100644 --- a/staking_deposit/settings.py +++ b/staking_deposit/settings.py @@ -7,6 +7,7 @@ class BaseChainSetting(NamedTuple): NETWORK_NAME: str GENESIS_FORK_VERSION: bytes + EXIT_FORK_VERSION: bytes # capella fork version for voluntary exits (EIP-7044) GENESIS_VALIDATORS_ROOT: Optional[bytes] = None @@ -20,32 +21,44 @@ class BaseChainSetting(NamedTuple): # Mainnet setting MainnetSetting = BaseChainSetting( - NETWORK_NAME=MAINNET, GENESIS_FORK_VERSION=bytes.fromhex('00000000'), + NETWORK_NAME=MAINNET, + GENESIS_FORK_VERSION=bytes.fromhex('00000000'), + EXIT_FORK_VERSION=bytes.fromhex('03000000'), GENESIS_VALIDATORS_ROOT=bytes.fromhex('4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95')) # Goerli setting GoerliSetting = BaseChainSetting( - NETWORK_NAME=GOERLI, GENESIS_FORK_VERSION=bytes.fromhex('00001020'), + NETWORK_NAME=GOERLI, + GENESIS_FORK_VERSION=bytes.fromhex('00001020'), + EXIT_FORK_VERSION=bytes.fromhex('03001020'), GENESIS_VALIDATORS_ROOT=bytes.fromhex('043db0d9a83813551ee2f33450d23797757d430911a9320530ad8a0eabc43efb')) # Sepolia setting SepoliaSetting = BaseChainSetting( - NETWORK_NAME=SEPOLIA, GENESIS_FORK_VERSION=bytes.fromhex('90000069'), + NETWORK_NAME=SEPOLIA, + GENESIS_FORK_VERSION=bytes.fromhex('90000069'), + EXIT_FORK_VERSION=bytes.fromhex('90000072'), GENESIS_VALIDATORS_ROOT=bytes.fromhex('d8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078')) # Zhejiang setting ZhejiangSetting = BaseChainSetting( - NETWORK_NAME=ZHEJIANG, GENESIS_FORK_VERSION=bytes.fromhex('00000069'), + NETWORK_NAME=ZHEJIANG, + GENESIS_FORK_VERSION=bytes.fromhex('00000069'), + EXIT_FORK_VERSION=bytes.fromhex('00000072'), GENESIS_VALIDATORS_ROOT=bytes.fromhex('53a92d8f2bb1d85f62d16a156e6ebcd1bcaba652d0900b2c2f387826f3481f6f')) # Holesky setting HoleskySetting = BaseChainSetting( - NETWORK_NAME=HOLESKY, GENESIS_FORK_VERSION=bytes.fromhex('01017000'), + NETWORK_NAME=HOLESKY, + GENESIS_FORK_VERSION=bytes.fromhex('01017000'), + EXIT_FORK_VERSION=bytes.fromhex('04017000'), GENESIS_VALIDATORS_ROOT=bytes.fromhex('9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1')) # Ephemery setting -# Upcoming EXIT_FORK_VERSION=bytes.fromhex('4000101b'), # for Ephemery # From https://github.com/ephemery-testnet/ephemery-genesis/blob/master/values.env -# There is no builtin GENESIS_VALIDATORS_ROOT since the root changes with each reset. -# You can manually obtain the GENESIS_VALIDATORS_ROOT with each reset on -# https://github.com/ephemery-testnet/ephemery-genesis/releases EphemerySetting = BaseChainSetting( - NETWORK_NAME=EPHEMERY, GENESIS_FORK_VERSION=bytes.fromhex('1000101b')) + NETWORK_NAME=EPHEMERY, + EXIT_FORK_VERSION=bytes.fromhex('4000101b'), + GENESIS_FORK_VERSION=bytes.fromhex('1000101b'), + # There is no builtin GENESIS_VALIDATORS_ROOT since the root changes with each reset. + # You can manually obtain the GENESIS_VALIDATORS_ROOT with each reset on + # https://github.com/ephemery-testnet/ephemery-genesis/releases + GENESIS_VALIDATORS_ROOT=None) ALL_CHAINS: Dict[str, BaseChainSetting] = { @@ -58,6 +71,8 @@ class BaseChainSetting(NamedTuple): EPHEMERY: EphemerySetting, } +NON_PRATER_CHAIN_KEYS: list[str] = list(key for key in ALL_CHAINS.keys() if key != PRATER) + def get_chain_setting(chain_name: str = MAINNET) -> BaseChainSetting: return ALL_CHAINS[chain_name] @@ -65,9 +80,11 @@ def get_chain_setting(chain_name: str = MAINNET) -> BaseChainSetting: def get_devnet_chain_setting(network_name: str, genesis_fork_version: str, + exit_fork_version: str, genesis_validator_root: str) -> BaseChainSetting: return BaseChainSetting( NETWORK_NAME=network_name, GENESIS_FORK_VERSION=decode_hex(genesis_fork_version), + EXIT_FORK_VERSION=decode_hex(exit_fork_version), GENESIS_VALIDATORS_ROOT=decode_hex(genesis_validator_root), ) diff --git a/staking_deposit/utils/constants.py b/staking_deposit/utils/constants.py index cd64ecde..3e1e8307 100644 --- a/staking_deposit/utils/constants.py +++ b/staking_deposit/utils/constants.py @@ -9,6 +9,7 @@ # Execution-spec constants taken from https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md DOMAIN_DEPOSIT = bytes.fromhex('03000000') +DOMAIN_VOLUNTARY_EXIT = bytes.fromhex('04000000') DOMAIN_BLS_TO_EXECUTION_CHANGE = bytes.fromhex('0A000000') BLS_WITHDRAWAL_PREFIX = bytes.fromhex('00') ETH1_ADDRESS_WITHDRAWAL_PREFIX = bytes.fromhex('01') @@ -22,6 +23,7 @@ WORD_LISTS_PATH = os.path.join('staking_deposit', 'key_handling', 'key_derivation', 'word_lists') DEFAULT_VALIDATOR_KEYS_FOLDER_NAME = 'validator_keys' DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME = 'bls_to_execution_changes' +DEFAULT_EXIT_TRANSACTION_FOLDER_NAME = 'exit_transactions' # Internationalisation constants INTL_CONTENT_PATH = os.path.join('staking_deposit', 'intl') diff --git a/staking_deposit/utils/ssz.py b/staking_deposit/utils/ssz.py index 5e027029..21c0bdd1 100644 --- a/staking_deposit/utils/ssz.py +++ b/staking_deposit/utils/ssz.py @@ -10,6 +10,7 @@ from staking_deposit.utils.constants import ( DOMAIN_BLS_TO_EXECUTION_CHANGE, DOMAIN_DEPOSIT, + DOMAIN_VOLUNTARY_EXIT, ZERO_BYTES32, ) @@ -56,6 +57,17 @@ def compute_deposit_domain(fork_version: bytes) -> bytes: return domain_type + fork_data_root[:28] +def compute_voluntary_exit_domain(fork_version: bytes, genesis_validators_root: bytes) -> bytes: + """ + VOLUNTARY_EXIT-only `compute_domain` + """ + if len(fork_version) != 4: + raise ValueError(f"Fork version should be in 4 bytes. Got {len(fork_version)}.") + domain_type = DOMAIN_VOLUNTARY_EXIT + fork_data_root = compute_fork_data_root(fork_version, genesis_validators_root) + return domain_type + fork_data_root[:28] + + def compute_bls_to_execution_change_domain(fork_version: bytes, genesis_validators_root: bytes) -> bytes: """ BLS_TO_EXECUTION_CHANGE-only `compute_domain` @@ -132,3 +144,23 @@ class SignedBLSToExecutionChange(Serializable): ('message', BLSToExecutionChange), ('signature', bytes96), ] + + +class VoluntaryExit(Serializable): + """ + Ref: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#voluntaryexit + """ + fields = [ + ('epoch', uint64), + ('validator_index', uint64) + ] + + +class SignedVoluntaryExit(Serializable): + """ + Ref: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#signedvoluntaryexit + """ + fields = [ + ('message', VoluntaryExit), + ('signature', bytes96), + ] diff --git a/staking_deposit/utils/validation.py b/staking_deposit/utils/validation.py index e227d027..0cd7c1bf 100644 --- a/staking_deposit/utils/validation.py +++ b/staking_deposit/utils/validation.py @@ -12,14 +12,18 @@ from py_ecc.bls import G2ProofOfPossession as bls from staking_deposit.exceptions import ValidationError +from staking_deposit.key_handling.keystore import Keystore from staking_deposit.utils.intl import load_text from staking_deposit.utils.ssz import ( BLSToExecutionChange, DepositData, DepositMessage, + SignedVoluntaryExit, + VoluntaryExit, compute_bls_to_execution_change_domain, compute_deposit_domain, compute_signing_root, + compute_voluntary_exit_domain, ) from staking_deposit.credentials import ( Credential, @@ -42,7 +46,7 @@ def verify_deposit_data_json(filefolder: str, credentials: Sequence[Credential]) """ Validate every deposit found in the deposit-data JSON file folder. """ - with open(filefolder, 'r') as f: + with open(filefolder, 'r', encoding='utf-8') as f: deposit_json = json.load(f) with click.progressbar(deposit_json, label=load_text(['msg_deposit_verification']), show_percent=False, show_pos=True) as deposits: @@ -140,6 +144,7 @@ def validate_eth1_withdrawal_address(cts: click.Context, param: Any, address: st click.echo('\n%s\n' % load_text(['msg_ECDSA_hex_addr_withdrawal'])) return normalized_address + # # BLSToExecutionChange # @@ -154,7 +159,7 @@ def verify_bls_to_execution_change_json(filefolder: str, """ Validate every BLSToExecutionChange found in the bls_to_execution_change JSON file folder. """ - with open(filefolder, 'r') as f: + with open(filefolder, 'r', encoding='utf-8') as f: btec_json = json.load(f) with click.progressbar(btec_json, label=load_text(['msg_bls_to_execution_change_verification']), show_percent=False, show_pos=True) as btecs: @@ -260,11 +265,55 @@ def validate_bls_withdrawal_credentials_list(input_bls_withdrawal_credentials_li def validate_validator_indices(input_validator_indices: str) -> Sequence[int]: - normalized_list = normalize_input_list(input_validator_indices) - return [validate_int_range(int(index), 0, 2**32) for index in normalized_list] + return [validate_int_range(index, 0, 2**32) for index in normalized_list] def validate_bls_withdrawal_credentials_matching(bls_withdrawal_credentials: bytes, credential: Credential) -> None: if bls_withdrawal_credentials[1:] != SHA256(credential.withdrawal_pk)[1:]: raise ValidationError(load_text(['err_not_matching']) + '\n') + + +# +# Exit Message Generation +# + + +def validate_keystore_file(file_path: str) -> Keystore: + try: + saved_keystore = Keystore.from_file(file_path) + except FileNotFoundError: + # Required as captive_prompt_callback does not utilize click type argument for validation + raise ValidationError(load_text(['err_file_not_found']) + '\n') + except Exception: + raise ValidationError(load_text(['err_invalid_keystore_file']) + '\n') + return saved_keystore + + +def verify_signed_exit_json(file_folder: str, pubkey: str, chain_settings: BaseChainSetting) -> bool: + with open(file_folder, 'r', encoding='utf-8') as f: + deposit_json: SignedVoluntaryExit = json.load(f) + signature = deposit_json["signature"] + message = deposit_json["message"] + return validate_signed_exit(message["validator_index"], message["epoch"], signature, pubkey, chain_settings) + + +def validate_signed_exit(validator_index: str, + epoch: str, + signature: str, + pubkey: str, + chain_settings: BaseChainSetting) -> bool: + bls_pubkey = BLSPubkey(bytes.fromhex(pubkey)) + bls_signature = BLSSignature(decode_hex(signature)) + message = VoluntaryExit( # type: ignore[no-untyped-call] + epoch=int(epoch), + validator_index=int(validator_index) + ) + + domain = compute_voluntary_exit_domain( + fork_version=chain_settings.EXIT_FORK_VERSION, + genesis_validators_root=chain_settings.GENESIS_VALIDATORS_ROOT + ) + + signing_root = compute_signing_root(message, domain) + return bls.Verify(bls_pubkey, signing_root, bls_signature) diff --git a/tests/test_cli/helpers.py b/tests/test_cli/helpers.py index 4342791b..eff3ab2e 100644 --- a/tests/test_cli/helpers.py +++ b/tests/test_cli/helpers.py @@ -1,8 +1,10 @@ +import json import os from staking_deposit.key_handling.keystore import Keystore from staking_deposit.utils.constants import ( DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME, + DEFAULT_EXIT_TRANSACTION_FOLDER_NAME, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ) @@ -17,6 +19,11 @@ def clean_btec_folder(my_folder_path: str) -> None: clean_folder(my_folder_path, sub_folder_path) +def clean_exit_transaction_folder(my_folder_path: str) -> None: + sub_folder_path = os.path.join(my_folder_path, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + clean_folder(my_folder_path, sub_folder_path) + + def clean_folder(primary_folder_path: str, sub_folder_path: str) -> None: if not os.path.exists(sub_folder_path): return @@ -49,3 +56,8 @@ def prepare_testing_folder(os_ref, testing_folder_name='TESTING_TEMP_FOLDER'): if not os_ref.path.exists(my_folder_path): os_ref.mkdir(my_folder_path) return my_folder_path + + +def read_json_file(path: str, file_name: str): + with open(os.path.join(path, file_name), 'r', encoding='utf-8') as f: + return json.load(f) diff --git a/tests/test_cli/test_existing_mnemonic.py b/tests/test_cli/test_existing_mnemonic.py index 722591f4..3a16b92d 100644 --- a/tests/test_cli/test_existing_mnemonic.py +++ b/tests/test_cli/test_existing_mnemonic.py @@ -30,7 +30,7 @@ def test_existing_mnemonic_bls_withdrawal() -> None: 'existing-mnemonic', '--eth1_withdrawal_address', '', '--folder', my_folder_path, - '--mnemonic-password', 'TREZOR', + '--mnemonic_password', 'TREZOR', ] result = runner.invoke(cli, arguments, input=data) @@ -73,7 +73,7 @@ def test_existing_mnemonic_eth1_address_withdrawal() -> None: '--language', 'english', 'existing-mnemonic', '--folder', my_folder_path, - '--mnemonic-password', 'TREZOR', + '--mnemonic_password', 'TREZOR', ] result = runner.invoke(cli, arguments, input=data) @@ -84,7 +84,7 @@ def test_existing_mnemonic_eth1_address_withdrawal() -> None: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) @@ -131,7 +131,7 @@ def test_existing_mnemonic_eth1_address_withdrawal_bad_checksum() -> None: '--language', 'english', 'existing-mnemonic', '--folder', my_folder_path, - '--mnemonic-password', 'TREZOR', + '--mnemonic_password', 'TREZOR', ] result = runner.invoke(cli, arguments, input=data) @@ -142,7 +142,7 @@ def test_existing_mnemonic_eth1_address_withdrawal_bad_checksum() -> None: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) @@ -189,7 +189,7 @@ async def test_script() -> None: 'existing-mnemonic', '--num_validators', '1', '--mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"', - '--mnemonic-password', 'TREZOR', + '--mnemonic_password', 'TREZOR', '--validator_start_index', '1', '--chain', 'mainnet', '--keystore_password', 'MyPassword', @@ -237,7 +237,7 @@ async def test_script_abbreviated_mnemonic() -> None: 'existing-mnemonic', '--num_validators', '1', '--mnemonic="aban aban aban aban aban aban aban aban aban aban aban abou"', - '--mnemonic-password', 'TREZOR', + '--mnemonic_password', 'TREZOR', '--validator_start_index', '1', '--chain', 'mainnet', '--keystore_password', 'MyPassword', diff --git a/tests/test_cli/test_exit_transaction_keystore.py b/tests/test_cli/test_exit_transaction_keystore.py new file mode 100644 index 00000000..0cf29775 --- /dev/null +++ b/tests/test_cli/test_exit_transaction_keystore.py @@ -0,0 +1,189 @@ +import os + +from click.testing import CliRunner + +from staking_deposit.credentials import Credential +from staking_deposit.deposit import cli +from staking_deposit.settings import get_chain_setting +from staking_deposit.utils.constants import DEFAULT_EXIT_TRANSACTION_FOLDER_NAME +from staking_deposit.utils.intl import ( + load_text, +) +from tests.test_cli.helpers import ( + clean_exit_transaction_folder, + read_json_file, + verify_file_permission, +) + + +def test_exit_transaction_keystore() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + exit_transaction_folder_path = os.path.join(my_folder_path, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + clean_exit_transaction_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + if not os.path.exists(exit_transaction_folder_path): + os.mkdir(exit_transaction_folder_path) + + # Shared parameters + chain = 'mainnet' + keystore_password = 'solo-stakers' + + # Prepare credential + credential = Credential( + mnemonic='aban aban aban aban aban aban aban aban aban aban aban abou', + mnemonic_password='', + index=0, + amount=0, + chain_setting=get_chain_setting(chain), + hex_eth1_withdrawal_address=None + ) + + # Save keystore file + keystore_filepath = credential.save_signing_keystore(keystore_password, exit_transaction_folder_path) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-keystore', + '--output_folder', my_folder_path, + '--chain', chain, + '--keystore', keystore_filepath, + '--keystore_password', keystore_password, + '--validator_index', '1', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 0 + + # Check files + _, _, exit_transaction_files = next(os.walk(exit_transaction_folder_path)) + + # Filter files to signed_exit as keystore file will exist as well + exit_transaction_file = [f for f in exit_transaction_files if 'signed_exit' in f] + + assert len(set(exit_transaction_file)) == 1 + + json_data = read_json_file(exit_transaction_folder_path, exit_transaction_file[0]) + + # Verify file content + assert json_data['message']['epoch'] == '1234' + assert json_data['message']['validator_index'] == '1' + assert json_data['signature'] + + # Verify file permissions + verify_file_permission(os, folder_path=exit_transaction_folder_path, files=exit_transaction_file) + + # Clean up + clean_exit_transaction_folder(my_folder_path) + + +def test_invalid_keystore_path() -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_exit_transaction_folder(my_folder_path) + + invalid_keystore_file = os.path.join(os.getcwd(), 'README.md') + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-keystore', + '--output_folder', my_folder_path, + '--chain', "mainnet", + '--keystore', invalid_keystore_file, + '--keystore_password', "password", + '--validator_index', '1', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 2 + + clean_exit_transaction_folder(my_folder_path) + + +def test_invalid_keystore_file() -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_exit_transaction_folder(my_folder_path) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-keystore', + '--output_folder', my_folder_path, + '--chain', "mainnet", + '--keystore', "invalid_keystore_path", + '--keystore_password', "password", + '--validator_index', '1', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 2 + + clean_exit_transaction_folder(my_folder_path) + + +def test_invalid_keystore_password() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + exit_transaction_folder_path = os.path.join(my_folder_path, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + clean_exit_transaction_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + if not os.path.exists(exit_transaction_folder_path): + os.mkdir(exit_transaction_folder_path) + + # Shared parameters + chain = 'mainnet' + keystore_password = 'solo-stakers' + + # Prepare credential + credential = Credential( + mnemonic='aban aban aban aban aban aban aban aban aban aban aban abou', + mnemonic_password='', + index=0, + amount=0, + chain_setting=get_chain_setting(chain), + hex_eth1_withdrawal_address=None + ) + + # Save keystore file + keystore_filepath = credential.save_signing_keystore(keystore_password, exit_transaction_folder_path) + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-keystore', + '--output_folder', my_folder_path, + '--chain', chain, + '--keystore', keystore_filepath, + '--keystore_password', "incorrect_password", + '--validator_index', '1', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 1 + + mnemonic_json_file = os.path.join(os.getcwd(), 'staking_deposit/cli/', 'exit_transaction_keystore.json') + assert load_text( + ['arg_exit_transaction_keystore_keystore_password', 'mismatch'], + mnemonic_json_file, + 'exit_transaction_keystore' + ) in result.output + + clean_exit_transaction_folder(my_folder_path) diff --git a/tests/test_cli/test_exit_transaction_mnemonic.py b/tests/test_cli/test_exit_transaction_mnemonic.py new file mode 100644 index 00000000..cd783e6f --- /dev/null +++ b/tests/test_cli/test_exit_transaction_mnemonic.py @@ -0,0 +1,136 @@ +import os +import pytest + +from click.testing import CliRunner + +from staking_deposit.deposit import cli +from staking_deposit.utils.constants import DEFAULT_EXIT_TRANSACTION_FOLDER_NAME + +from tests.test_cli.helpers import clean_exit_transaction_folder, read_json_file, verify_file_permission + + +def test_exit_transaction_menmonic() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_exit_transaction_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-mnemonic', + '--output_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', 'aban aban aban aban aban aban aban aban aban aban aban abou', + '--validator_start_index', '0', + '--validator_indices', '1', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 0 + + # Check files + exit_transaction_folder_path = os.path.join(my_folder_path, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + _, _, exit_transaction_files = next(os.walk(exit_transaction_folder_path)) + + assert len(set(exit_transaction_files)) == 1 + + json_data = read_json_file(exit_transaction_folder_path, exit_transaction_files[0]) + + # Verify file content + assert json_data['message']['epoch'] == '1234' + assert json_data['message']['validator_index'] == '1' + assert json_data['signature'] + + # Verify file permissions + verify_file_permission(os, folder_path=exit_transaction_folder_path, files=exit_transaction_files) + + # Clean up + clean_exit_transaction_folder(my_folder_path) + + +def test_exit_transaction_menmonic_multiple() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_exit_transaction_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-mnemonic', + '--output_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', 'aban aban aban aban aban aban aban aban aban aban aban abou', + '--validator_start_index', '0', + '--validator_indices', '0 1 2 3', + '--epoch', '1234', + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 0 + + # Check files + exit_transaction_folder_path = os.path.join(my_folder_path, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME) + _, _, exit_transaction_files = next(os.walk(exit_transaction_folder_path)) + + assert len(set(exit_transaction_files)) == 4 + + # Verify file content + exit_transaction_files.sort() + for index in [0, 1, 2, 3]: + json_data = read_json_file(exit_transaction_folder_path, exit_transaction_files[index]) + assert json_data['message']['epoch'] == '1234' + assert json_data['message']['validator_index'] == str(index) + assert json_data['signature'] + + # Verify file permissions + verify_file_permission(os, folder_path=exit_transaction_folder_path, files=exit_transaction_files) + + # Clean up + clean_exit_transaction_folder(my_folder_path) + + +@pytest.mark.parametrize( + 'chain, mnemonic, start_index, indices, epoch, assertion', + [ + ('asdf', "aban aban aban aban aban aban aban aban aban aban aban abou", 0, 0, 0, 1), + ('holesky', "this is not valid", 0, 0, 0, 1), + # 2 exit code due to thrown ValidationError + ('holesky', "aban aban aban aban aban aban aban aban aban aban aban abou", "a", 0, 0, 2), + ('holesky', "aban aban aban aban aban aban aban aban aban aban aban abou", 0, "b", 0, 1), + ('holesky', "aban aban aban aban aban aban aban aban aban aban aban abou", 0, 0, "c", 1), + ] +) +def test_exit_mnemonic_invalid_params(chain, mnemonic, start_index, indices, epoch, assertion) -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_exit_transaction_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'exit-transaction-mnemonic', + '--output_folder', my_folder_path, + '--chain', chain, + '--mnemonic', mnemonic, + '--validator_start_index', start_index, + '--validator_indices', indices, + '--epoch', epoch, + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == assertion diff --git a/tests/test_cli/test_new_mnemonic.py b/tests/test_cli/test_new_mnemonic.py index 64add2ce..40b76eb9 100644 --- a/tests/test_cli/test_new_mnemonic.py +++ b/tests/test_cli/test_new_mnemonic.py @@ -95,7 +95,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) @@ -155,7 +155,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) @@ -211,7 +211,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) @@ -326,7 +326,7 @@ async def test_script_bls_withdrawal() -> None: _, _, key_files = next(os.walk(validator_keys_folder_path)) deposit_file = [key_file for key_file in key_files if key_file.startswith('deposit_data')][0] - with open(validator_keys_folder_path + '/' + deposit_file, 'r') as f: + with open(validator_keys_folder_path + '/' + deposit_file, 'r', encoding='utf-8') as f: deposits_dict = json.load(f) for deposit in deposits_dict: withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) diff --git a/tests/test_cli/test_regeneration.py b/tests/test_cli/test_regeneration.py index 2adecc3e..2cccc80c 100644 --- a/tests/test_cli/test_regeneration.py +++ b/tests/test_cli/test_regeneration.py @@ -86,9 +86,9 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: # Finally: # Check the index=1 files have the same pubkey assert '1_0_0' in part_1_key_files[1] and '1_0_0' in part_2_key_files[0] - with open(Path(validator_keys_folder_path_1 + '/' + part_1_key_files[1])) as f: + with open(Path(validator_keys_folder_path_1 + '/' + part_1_key_files[1]), encoding='utf-8') as f: keystore_1_1 = json.load(f) - with open(Path(validator_keys_folder_path_2 + '/' + part_2_key_files[0])) as f: + with open(Path(validator_keys_folder_path_2 + '/' + part_2_key_files[0]), encoding='utf-8') as f: keystore_2_0 = json.load(f) assert keystore_1_1['pubkey'] == keystore_2_0['pubkey'] assert keystore_1_1['path'] == keystore_2_0['path'] diff --git a/tests/test_key_handling/test_key_derivation/test_path.py b/tests/test_key_handling/test_key_derivation/test_path.py index 87e001ac..0bfc1dd6 100644 --- a/tests/test_key_handling/test_key_derivation/test_path.py +++ b/tests/test_key_handling/test_key_derivation/test_path.py @@ -16,7 +16,7 @@ test_vector_filefolder = os.path.join(os.getcwd(), 'tests', 'test_key_handling', 'test_key_derivation', 'test_vectors', 'tree_kdf_intermediate.json') -with open(test_vector_filefolder, 'r') as f: +with open(test_vector_filefolder, 'r', encoding='utf-8') as f: test_vector_dict = json.load(f) diff --git a/tests/test_key_handling/test_key_derivation/test_tree.py b/tests/test_key_handling/test_key_derivation/test_tree.py index 39511d07..8f1e7a09 100644 --- a/tests/test_key_handling/test_key_derivation/test_tree.py +++ b/tests/test_key_handling/test_key_derivation/test_tree.py @@ -13,7 +13,7 @@ test_vector_filefolder = os.path.join(os.getcwd(), 'tests', 'test_key_handling', 'test_key_derivation', 'test_vectors', 'tree_kdf.json') -with open(test_vector_filefolder, 'r') as f: +with open(test_vector_filefolder, 'r', encoding='utf-8') as f: test_vectors = json.load(f)['kdf_tests'] diff --git a/tests/test_key_handling/test_keystore.py b/tests/test_key_handling/test_keystore.py index 696aa201..91ef3b87 100644 --- a/tests/test_key_handling/test_keystore.py +++ b/tests/test_key_handling/test_keystore.py @@ -19,7 +19,7 @@ def test_json_serialization() -> None: for keystore, keystore_json_file in zip(test_vector_keystores, test_vector_files): keystore_json_path = os.path.join(test_vector_folder, keystore_json_file) - with open(keystore_json_path) as f: + with open(keystore_json_path, encoding='utf-8') as f: assert json.loads(keystore.as_json()) == json.load(f) diff --git a/tests/test_utils/test_validation.py b/tests/test_utils/test_validation.py index 9b918462..f3c959ac 100644 --- a/tests/test_utils/test_validation.py +++ b/tests/test_utils/test_validation.py @@ -4,10 +4,12 @@ ) from staking_deposit.exceptions import ValidationError +from staking_deposit.settings import get_chain_setting from staking_deposit.utils.validation import ( normalize_input_list, validate_int_range, validate_password_strength, + validate_signed_exit, ) @@ -60,3 +62,38 @@ def test_validate_int_range(num: Any, low: int, high: int, valid: bool) -> None: ) def test_normalize_input_list(input, result): assert normalize_input_list(input) == result + + +valid_pubkey = "911e7c7fc980bcf5400980917ee92797d52d226768e1b26985fabaf5f214464059ab2d52170b0605f4c8e7a872cde436" +valid_signature = ( + "0x854053a7faebf4547ca3904ff14d896a994d5fb7289478681842fb72622364cd0cb4922170a370ea53234a734b47cd6" + "80c7edca86e8d796abd8eaeb8dd85d99e57c962c84d6642dff4b6e9bfb6d6df5fa22910c583f13135f5b2b43e4f95e8cf" +) +invalid_pubkey = "b54186e3dbdde180cc39f52e0cf4207c5745a50e2e8bd12f49b925f87682cab88ef108f60cf3ea1ac82b7c6fe796f5d6" +invalid_signature = ( + "0x8cceb99d17361031e01dfb6aa997554a35f60bcc8a106ac76fdea6f5a4780fb8b65b4cd827bca0c88b340508b69f577" + "50122db94c319aa05a0165e71b41f30c0982c415727b7e2387cce78d995acd54f038b743dc1426b0fb0d4783617d4fe6e" +) + + +@pytest.mark.parametrize( + 'chain, epoch, validator_index, pubkey, signature, result', + [ + # valid + ('mainnet', 0, 0, valid_pubkey, valid_signature, True), + # bad chain + ('holesky', 0, 0, valid_pubkey, valid_signature, False), + # bad epoch + ('mainnet', 1, 0, valid_pubkey, valid_signature, False), + # bad validator_index + ('mainnet', 0, 1, valid_pubkey, valid_signature, False), + # bad pubkey + ('mainnet', 0, 0, invalid_pubkey, valid_signature, False), + # bad signature + ('mainnet', 0, 0, valid_pubkey, invalid_signature, False), + ] +) +def test_validate_signed_exit(chain, epoch, validator_index, pubkey, signature, result): + chain_settings = get_chain_setting(chain) + + assert validate_signed_exit(validator_index, epoch, signature, pubkey, chain_settings) == result