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 37f1a23b..9681cfcc 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 @@ -253,9 +281,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) @@ -321,9 +351,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 @@ -405,9 +437,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 @@ -533,9 +567,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..63725cf8 --- /dev/null +++ b/staking_deposit/cli/exit_transaction_keystore.py @@ -0,0 +1,117 @@ +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 ALL_CHAINS, MAINNET, PRATER, get_chain_setting +from staking_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from staking_deposit.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.utils.validation import validate_int_range + + +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, list(ALL_CHAINS.keys())), + choice_prompt_func( + lambda: load_text(['arg_exit_transaction_keystore_chain', 'prompt'], func=FUNC_NAME), + list(ALL_CHAINS.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), + # 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) + ), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: x, + 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: str, + keystore_password: str, + validator_index: int, + epoch: int, + output_folder: str, + **kwargs: Any) -> None: + saved_keystore = Keystore.from_file(keystore) + + try: + secret_bytes = saved_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, + ) + + saved_folder = export_exit_transaction_json(folder=output_folder, signed_exit=signed_exit) + + 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..1e0eeae4 --- /dev/null +++ b/staking_deposit/cli/exit_transaction_mnemonic.py @@ -0,0 +1,115 @@ +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.exit_transaction import exit_transaction_generation, export_exit_transaction_json +from staking_deposit.settings import ALL_CHAINS, MAINNET, PRATER, get_chain_setting +from staking_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from staking_deposit.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.utils.validation import validate_int_range, validate_validator_indices + + +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, list(ALL_CHAINS.keys())), + choice_prompt_func( + lambda: load_text(['arg_exit_transaction_mnemonic_chain', 'prompt'], func=FUNC_NAME), + list(ALL_CHAINS.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), + # 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) + ), +) +@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: + + chain_settings = get_chain_setting(chain) + num_keys = len(validator_indices) + key_indices = range(validator_start_index, validator_start_index + num_keys) + + click.echo(load_text(['msg_creation_start'])) + # We assume that the list of validator indices are in order and increment the start index + for key_index, validator_index in zip(key_indices, validator_indices): + credential = 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 + ) + + signing_key = credential.signing_sk + + signed_voluntary_exit = exit_transaction_generation( + chain_settings=chain_settings, + signing_key=signing_key, + validator_index=validator_index, + epoch=epoch + ) + + saved_folder = export_exit_transaction_json(folder=output_folder, signed_exit=signed_voluntary_exit) + click.echo(load_text(['msg_creation_success']) + saved_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..8fb27195 100644 --- a/staking_deposit/cli/generate_bls_to_execution_change.py +++ b/staking_deposit/cli/generate_bls_to_execution_change.py @@ -155,6 +155,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/deposit.py b/staking_deposit/deposit.py index 66acaa91..826fcb63 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 ( @@ -55,6 +57,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) if __name__ == '__main__': diff --git a/staking_deposit/exit_transaction.py b/staking_deposit/exit_transaction.py new file mode 100644 index 00000000..186bc07c --- /dev/null +++ b/staking_deposit/exit_transaction.py @@ -0,0 +1,68 @@ +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.constants import DEFAULT_EXIT_TRANSACTION_FOLDER_NAME +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( + 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( + 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), + 'validator_index': str(signed_exit.message.validator_index), + } + signed_exit_json.update({'message': message}) + signed_exit_json.update({'signature': '0x' + signed_exit.signature.hex()}) + + output_folder = os.path.join( + folder, + DEFAULT_EXIT_TRANSACTION_FOLDER_NAME, + ) + if not os.path.exists(output_folder): + os.mkdir(output_folder) + + filefolder = os.path.join( + output_folder, + 'signed_exit_transaction-%s-%i.json' % (signed_exit.message.validator_index, time.time()) + ) + + 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..10c05d11 --- /dev/null +++ b/staking_deposit/intl/en/cli/exit_transaction_keystore.json @@ -0,0 +1,32 @@ +{ + "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 password does not match the provided keystore. Please 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_creation_success": "\nSuccess!\nYour SignedExitTransaction JSON 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..a7a69559 --- /dev/null +++ b/staking_deposit/intl/en/cli/exit_transaction_mnemonic.json @@ -0,0 +1,28 @@ +{ + "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_creation_start": "Your signed exit transaction(s) will now be created for each validator index provided...", + "msg_creation_success": "SignedExitTransaction JSON file created. It can be found at: ", + "msg_pause": "\n\nPress any key." + } +} diff --git a/staking_deposit/settings.py b/staking_deposit/settings.py index e989d89b..f58fe59e 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: bytes @@ -19,23 +20,33 @@ 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')) @@ -55,9 +66,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 513b0096..ffa0455a 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/tests/test_cli/helpers.py b/tests/test_cli/helpers.py index 4342791b..13b6c74c 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') as f: + return json.load(f) diff --git a/tests/test_cli/test_existing_menmonic.py b/tests/test_cli/test_existing_menmonic.py index d41ce464..e3ada0ce 100644 --- a/tests/test_cli/test_existing_menmonic.py +++ b/tests/test_cli/test_existing_menmonic.py @@ -29,7 +29,7 @@ def test_existing_mnemonic_bls_withdrawal() -> None: '--language', 'english', 'existing-mnemonic', '--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', '--eth1_withdrawal_address', eth1_withdrawal_address, ] result = runner.invoke(cli, arguments, input=data) @@ -132,7 +132,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', '--eth1_withdrawal_address', wrong_eth1_withdrawal_address, ] result = runner.invoke(cli, arguments, input=data) @@ -191,7 +191,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', @@ -238,7 +238,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..253b546e --- /dev/null +++ b/tests/test_cli/test_exit_transaction_keystore.py @@ -0,0 +1,81 @@ +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 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') + 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) 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..27a5f7b2 --- /dev/null +++ b/tests/test_cli/test_exit_transaction_mnemonic.py @@ -0,0 +1,99 @@ +import os + +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)