diff --git a/.circleci/config.yml b/.circleci/config.yml index 85ce3e15..797c0720 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,12 +30,12 @@ jobs: steps: - checkout - restore_cache: - key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v2 + key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v3 - run: name: Install requirements in venv command: make venv_build_test - save_cache: - key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v2 + key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v3 paths: - ./venv venv_pytest: @@ -45,7 +45,7 @@ jobs: steps: - checkout - restore_cache: - key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v2 + key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v3 - run: name: Run tests with venv command: make venv_test @@ -61,7 +61,7 @@ jobs: steps: - checkout - restore_cache: - key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v2 + key: venv-deps2-{{ arch }}-{{ .Branch }}-{{ checksum "requirements.txt" }}-{{ checksum "requirements_test.txt" }}-{{ checksum "setup.py" }}-{{ checksum "Makefile" }}-v3 - run: name: Run linter with venv command: make venv_lint @@ -105,6 +105,9 @@ jobs: - run: name: Run deposit script on Windows" command: python ./test_deposit_script.py + - run: + name: Run btec script on Windows" + command: python ./test_btec_script.py build-linux-amd64: machine: image: ubuntu-2004:202201-02 @@ -135,9 +138,11 @@ jobs: export TEST_FOLDER_NAME=TMP_TEST_FOLDER mkdir ${TEST_FOLDER_NAME} cp -r ${BUILD_FILE_NAME} ${TEST_FOLDER_NAME} - cp test_binary_script.py ${TEST_FOLDER_NAME} + cp test_binary_deposit_script.py ${TEST_FOLDER_NAME} + cp test_binary_btec_script.py ${TEST_FOLDER_NAME} cd ${TEST_FOLDER_NAME} - python test_binary_script.py ./${BUILD_FILE_NAME}; + python test_binary_deposit_script.py ./${BUILD_FILE_NAME}; + python test_binary_btec_script.py ./${BUILD_FILE_NAME}; - run: name: Compress the file command: | @@ -181,9 +186,11 @@ jobs: export TEST_FOLDER_NAME=TMP_TEST_FOLDER mkdir ${TEST_FOLDER_NAME} cp -r ${BUILD_FILE_NAME} ${TEST_FOLDER_NAME} - cp test_binary_script.py ${TEST_FOLDER_NAME} + cp test_binary_deposit_script.py ${TEST_FOLDER_NAME} + cp test_binary_btec_script.py ${TEST_FOLDER_NAME} cd ${TEST_FOLDER_NAME} - python test_binary_script.py ./${BUILD_FILE_NAME}; + python test_binary_deposit_script.py ./${BUILD_FILE_NAME}; + python test_binary_btec_script.py ./${BUILD_FILE_NAME}; - run: name: Compress the file command: | @@ -225,9 +232,11 @@ jobs: $TEST_FOLDER_NAME = "TMP_TEST_FOLDER" mkdir ${TEST_FOLDER_NAME} Copy-item ${BUILD_FILE_NAME} -destination ${TEST_FOLDER_NAME} -recurse - copy test_binary_script.py ${TEST_FOLDER_NAME} + copy test_binary_deposit_script.py ${TEST_FOLDER_NAME} + copy test_binary_btec_script.py ${TEST_FOLDER_NAME} cd ${TEST_FOLDER_NAME} - python test_binary_script.py ${BUILD_FILE_NAME} + python test_binary_deposit_script.py ${BUILD_FILE_NAME} + python test_binary_btec_script.py ${BUILD_FILE_NAME} - run: name: Compress the file command: | @@ -271,9 +280,11 @@ jobs: export TEST_FOLDER_NAME=TMP_TEST_FOLDER mkdir ${TEST_FOLDER_NAME} cp -r ${BUILD_FILE_NAME} ${TEST_FOLDER_NAME} - cp test_binary_script.py ${TEST_FOLDER_NAME} + cp test_binary_deposit_script.py ${TEST_FOLDER_NAME} + cp test_binary_btec_script.py ${TEST_FOLDER_NAME} cd ${TEST_FOLDER_NAME} - python3 test_binary_script.py ./${BUILD_FILE_NAME}; + python3 test_binary_deposit_script.py ./${BUILD_FILE_NAME}; + python3 test_binary_btec_script.py ./${BUILD_FILE_NAME}; - run: name: Compress the file command: | diff --git a/.gitignore b/.gitignore index e4add855..d8c216b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ validator_keys +bls_to_execution_changes # Python testing & linting: build/ diff --git a/README.md b/README.md index eec5bd7b..d9373cae 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ - [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) + - [`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) @@ -112,7 +114,7 @@ or run the following command to enter the interactive CLI and generate keys from ./deposit existing-mnemonic ``` -###### language Argument +###### `language` Argument The Launchpad offers many language/internationalization options. If you wish to select one as a CLI argument, it must be passed in before one of the commands is chosen. @@ -120,6 +122,14 @@ The Launchpad offers many language/internationalization options. If you wish to | -------- | -------- | -------- | | `--language` | String. Options: `العربية`, `ελληνικά`, `English`, `Français`, `Bahasa melayu`, `Italiano`, `日本語`, `한국어`, `Português do Brasil`, `român`, `简体中文`. Default to `English` | The language you wish to use the CLI in. | +###### `--non_interactive` flag + +**Warning: with this flag, there will be no confirmation step(s) to verify the input value(s). Please use it carefully.** + +| Argument | Type | Description | +| -------- | -------- | -------- | +| `--non_interactive` | Flag | Run CLI in non-interactive mode. | + ###### Commands The CLI offers different commands depending on what you want to do with the tool. @@ -136,10 +146,10 @@ You can use `new-mnemonic --help` to see all arguments. Note that if there are m | Argument | Type | Description | | -------- | -------- | -------- | | `--num_validators` | Non-negative integer | The number of signing keys you want to generate. Note that the child key(s) are generated via the same master key. | -| `--mnemonic_language` | String. Options: `简体中文`, `繁體中文`, `český jazyk`, `English`, `Italiano`, `한국어`, `Português`, `Español`. Default to `English` | The mnemonic language | +| `--mnemonic_language` | String. Options: `简体中文`, `繁體中文`, `český jazyk`, `English`, `Italiano`, `한국어`, `Português`, `Español`. Default to `English` | The language of the mnemonic word list | | `--folder` | String. Pointing to `./validator_keys` by default | The folder path for the keystore(s) and deposit(s) | | `--chain` | String. `mainnet` by default | The chain setting for the signing domain. | -| `--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 [EIP-2334 format](https://eips.ethereum.org/EIPS/eip-2334#eth2-specific-parameters). | +| `--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). | ###### `existing-mnemonic` Arguments @@ -151,7 +161,7 @@ You can use `existing-mnemonic --help` to see all arguments. Note that if there | `--num_validators` | Non-negative integer | The number of new signing keys you want to generate. Note that the child key(s) are generated via the same master key. | | `--folder` | String. Pointing to `./validator_keys` by default | The folder path for the keystore(s) and deposit(s) | | `--chain` | String. `mainnet` by default | The chain setting for the signing domain. | -| `--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 [EIP-2334 format](https://eips.ethereum.org/EIPS/eip-2334#eth2-specific-parameters). | +| `--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). | ###### Successful message @@ -168,6 +178,22 @@ Success! Your keys can be found at: ``` +###### `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. + +| Argument | Type | Description | +| -------- | -------- | -------- | +| `--bls_to_execution_changes_folder` | String. Pointing to `./bls_to_execution_changes` by default | The folder path for the `bls_to_execution_change-*` JSON file(s) | +| `--chain` | String. `mainnet` by default | The chain setting for the signing domain. | +| `--mnemonic` | String. mnemonic split by space. | The mnemonic you used to create withdrawal credentials. | +| `--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 withdrawal credentials 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. | +| `--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. | + #### Option 2. Build `deposit-cli` with native Python ##### Step 0. Python version checking @@ -228,6 +254,7 @@ See [here](#commands) 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 ###### Successful message See [here](#successful-message) @@ -295,6 +322,7 @@ See [here](#commands) 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 #### Option 4. Use Docker image @@ -378,6 +406,7 @@ See [here](#commands) 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 #### Option 2. Build `deposit-cli` with native Python @@ -440,6 +469,7 @@ See [here](#commands) 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 #### Option 3. Build `deposit-cli` with `virtualenv` @@ -504,6 +534,7 @@ See [here](#commands) 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 ## Development diff --git a/setup.py b/setup.py index e0b96e07..3f34b5be 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="staking_deposit", - version='2.4.0', + version='2.5.0', py_modules=["staking_deposit"], packages=find_packages(exclude=('tests', 'docs')), python_requires=">=3.8,<4", diff --git a/staking_deposit/cli/existing_mnemonic.py b/staking_deposit/cli/existing_mnemonic.py index 97e090f6..8aa87394 100644 --- a/staking_deposit/cli/existing_mnemonic.py +++ b/staking_deposit/cli/existing_mnemonic.py @@ -1,6 +1,7 @@ import click from typing import ( Any, + Callable, ) from staking_deposit.exceptions import ValidationError @@ -22,6 +23,39 @@ ) +def load_mnemonic_arguments_decorator(function: Callable[..., Any]) -> Callable[..., Any]: + ''' + This is a decorator that, when applied to a parent-command, implements the + to obtain the necessary arguments for the generate_keys() subcommand. + ''' + decorators = [ + jit_option( + callback=validate_mnemonic, + help=lambda: load_text(['arg_mnemonic', 'help'], func='existing_mnemonic'), + param_decls='--mnemonic', + prompt=lambda: load_text(['arg_mnemonic', 'prompt'], func='existing_mnemonic'), + type=str, + ), + jit_option( + callback=captive_prompt_callback( + lambda x: x, + lambda: load_text(['arg_mnemonic_password', 'prompt'], func='existing_mnemonic'), + lambda: load_text(['arg_mnemonic_password', 'confirm'], func='existing_mnemonic'), + lambda: load_text(['arg_mnemonic_password', 'mismatch'], func='existing_mnemonic'), + True, + ), + default='', + help=lambda: load_text(['arg_mnemonic_password', 'help'], func='existing_mnemonic'), + hidden=True, + param_decls='--mnemonic-password', + prompt=False, + ), + ] + for decorator in reversed(decorators): + function = decorator(function) + return function + + def validate_mnemonic(ctx: click.Context, param: Any, mnemonic: str) -> str: mnemonic = reconstruct_mnemonic(mnemonic, WORD_LISTS_PATH) if mnemonic is not None: @@ -33,27 +67,7 @@ def validate_mnemonic(ctx: click.Context, param: Any, mnemonic: str) -> str: @click.command( help=load_text(['arg_existing_mnemonic', 'help'], func='existing_mnemonic'), ) -@jit_option( - callback=validate_mnemonic, - help=lambda: load_text(['arg_mnemonic', 'help'], func='existing_mnemonic'), - param_decls='--mnemonic', - prompt=lambda: load_text(['arg_mnemonic', 'prompt'], func='existing_mnemonic'), - type=str, -) -@jit_option( - callback=captive_prompt_callback( - lambda x: x, - lambda: load_text(['arg_mnemonic_password', 'prompt'], func='existing_mnemonic'), - lambda: load_text(['arg_mnemonic_password', 'confirm'], func='existing_mnemonic'), - lambda: load_text(['arg_mnemonic_password', 'mismatch'], func='existing_mnemonic'), - True, - ), - default='', - help=lambda: load_text(['arg_mnemonic_password', 'help'], func='existing_mnemonic'), - hidden=True, - param_decls='--mnemonic-password', - prompt=False, -) +@load_mnemonic_arguments_decorator @jit_option( callback=captive_prompt_callback( lambda num: validate_int_range(num, 0, 2**32), diff --git a/staking_deposit/cli/generate_bls_to_execution_change.py b/staking_deposit/cli/generate_bls_to_execution_change.py new file mode 100644 index 00000000..0b3b0f49 --- /dev/null +++ b/staking_deposit/cli/generate_bls_to_execution_change.py @@ -0,0 +1,202 @@ +import os +import click +import json +from typing import ( + Any, + Sequence, +) + +from eth_typing import HexAddress + +from staking_deposit.credentials import ( + CredentialList, +) +from staking_deposit.utils.validation import ( + validate_bls_withdrawal_credentials_list, + validate_bls_withdrawal_credentials_matching, + validate_eth1_withdrawal_address, + validate_int_range, + verify_bls_to_execution_change_json, + validate_validator_indices, +) +from staking_deposit.utils.constants import ( + DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME, + MAX_DEPOSIT_AMOUNT, +) +from staking_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from staking_deposit.exceptions import ValidationError +from staking_deposit.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.settings import ( + ALL_CHAINS, + MAINNET, + PRATER, + get_chain_setting, + get_devnet_chain_setting, +) +from .existing_mnemonic import ( + load_mnemonic_arguments_decorator, +) + + +def get_password(text: str) -> str: + return click.prompt(text, hide_input=True, show_default=False, type=str) + + +FUNC_NAME = 'generate_bls_to_execution_change' + + +@click.command( + help=load_text(['arg_generate_bls_to_execution_change', 'help'], func=FUNC_NAME), +) +@jit_option( + default=os.getcwd(), + help=lambda: load_text(['arg_bls_to_execution_changes_folder', 'help'], func=FUNC_NAME), + param_decls='--bls_to_execution_changes_folder', + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: closest_match(x, list(ALL_CHAINS.keys())), + choice_prompt_func( + lambda: load_text(['arg_chain', 'prompt'], func=FUNC_NAME), + list(ALL_CHAINS.keys()) + ), + ), + default=MAINNET, + help=lambda: load_text(['arg_chain', 'help'], func=FUNC_NAME), + 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) + ), +) +@load_mnemonic_arguments_decorator +@jit_option( + callback=captive_prompt_callback( + lambda num: validate_int_range(num, 0, 2**32), + lambda: load_text(['arg_validator_start_index', 'prompt'], func=FUNC_NAME), + ), + default=0, + help=lambda: load_text(['arg_validator_start_index', 'help'], func=FUNC_NAME), + param_decls="--validator_start_index", + prompt=lambda: load_text(['arg_validator_start_index', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda validator_indices: validate_validator_indices(validator_indices), + lambda: load_text(['arg_validator_indices', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_validator_indices', 'help'], func=FUNC_NAME), + param_decls='--validator_indices', + prompt=lambda: load_text(['arg_validator_indices', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda bls_withdrawal_credentials_list: + validate_bls_withdrawal_credentials_list(bls_withdrawal_credentials_list), + lambda: load_text(['arg_bls_withdrawal_credentials_list', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_bls_withdrawal_credentials_list', 'help'], func=FUNC_NAME), + param_decls='--bls_withdrawal_credentials_list', + prompt=lambda: load_text(['arg_bls_withdrawal_credentials_list', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda address: validate_eth1_withdrawal_address(None, None, address), + lambda: load_text(['arg_execution_address', 'prompt'], func=FUNC_NAME), + lambda: load_text(['arg_execution_address', 'confirm'], func=FUNC_NAME), + lambda: load_text(['arg_execution_address', 'mismatch'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_execution_address', 'help'], func=FUNC_NAME), + param_decls=['--execution_address', '--eth1_withdrawal_address'], + prompt=lambda: load_text(['arg_execution_address', 'prompt'], func=FUNC_NAME), +) +@jit_option( + # Only for devnet tests + default=None, + help="[DEVNET ONLY] Set specific GENESIS_FORK_VERSION value", + param_decls='--devnet_chain_setting', +) +@click.pass_context +def generate_bls_to_execution_change( + ctx: click.Context, + bls_to_execution_changes_folder: str, + chain: str, + mnemonic: str, + mnemonic_password: str, + validator_start_index: int, + validator_indices: Sequence[int], + bls_withdrawal_credentials_list: Sequence[bytes], + execution_address: HexAddress, + devnet_chain_setting: str, + **kwargs: Any) -> None: + # Generate folder + bls_to_execution_changes_folder = os.path.join( + bls_to_execution_changes_folder, + DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME, + ) + if not os.path.exists(bls_to_execution_changes_folder): + os.mkdir(bls_to_execution_changes_folder) + + # Get chain setting + chain_setting = get_chain_setting(chain) + + if devnet_chain_setting is not None: + click.echo('\n%s\n' % '**[Warning] Using devnet chain setting to generate the SignedBLSToExecutionChange.**\t') + devnet_chain_setting_dict = json.loads(devnet_chain_setting) + chain_setting = get_devnet_chain_setting( + network_name=devnet_chain_setting_dict['network_name'], + genesis_fork_version=devnet_chain_setting_dict['genesis_fork_version'], + genesis_validator_root=devnet_chain_setting_dict['genesis_validator_root'], + ) + + if len(validator_indices) != len(bls_withdrawal_credentials_list): + raise ValueError( + "The size of `validator_indices` (%d) should be as same as `bls_withdrawal_credentials_list` (%d)." + % (len(validator_indices), len(bls_withdrawal_credentials_list)) + ) + + num_validators = len(validator_indices) + amounts = [MAX_DEPOSIT_AMOUNT] * num_validators + + credentials = CredentialList.from_mnemonic( + mnemonic=mnemonic, + mnemonic_password=mnemonic_password, + num_keys=num_validators, + amounts=amounts, + chain_setting=chain_setting, + start_index=validator_start_index, + hex_eth1_withdrawal_address=execution_address, + ) + + # Check if the given old bls_withdrawal_credentials is as same as the mnemonic generated + for i, credential in enumerate(credentials.credentials): + try: + validate_bls_withdrawal_credentials_matching(bls_withdrawal_credentials_list[i], credential) + except ValidationError as e: + click.echo('\n[Error] ' + str(e)) + return + + btec_file = credentials.export_bls_to_execution_change_json(bls_to_execution_changes_folder, validator_indices) + + json_file_validation_result = verify_bls_to_execution_change_json( + btec_file, + credentials.credentials, + input_validator_indices=validator_indices, + input_execution_address=execution_address, + chain_setting=chain_setting, + ) + if not json_file_validation_result: + raise ValidationError(load_text(['err_verify_btec'])) + + click.echo(load_text(['msg_creation_success']) + str(bls_to_execution_changes_folder)) + + click.pause(load_text(['msg_pause'])) diff --git a/staking_deposit/cli/generate_keys.py b/staking_deposit/cli/generate_keys.py index 4caa3b7b..9163d55c 100644 --- a/staking_deposit/cli/generate_keys.py +++ b/staking_deposit/cli/generate_keys.py @@ -6,8 +6,6 @@ ) from eth_typing import HexAddress -from eth_utils import is_hex_address, to_normalized_address - from staking_deposit.credentials import ( CredentialList, ) @@ -16,6 +14,7 @@ verify_deposit_data_json, validate_int_range, validate_password_strength, + validate_eth1_withdrawal_address, ) from staking_deposit.utils.constants import ( MAX_DEPOSIT_AMOUNT, @@ -43,17 +42,6 @@ def get_password(text: str) -> str: return click.prompt(text, hide_input=True, show_default=False, type=str) -def validate_eth1_withdrawal_address(cts: click.Context, param: Any, address: str) -> HexAddress: - if address is None: - return None - if not is_hex_address(address): - raise ValueError(load_text(['err_invalid_ECDSA_hex_addr'])) - - normalized_address = to_normalized_address(address) - click.echo('\n%s\n' % load_text(['msg_ECDSA_addr_withdrawal'])) - return normalized_address - - def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[..., Any]: ''' This is a decorator that, when applied to a parent-command, implements the @@ -106,10 +94,15 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ prompt=lambda: load_text(['keystore_password', 'prompt'], func='generate_keys_arguments_decorator'), ), jit_option( - callback=validate_eth1_withdrawal_address, + callback=captive_prompt_callback( + lambda address: validate_eth1_withdrawal_address(None, None, address), + lambda: load_text(['arg_execution_address', 'prompt'], func='generate_keys_arguments_decorator'), + lambda: load_text(['arg_execution_address', 'confirm'], func='generate_keys_arguments_decorator'), + lambda: load_text(['arg_execution_address', 'mismatch'], func='generate_keys_arguments_decorator'), + ), default=None, - help=lambda: load_text(['eth1_withdrawal_address', 'help'], func='generate_keys_arguments_decorator'), - param_decls='--eth1_withdrawal_address', + help=lambda: load_text(['arg_execution_address', 'help'], func='generate_keys_arguments_decorator'), + param_decls=['--execution_address', '--eth1_withdrawal_address'], ), ] for decorator in reversed(decorators): @@ -121,7 +114,7 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ @click.pass_context def generate_keys(ctx: click.Context, validator_start_index: int, num_validators: int, folder: str, chain: str, keystore_password: str, - eth1_withdrawal_address: HexAddress, **kwargs: Any) -> None: + execution_address: HexAddress, **kwargs: Any) -> None: mnemonic = ctx.obj['mnemonic'] mnemonic_password = ctx.obj['mnemonic_password'] amounts = [MAX_DEPOSIT_AMOUNT] * num_validators @@ -139,7 +132,7 @@ def generate_keys(ctx: click.Context, validator_start_index: int, amounts=amounts, chain_setting=chain_setting, start_index=validator_start_index, - hex_eth1_withdrawal_address=eth1_withdrawal_address, + hex_eth1_withdrawal_address=execution_address, ) keystore_filefolders = credentials.export_keystores(password=keystore_password, folder=folder) deposits_file = credentials.export_deposit_data_json(folder=folder) diff --git a/staking_deposit/credentials.py b/staking_deposit/credentials.py index 80a4ef86..9d70ead8 100644 --- a/staking_deposit/credentials.py +++ b/staking_deposit/credentials.py @@ -3,7 +3,7 @@ from enum import Enum import time import json -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any, Sequence from eth_typing import Address, HexAddress from eth_utils import to_canonical_address @@ -27,9 +27,12 @@ from staking_deposit.utils.intl import load_text from staking_deposit.utils.ssz import ( compute_deposit_domain, + compute_bls_to_execution_change_domain, compute_signing_root, + BLSToExecutionChange, DepositData, DepositMessage, + SignedBLSToExecutionChange, ) @@ -158,6 +161,48 @@ def verify_keystore(self, keystore_filefolder: str, password: str) -> bool: secret_bytes = saved_keystore.decrypt(password) return self.signing_sk == int.from_bytes(secret_bytes, 'big') + def get_bls_to_execution_change(self, validator_index: int) -> SignedBLSToExecutionChange: + if self.eth1_withdrawal_address is None: + raise ValueError("The execution address should NOT be empty.") + + message = BLSToExecutionChange( + validator_index=validator_index, + from_bls_pubkey=self.withdrawal_pk, + to_execution_address=self.eth1_withdrawal_address, + ) + domain = compute_bls_to_execution_change_domain( + fork_version=self.chain_setting.GENESIS_FORK_VERSION, + genesis_validators_root=self.chain_setting.GENESIS_VALIDATORS_ROOT, + ) + signing_root = compute_signing_root(message, domain) + signature = bls.Sign(self.withdrawal_sk, signing_root) + + return SignedBLSToExecutionChange( + message=message, + signature=signature, + ) + + def get_bls_to_execution_change_dict(self, validator_index: int) -> Dict[str, bytes]: + result_dict: Dict[str, Any] = {} + signed_bls_to_execution_change = self.get_bls_to_execution_change(validator_index) + message = { + 'validator_index': str(signed_bls_to_execution_change.message.validator_index), + 'from_bls_pubkey': '0x' + signed_bls_to_execution_change.message.from_bls_pubkey.hex(), + 'to_execution_address': '0x' + signed_bls_to_execution_change.message.to_execution_address.hex(), + } + result_dict.update({'message': message}) + result_dict.update({'signature': '0x' + signed_bls_to_execution_change.signature.hex()}) + + # metadata + metadata: Dict[str, Any] = { + 'network_name': self.chain_setting.NETWORK_NAME, + 'genesis_validators_root': '0x' + self.chain_setting.GENESIS_VALIDATORS_ROOT.hex(), + 'deposit_cli_version': DEPOSIT_CLI_VERSION, + } + + result_dict.update({'metadata': metadata}) + return result_dict + class CredentialList: """ @@ -210,3 +255,16 @@ def verify_keystores(self, keystore_filefolders: List[str], password: str) -> bo length=len(self.credentials), show_percent=False, show_pos=True) as items: return all(credential.verify_keystore(keystore_filefolder=filefolder, password=password) for credential, filefolder in items) + + def export_bls_to_execution_change_json(self, folder: str, validator_indices: Sequence[int]) -> str: + with click.progressbar(self.credentials, label=load_text(['msg_bls_to_execution_change_creation']), + show_percent=False, show_pos=True) as credentials: + bls_to_execution_changes = [cred.get_bls_to_execution_change_dict(validator_indices[i]) + for i, cred in enumerate(credentials)] + + filefolder = os.path.join(folder, 'bls_to_execution_change-%i.json' % time.time()) + with open(filefolder, 'w') as f: + json.dump(bls_to_execution_changes, f) + if os.name == 'posix': + os.chmod(filefolder, int('440', 8)) # Read for owner & group + return filefolder diff --git a/staking_deposit/deposit.py b/staking_deposit/deposit.py index c224bfd2..66acaa91 100644 --- a/staking_deposit/deposit.py +++ b/staking_deposit/deposit.py @@ -2,6 +2,7 @@ import sys from staking_deposit.cli.existing_mnemonic import existing_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 ( captive_prompt_callback, @@ -43,8 +44,8 @@ def check_python_version() -> None: '--non_interactive', default=False, is_flag=True, - help='Disables interactive prompts.', - hidden=True, + help='Disables interactive prompts. Warning: with this flag, there will be no confirmation step(s) to verify the input value(s). Please use it carefully.', # noqa: E501 + hidden=False, ) def cli(ctx: click.Context, language: str, non_interactive: bool) -> None: config.language = language @@ -53,8 +54,10 @@ 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) if __name__ == '__main__': check_python_version() + print('\n***Using the tool on an offline and secure device is highly recommended to keep your mnemonic safe.***\n') cli() diff --git a/staking_deposit/intl/en/cli/generate_bls_to_execution_change.json b/staking_deposit/intl/en/cli/generate_bls_to_execution_change.json new file mode 100644 index 00000000..cca5adfd --- /dev/null +++ b/staking_deposit/intl/en/cli/generate_bls_to_execution_change.json @@ -0,0 +1,41 @@ +{ + "generate_bls_to_execution_change": { + "arg_generate_bls_to_execution_change" :{ + "help": "Generating the SignedBLSToExecutionChange data to enable withdrawals on Ethereum Beacon Chain." + }, + "arg_execution_address": { + "help": "The 20-byte (Eth1) execution address that will be used in withdrawal", + "prompt": "Please enter the 20-byte execution address for the new withdrawal credentials. Note that you CANNOT change it once you have set it on chain.", + "confirm": "Repeat your execution address for confirmation.", + "mismatch": "Error: the two entered values do not match. Please type again." + }, + "arg_validator_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_bls_withdrawal_credentials_list": { + "help": "A list of 32-byte old BLS withdrawal credentials of the certain validator(s)", + "prompt": "Please enter a list of the old BLS withdrawal credentials of your validator(s). Split multiple items with whitespaces or commas. The withdrawal credentials are in hexadecimal encoded form." + }, + "arg_validator_start_index": { + "help": "The index position for the keys to start generating withdrawal credentials in ERC-2334 format", + "prompt": "Please enter the index position for the keys to start generating withdrawal credentials in ERC-2334 format.", + "confirm": "Please repeat the index to confirm" + }, + "arg_chain": { + "help": "The name of Ethereum PoS chain you are targeting. Use \"mainnet\" if you are depositing ETH", + "prompt": "Please choose the (mainnet or testnet) network/chain name" + }, + "arg_fork": { + "help": "The fork name of the fork you want to signing the message with.", + "prompt": "Please choose the fork name of the fork you want to signing the message with." + }, + "arg_bls_to_execution_changes_folder": { + "help": "The folder path for the keystore(s). Pointing to `./bls_to_execution_changes` by default." + }, + "msg_key_creation": "Creating your SignedBLSToExecutionChange.", + "msg_creation_success": "\nSuccess!\nYour SignedBLSToExecutionChange JSON file can be found at: ", + "msg_pause": "\n\nPress any key.", + "err_verify_btec": "Failed to verify the bls_to_execution_change JSON files." + } +} diff --git a/staking_deposit/intl/en/cli/generate_keys.json b/staking_deposit/intl/en/cli/generate_keys.json index c1f33b3b..f49b65d6 100644 --- a/staking_deposit/intl/en/cli/generate_keys.json +++ b/staking_deposit/intl/en/cli/generate_keys.json @@ -1,8 +1,4 @@ { - "validate_eth1_withdrawal_address": { - "err_invalid_ECDSA_hex_addr": "The given Eth1 address is not in hexadecimal encoded form.", - "msg_ECDSA_addr_withdrawal": "**[Warning] you are setting an Eth1 address as your withdrawal address. Please ensure that you have control over this address.**" - }, "generate_keys_arguments_decorator": { "num_validators": { "help": "The number of new validator keys you want to generate (you can always generate more later)", @@ -21,8 +17,11 @@ "confirm": "Repeat your keystore password for confirmation", "mismatch": "Error: the two entered values do not match. Please type again." }, - "eth1_withdrawal_address": { - "help": "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." + "arg_execution_address": { + "help": "The 20-byte (Eth1) execution address that will be used in withdrawal", + "prompt": "Please enter the 20-byte execution address for the new withdrawal credentials. Note that you CANNOT change it once you have set it on chain.", + "confirm": "Repeat your execution address for confirmation.", + "mismatch": "Error: the two entered values do not match. Please type again." } }, "generate_keys": { diff --git a/staking_deposit/intl/en/cli/new_mnemonic.json b/staking_deposit/intl/en/cli/new_mnemonic.json index feee3ac2..9a0d3d89 100644 --- a/staking_deposit/intl/en/cli/new_mnemonic.json +++ b/staking_deposit/intl/en/cli/new_mnemonic.json @@ -5,8 +5,8 @@ }, "arg_mnemonic_language": { "default": "english", - "help": "The language that your mnemonic is in", - "prompt": "Please choose your mnemonic language" + "help": "The language of the mnemonic word list", + "prompt": "Please choose the language of the mnemonic word list" }, "msg_mnemonic_presentation": "This is your mnemonic (seed phrase). Write it down and store it safely. It is the ONLY way to retrieve your deposit.", "msg_press_any_key": "Press any key when you have written down your mnemonic.", diff --git a/staking_deposit/intl/en/credentials.json b/staking_deposit/intl/en/credentials.json index da6f6559..85d72612 100644 --- a/staking_deposit/intl/en/credentials.json +++ b/staking_deposit/intl/en/credentials.json @@ -8,6 +8,9 @@ "export_deposit_data_json": { "msg_depositdata_creation": "Creating your depositdata:\t" }, + "export_bls_to_execution_change_json": { + "msg_bls_to_execution_change_creation": "Creating your SignedBLSToExecutionChange:\t" + }, "verify_keystores": { "msg_keystore_verification": "Verifying your keystores:\t" } diff --git a/staking_deposit/intl/en/utils/validation.json b/staking_deposit/intl/en/utils/validation.json index 50145b22..3674fd71 100644 --- a/staking_deposit/intl/en/utils/validation.json +++ b/staking_deposit/intl/en/utils/validation.json @@ -10,5 +10,27 @@ }, "validate_choice": { "err_invalid_choice": "That is not one of the valid choices. Please retype your choice." + }, + "validate_eth1_withdrawal_address": { + "err_invalid_ECDSA_hex_addr": "The given Eth1 address is not in hexadecimal encoded form.", + "err_invalid_ECDSA_hex_addr_checksum": "The given Eth1 address is not in checksum form.", + "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_not_bls_form": "The given withdrawal credentials is not in BLS_WITHDRAWAL_PREFIX form." + }, + "validate_bls_withdrawal_credentials_matching": { + "err_not_matching": "The given withdrawal credentials does not match the old BLS withdrawal credentials that mnemonic generated." + }, + "verify_bls_to_execution_change_json": { + "msg_bls_to_execution_change_verification": "Verifying your BLSToExecutionChange file:\t" + }, + "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." } -} \ No newline at end of file +} diff --git a/staking_deposit/intl/tr/cli/generate_keys.json b/staking_deposit/intl/tr/cli/generate_keys.json index bcfc3bb5..975a3e4f 100644 --- a/staking_deposit/intl/tr/cli/generate_keys.json +++ b/staking_deposit/intl/tr/cli/generate_keys.json @@ -1,8 +1,4 @@ { - "validate_eth1_withdrawal_address": { - "err_invalid_ECDSA_hex_addr": "Girilen Eth1 adresi onaltılık sistemde kodlanmamıştır.", - "msg_ECDSA_addr_withdrawal": "**[Uyarı] bir Eth1 adresini varlık çekme adresi olarak giriyorsunuz. Lütfen bu adresin kontrolünün sizde olduğundan emin olun.**" - }, "generate_keys_arguments_decorator": { "num_validators": { "help": "Oluşturmak istediğiniz yeni doğrulayıcı anahtarlarının sayısını giriniz (daha fazlasını daha sonra da oluşturabilirsiniz)", diff --git a/staking_deposit/intl/tr/utils/validation.json b/staking_deposit/intl/tr/utils/validation.json index 73fcef98..2c73e99c 100644 --- a/staking_deposit/intl/tr/utils/validation.json +++ b/staking_deposit/intl/tr/utils/validation.json @@ -10,5 +10,9 @@ }, "validate_choice": { "err_invalid_choice": "Geçerli bir seçim değil. Lütfen seçiminizi tekrar girin." + }, + "validate_eth1_withdrawal_address": { + "err_invalid_ECDSA_hex_addr": "Girilen Eth1 adresi onaltılık sistemde kodlanmamıştır.", + "msg_ECDSA_hex_addr_withdrawal": "**[Uyarı] bir Eth1 adresini varlık çekme adresi olarak giriyorsunuz. Lütfen bu adresin kontrolünün sizde olduğundan emin olun.**" } } diff --git a/staking_deposit/settings.py b/staking_deposit/settings.py index aab46570..9cd24d95 100644 --- a/staking_deposit/settings.py +++ b/staking_deposit/settings.py @@ -1,37 +1,41 @@ from typing import Dict, NamedTuple +from eth_utils import decode_hex - -DEPOSIT_CLI_VERSION = '2.4.0' +DEPOSIT_CLI_VERSION = '2.5.0' class BaseChainSetting(NamedTuple): NETWORK_NAME: str GENESIS_FORK_VERSION: bytes + GENESIS_VALIDATORS_ROOT: bytes MAINNET = 'mainnet' -ROPSTEN = 'ropsten' GOERLI = 'goerli' PRATER = 'prater' SEPOLIA = 'sepolia' ZHEJIANG = 'zhejiang' - # Mainnet setting -MainnetSetting = BaseChainSetting(NETWORK_NAME=MAINNET, GENESIS_FORK_VERSION=bytes.fromhex('00000000')) -# Ropsten setting -RopstenSetting = BaseChainSetting(NETWORK_NAME=ROPSTEN, GENESIS_FORK_VERSION=bytes.fromhex('80000069')) +MainnetSetting = BaseChainSetting( + NETWORK_NAME=MAINNET, GENESIS_FORK_VERSION=bytes.fromhex('00000000'), + GENESIS_VALIDATORS_ROOT=bytes.fromhex('4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95')) # Goerli setting -GoerliSetting = BaseChainSetting(NETWORK_NAME=GOERLI, GENESIS_FORK_VERSION=bytes.fromhex('00001020')) +GoerliSetting = BaseChainSetting( + NETWORK_NAME=GOERLI, GENESIS_FORK_VERSION=bytes.fromhex('00001020'), + GENESIS_VALIDATORS_ROOT=bytes.fromhex('043db0d9a83813551ee2f33450d23797757d430911a9320530ad8a0eabc43efb')) # Sepolia setting -SepoliaSetting = BaseChainSetting(NETWORK_NAME=SEPOLIA, GENESIS_FORK_VERSION=bytes.fromhex('90000069')) +SepoliaSetting = BaseChainSetting( + NETWORK_NAME=SEPOLIA, GENESIS_FORK_VERSION=bytes.fromhex('90000069'), + GENESIS_VALIDATORS_ROOT=bytes.fromhex('d8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078')) # Zhejiang setting -ZhejiangSetting = BaseChainSetting(NETWORK_NAME=ZHEJIANG, GENESIS_FORK_VERSION=bytes.fromhex('00000069')) +ZhejiangSetting = BaseChainSetting( + NETWORK_NAME=ZHEJIANG, GENESIS_FORK_VERSION=bytes.fromhex('00000069'), + GENESIS_VALIDATORS_ROOT=bytes.fromhex('53a92d8f2bb1d85f62d16a156e6ebcd1bcaba652d0900b2c2f387826f3481f6f')) ALL_CHAINS: Dict[str, BaseChainSetting] = { MAINNET: MainnetSetting, - ROPSTEN: RopstenSetting, GOERLI: GoerliSetting, PRATER: GoerliSetting, # Prater is the old name of the Prater/Goerli testnet SEPOLIA: SepoliaSetting, @@ -41,3 +45,13 @@ class BaseChainSetting(NamedTuple): def get_chain_setting(chain_name: str = MAINNET) -> BaseChainSetting: return ALL_CHAINS[chain_name] + + +def get_devnet_chain_setting(network_name: str, + genesis_fork_version: str, + genesis_validator_root: str) -> BaseChainSetting: + return BaseChainSetting( + NETWORK_NAME=network_name, + GENESIS_FORK_VERSION=decode_hex(genesis_fork_version), + GENESIS_VALIDATORS_ROOT=decode_hex(genesis_validator_root), + ) diff --git a/staking_deposit/utils/click.py b/staking_deposit/utils/click.py index 8b9b2ae3..ceb36d64 100644 --- a/staking_deposit/utils/click.py +++ b/staking_deposit/utils/click.py @@ -25,7 +25,7 @@ class JITOption(click.Option): ''' def __init__( self, - param_decls: str, + param_decls: Union[str, Sequence[str]], default: Union[Callable[[], Any], None, Any] = None, help: Union[Callable[[], str], str, None] = None, prompt: Union[Callable[[], str], str, None] = None, @@ -36,8 +36,12 @@ def __init__( self.callable_help = help self.callable_prompt = prompt + # `click.Option.Argument.param_decls` takes a list of flags or argument names. + if isinstance(param_decls, str): + param_decls = [_value_of(param_decls)] + return super().__init__( - param_decls=[_value_of(param_decls)], + param_decls=param_decls, default=_value_of(default), help=_value_of(help), prompt=_value_of(prompt), @@ -98,13 +102,13 @@ def callback(ctx: click.Context, param: Any, user_input: str) -> Any: try: processed_input = processing_func(user_input) # Logic for confirming user input: - if confirmation_prompt is not None and processed_input != '': + if confirmation_prompt is not None and processed_input not in ('', None): confirmation_input = click.prompt(confirmation_prompt(), hide_input=hide_input) if processing_func(confirmation_input) != processed_input: raise ValidationError(confirmation_mismatch_msg()) return processed_input except ValidationError as e: - click.echo(e) + click.echo('\n[Error] ' + str(e)) user_input = click.prompt(prompt(), hide_input=hide_input) return callback diff --git a/staking_deposit/utils/constants.py b/staking_deposit/utils/constants.py index 12f6122a..cd64ecde 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_BLS_TO_EXECUTION_CHANGE = bytes.fromhex('0A000000') BLS_WITHDRAWAL_PREFIX = bytes.fromhex('00') ETH1_ADDRESS_WITHDRAWAL_PREFIX = bytes.fromhex('01') @@ -20,6 +21,7 @@ # File/folder constants 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' # 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 e079a007..513b0096 100644 --- a/staking_deposit/utils/ssz.py +++ b/staking_deposit/utils/ssz.py @@ -8,11 +8,13 @@ bytes96 ) from staking_deposit.utils.constants import ( + DOMAIN_BLS_TO_EXECUTION_CHANGE, DOMAIN_DEPOSIT, ZERO_BYTES32, ) bytes8 = ByteVector(8) +bytes20 = ByteVector(20) # Crypto Domain SSZ @@ -31,6 +33,18 @@ class ForkData(Serializable): ] +def compute_fork_data_root(current_version: bytes, genesis_validators_root: bytes) -> bytes: + """ + Return the appropriate ForkData root for a given deposit version. + """ + if len(current_version) != 4: + raise ValueError(f"Fork version should be in 4 bytes. Got {len(current_version)}.") + return ForkData( + current_version=current_version, + genesis_validators_root=genesis_validators_root, + ).hash_tree_root + + def compute_deposit_domain(fork_version: bytes) -> bytes: """ Deposit-only `compute_domain` @@ -42,17 +56,23 @@ def compute_deposit_domain(fork_version: bytes) -> bytes: 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` + """ + if len(fork_version) != 4: + raise ValueError(f"Fork version should be in 4 bytes. Got {len(fork_version)}.") + domain_type = DOMAIN_BLS_TO_EXECUTION_CHANGE + fork_data_root = compute_fork_data_root(fork_version, genesis_validators_root) + return domain_type + fork_data_root[:28] + + def compute_deposit_fork_data_root(current_version: bytes) -> bytes: """ Return the appropriate ForkData root for a given deposit version. """ genesis_validators_root = ZERO_BYTES32 # For deposit, it's fixed value - if len(current_version) != 4: - raise ValueError(f"Fork version should be in 4 bytes. Got {len(current_version)}.") - return ForkData( - current_version=current_version, - genesis_validators_root=genesis_validators_root, - ).hash_tree_root + return compute_fork_data_root(current_version, genesis_validators_root) def compute_signing_root(ssz_object: Serializable, domain: bytes) -> bytes: @@ -91,3 +111,24 @@ class DepositData(Serializable): ('amount', uint64), ('signature', bytes96) ] + + +class BLSToExecutionChange(Serializable): + """ + Ref: https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#blstoexecutionchange + """ + fields = [ + ('validator_index', uint64), + ('from_bls_pubkey', bytes48), + ('to_execution_address', bytes20), + ] + + +class SignedBLSToExecutionChange(Serializable): + """ + Ref: https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#signedblstoexecutionchange + """ + fields = [ + ('message', BLSToExecutionChange), + ('signature', bytes96), + ] diff --git a/staking_deposit/utils/validation.py b/staking_deposit/utils/validation.py index d3bdd7ab..ab6f9849 100644 --- a/staking_deposit/utils/validation.py +++ b/staking_deposit/utils/validation.py @@ -1,20 +1,25 @@ import click import json +import re +from typing import Any, Dict, Sequence + from eth_typing import ( BLSPubkey, BLSSignature, + HexAddress, ) -from typing import Any, Dict, Sequence - +from eth_utils import is_hex_address, is_checksum_address, to_normalized_address, decode_hex from py_ecc.bls import G2ProofOfPossession as bls from staking_deposit.exceptions import ValidationError from staking_deposit.utils.intl import load_text from staking_deposit.utils.ssz import ( - compute_deposit_domain, - compute_signing_root, + BLSToExecutionChange, DepositData, DepositMessage, + compute_bls_to_execution_change_domain, + compute_deposit_domain, + compute_signing_root, ) from staking_deposit.credentials import ( Credential, @@ -26,7 +31,12 @@ ETH1_ADDRESS_WITHDRAWAL_PREFIX, ) from staking_deposit.utils.crypto import SHA256 +from staking_deposit.settings import BaseChainSetting + +# +# Deposit +# def verify_deposit_data_json(filefolder: str, credentials: Sequence[Credential]) -> bool: """ @@ -112,3 +122,145 @@ def validate_int_range(num: Any, low: int, high: int) -> int: return num_int except (ValueError, AssertionError): raise ValidationError(load_text(['err_not_positive_integer'])) + + +def validate_eth1_withdrawal_address(cts: click.Context, param: Any, address: str) -> HexAddress: + if address is None: + return None + if not is_hex_address(address): + raise ValidationError(load_text(['err_invalid_ECDSA_hex_addr'])) + if not is_checksum_address(address): + raise ValidationError(load_text(['err_invalid_ECDSA_hex_addr_checksum'])) + + normalized_address = to_normalized_address(address) + click.echo('\n%s\n' % load_text(['msg_ECDSA_hex_addr_withdrawal'])) + return normalized_address + +# +# BLSToExecutionChange +# + + +def verify_bls_to_execution_change_json(filefolder: str, + credentials: Sequence[Credential], + *, + input_validator_indices: Sequence[int], + input_execution_address: str, + chain_setting: BaseChainSetting) -> bool: + """ + Validate every BLSToExecutionChange found in the bls_to_execution_change JSON file folder. + """ + with open(filefolder, 'r') 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: + return all([ + validate_bls_to_execution_change( + btec, credential, + input_validator_index=input_validator_index, + input_execution_address=input_execution_address, + chain_setting=chain_setting) + for btec, credential, input_validator_index in zip(btecs, credentials, input_validator_indices) + ]) + return False + + +def validate_bls_to_execution_change(btec_dict: Dict[str, Any], + credential: Credential, + *, + input_validator_index: int, + input_execution_address: str, + chain_setting: BaseChainSetting) -> bool: + validator_index = int(btec_dict['message']['validator_index']) + from_bls_pubkey = BLSPubkey(decode_hex(btec_dict['message']['from_bls_pubkey'])) + to_execution_address = decode_hex(btec_dict['message']['to_execution_address']) + signature = BLSSignature(decode_hex(btec_dict['signature'])) + genesis_validators_root = decode_hex(btec_dict['metadata']['genesis_validators_root']) + + if validator_index != input_validator_index: + return False + if from_bls_pubkey != credential.withdrawal_pk: + return False + if ( + to_execution_address != credential.eth1_withdrawal_address + or to_execution_address != decode_hex(input_execution_address) + ): + return False + if genesis_validators_root != chain_setting.GENESIS_VALIDATORS_ROOT: + return False + + message = BLSToExecutionChange( + validator_index=validator_index, + from_bls_pubkey=from_bls_pubkey, + to_execution_address=to_execution_address, + ) + domain = compute_bls_to_execution_change_domain( + fork_version=chain_setting.GENESIS_FORK_VERSION, + genesis_validators_root=genesis_validators_root, + ) + signing_root = compute_signing_root(message, domain) + + if not bls.Verify(BLSPubkey(credential.withdrawal_pk), signing_root, signature): + return False + + return True + + +def normalize_bls_withdrawal_credentials_to_bytes(bls_withdrawal_credentials: str) -> bytes: + if bls_withdrawal_credentials.startswith('0x'): + bls_withdrawal_credentials = bls_withdrawal_credentials[2:] + + try: + bls_withdrawal_credentials_bytes = bytes.fromhex(bls_withdrawal_credentials) + except Exception: + raise ValidationError(load_text(['err_incorrect_hex_form']) + '\n') + return bls_withdrawal_credentials_bytes + + +def is_eth1_address_withdrawal_credentials(withdrawal_credentials: bytes) -> bool: + return ( + len(withdrawal_credentials) == 32 + and withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX + and withdrawal_credentials[1:12] == b'\x00' * 11 + ) + + +def validate_bls_withdrawal_credentials(bls_withdrawal_credentials: str) -> bytes: + bls_withdrawal_credentials_bytes = normalize_bls_withdrawal_credentials_to_bytes(bls_withdrawal_credentials) + + if is_eth1_address_withdrawal_credentials(bls_withdrawal_credentials_bytes): + raise ValidationError(load_text(['err_is_already_eth1_form']) + '\n') + + try: + assert len(bls_withdrawal_credentials_bytes) == 32 + assert bls_withdrawal_credentials_bytes[:1] == BLS_WITHDRAWAL_PREFIX + except (ValueError, AssertionError): + raise ValidationError(load_text(['err_not_bls_form']) + '\n') + + return bls_withdrawal_credentials_bytes + + +def normalize_input_list(input: str) -> Sequence[str]: + try: + input = input.strip('[({})]') + input = re.sub(' +', ' ', input) + result = re.split(r'; |, | |,|;', input) + except Exception: + raise ValidationError(load_text(['err_incorrect_list']) + '\n') + return result + + +def validate_bls_withdrawal_credentials_list(input_bls_withdrawal_credentials_list: str) -> Sequence[bytes]: + bls_withdrawal_credentials_list = normalize_input_list(input_bls_withdrawal_credentials_list) + return [validate_bls_withdrawal_credentials(cred) for cred in bls_withdrawal_credentials_list] + + +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] + + +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') diff --git a/test_binary_btec_script.py b/test_binary_btec_script.py new file mode 100755 index 00000000..6fb92d32 --- /dev/null +++ b/test_binary_btec_script.py @@ -0,0 +1,80 @@ +import asyncio +import os +import sys + + +# For not importing staking_deposit here +DEFAULT_VALIDATOR_KEYS_FOLDER_NAME = 'bls_to_execution_changes' + + +async def main(argv): + binary_file_path = argv[1] + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + if os.name == 'nt': # Windows + run_script_cmd = ".\\" + binary_file_path + '\deposit.exe' + else: # Mac or Linux + run_script_cmd = './' + binary_file_path + '/deposit' + + cmd_args = [ + run_script_cmd, + '--language', 'english', + '--non_interactive', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', '\"sister protect peanut hill ready work profit fit wish want small inflict flip member tail between sick setup bright duck morning sell paper worry\"', + '--bls_withdrawal_credentials_list', '0x00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de', + '--validator_start_index', '0', + '--validator_indices', '1', + '--execution_address', '0x3434343434343434343434343434343434343434', + ] + proc = await asyncio.create_subprocess_shell( + ' '.join(cmd_args), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + seed_phrase = '' + parsing = False + async for out in proc.stdout: + output = out.decode('utf-8').rstrip() + if output.startswith("***Using the tool"): + parsing = True + elif output.startswith("This is your mnemonic"): + parsing = True + elif output.startswith("Please type your mnemonic"): + parsing = False + elif parsing: + seed_phrase += output + if len(seed_phrase) > 0: + encoded_phrase = seed_phrase.encode() + proc.stdin.write(encoded_phrase) + proc.stdin.write(b'\n') + print(output) + + async for out in proc.stderr: + output = out.decode('utf-8').rstrip() + print(f'[stderr] {output}') + + assert len(seed_phrase) > 0 + + # Check files + validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + _, _, key_files = next(os.walk(validator_keys_folder_path)) + + # Clean up + for key_file_name in key_files: + os.remove(os.path.join(validator_keys_folder_path, key_file_name)) + os.rmdir(validator_keys_folder_path) + os.rmdir(my_folder_path) + + +if os.name == 'nt': # Windows + loop = asyncio.ProactorEventLoop() + asyncio.set_event_loop(loop) + loop.run_until_complete(main(sys.argv)) +else: + asyncio.run(main(sys.argv)) diff --git a/test_binary_script.py b/test_binary_deposit_script.py similarity index 100% rename from test_binary_script.py rename to test_binary_deposit_script.py diff --git a/test_btec_script.py b/test_btec_script.py new file mode 100755 index 00000000..6e6e22b7 --- /dev/null +++ b/test_btec_script.py @@ -0,0 +1,85 @@ +import asyncio +import os + +# For not importing staking_deposit here +DEFAULT_VALIDATOR_KEYS_FOLDER_NAME = 'bls_to_execution_changes' + + +async def main(): + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + if os.name == 'nt': # Windows + run_script_cmd = 'sh deposit.sh' + else: # Mac or Linux + run_script_cmd = './deposit.sh' + + install_cmd = run_script_cmd + ' install' + print('[INFO] Creating subprocess 1: installation:' , install_cmd) + proc = await asyncio.create_subprocess_shell( + install_cmd, + ) + await proc.wait() + print('[INFO] Installed') + + cmd_args = [ + run_script_cmd, + '--language', 'english', + '--non_interactive', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', '\"sister protect peanut hill ready work profit fit wish want small inflict flip member tail between sick setup bright duck morning sell paper worry\"', + '--bls_withdrawal_credentials_list', '0x00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de', + '--validator_start_index', '0', + '--validator_indices', '1', + '--execution_address', '0x3434343434343434343434343434343434343434', + ] + proc = await asyncio.create_subprocess_shell( + ' '.join(cmd_args), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + seed_phrase = '' + parsing = False + async for out in proc.stdout: + output = out.decode('utf-8').rstrip() + if output.startswith("***Using the tool"): + parsing = True + elif output.startswith("This is your mnemonic"): + parsing = True + elif output.startswith("Please type your mnemonic"): + parsing = False + elif parsing: + seed_phrase += output + if len(seed_phrase) > 0: + encoded_phrase = seed_phrase.encode() + proc.stdin.write(encoded_phrase) + proc.stdin.write(b'\n') + print(output) + + async for out in proc.stderr: + output = out.decode('utf-8').rstrip() + print(f'[stderr] {output}') + + assert len(seed_phrase) > 0 + + # Check files + validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + _, _, key_files = next(os.walk(validator_keys_folder_path)) + + # Clean up + for key_file_name in key_files: + os.remove(os.path.join(validator_keys_folder_path, key_file_name)) + os.rmdir(validator_keys_folder_path) + os.rmdir(my_folder_path) + + +if os.name == 'nt': # Windows + loop = asyncio.ProactorEventLoop() + asyncio.set_event_loop(loop) + loop.run_until_complete(main()) +else: + asyncio.run(main()) diff --git a/tests/test_cli/helpers.py b/tests/test_cli/helpers.py index 127e04a1..4342791b 100644 --- a/tests/test_cli/helpers.py +++ b/tests/test_cli/helpers.py @@ -1,19 +1,31 @@ import os from staking_deposit.key_handling.keystore import Keystore -from staking_deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME +from staking_deposit.utils.constants import ( + DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME, + DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, +) def clean_key_folder(my_folder_path: str) -> None: - validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) - if not os.path.exists(validator_keys_folder_path): + sub_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + clean_folder(my_folder_path, sub_folder_path) + + +def clean_btec_folder(my_folder_path: str) -> None: + sub_folder_path = os.path.join(my_folder_path, DEFAULT_BLS_TO_EXECUTION_CHANGES_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 - _, _, key_files = next(os.walk(validator_keys_folder_path)) + _, _, key_files = next(os.walk(sub_folder_path)) for key_file_name in key_files: - os.remove(os.path.join(validator_keys_folder_path, key_file_name)) - os.rmdir(validator_keys_folder_path) - os.rmdir(my_folder_path) + os.remove(os.path.join(sub_folder_path, key_file_name)) + os.rmdir(sub_folder_path) + os.rmdir(primary_folder_path) def get_uuid(key_file: str) -> str: @@ -23,3 +35,17 @@ def get_uuid(key_file: str) -> str: def get_permissions(path: str, file_name: str) -> str: return oct(os.stat(os.path.join(path, file_name)).st_mode & 0o777) + + +def verify_file_permission(os_ref, folder_path, files): + if os_ref.name == 'posix': + for file_name in files: + assert get_permissions(folder_path, file_name) == '0o440' + + +def prepare_testing_folder(os_ref, testing_folder_name='TESTING_TEMP_FOLDER'): + my_folder_path = os_ref.path.join(os_ref.getcwd(), testing_folder_name) + clean_btec_folder(my_folder_path) + if not os_ref.path.exists(my_folder_path): + os_ref.mkdir(my_folder_path) + return my_folder_path diff --git a/tests/test_cli/test_existing_menmonic.py b/tests/test_cli/test_existing_menmonic.py index bcd5a8a1..d41ce464 100644 --- a/tests/test_cli/test_existing_menmonic.py +++ b/tests/test_cli/test_existing_menmonic.py @@ -9,7 +9,7 @@ from staking_deposit.deposit import cli from staking_deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ETH1_ADDRESS_WITHDRAWAL_PREFIX -from.helpers import clean_key_folder, get_permissions, get_uuid +from .helpers import clean_key_folder, get_permissions, get_uuid def test_existing_mnemonic_bls_withdrawal() -> None: @@ -62,12 +62,13 @@ def test_existing_mnemonic_eth1_address_withdrawal() -> None: os.mkdir(my_folder_path) runner = CliRunner() + eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' inputs = [ 'TREZOR', + eth1_withdrawal_address, 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', '2', '2', '5', 'mainnet', 'MyPassword', 'MyPassword'] data = '\n'.join(inputs) - eth1_withdrawal_address = '0x00000000219ab540356cbb839cbe05303d7705fa' arguments = [ '--language', 'english', 'existing-mnemonic', @@ -107,6 +108,65 @@ def test_existing_mnemonic_eth1_address_withdrawal() -> None: clean_key_folder(my_folder_path) +def test_existing_mnemonic_eth1_address_withdrawal_bad_checksum() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + + # NOTE: final 'A' needed to be an 'a' + wrong_eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705FA' + correct_eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' + + inputs = [ + 'TREZOR', + correct_eth1_withdrawal_address, correct_eth1_withdrawal_address, + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + '2', '2', '5', 'mainnet', 'MyPassword', 'MyPassword' + ] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'existing-mnemonic', + '--folder', my_folder_path, + '--mnemonic-password', 'TREZOR', + '--eth1_withdrawal_address', wrong_eth1_withdrawal_address, + ] + result = runner.invoke(cli, arguments, input=data) + + assert result.exit_code == 0 + + # Check files + validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + _, _, 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: + deposits_dict = json.load(f) + for deposit in deposits_dict: + withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) + assert withdrawal_credentials == ( + ETH1_ADDRESS_WITHDRAWAL_PREFIX + b'\x00' * 11 + decode_hex(correct_eth1_withdrawal_address) + ) + + all_uuid = [ + get_uuid(validator_keys_folder_path + '/' + key_file) + for key_file in key_files + if key_file.startswith('keystore') + ] + assert len(set(all_uuid)) == 5 + + # Verify file permissions + if os.name == 'posix': + for file_name in key_files: + assert get_permissions(validator_keys_folder_path, file_name) == '0o440' + # Clean up + clean_key_folder(my_folder_path) + + @pytest.mark.asyncio async def test_script() -> None: my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') diff --git a/tests/test_cli/test_generate_bls_to_execution_change.py b/tests/test_cli/test_generate_bls_to_execution_change.py new file mode 100644 index 00000000..bdcb22cf --- /dev/null +++ b/tests/test_cli/test_generate_bls_to_execution_change.py @@ -0,0 +1,121 @@ +import os + +from click.testing import CliRunner + +from staking_deposit.deposit import cli +from staking_deposit.utils.constants import DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME +from .helpers import ( + clean_btec_folder, + prepare_testing_folder, + verify_file_permission, +) + + +def test_existing_mnemonic_bls_withdrawal() -> None: + # Prepare folder + my_folder_path = prepare_testing_folder(os) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', 'sister protect peanut hill ready work profit fit wish want small inflict flip member tail between sick setup bright duck morning sell paper worry', # noqa: E501 + '--bls_withdrawal_credentials_list', '0x00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de', + '--validator_start_index', '0', + '--validator_indices', '1', + '--execution_address', '0x3434343434343434343434343434343434343434', + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + # Check files + bls_to_execution_changes_folder_path = os.path.join(my_folder_path, DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME) + _, _, btec_files = next(os.walk(bls_to_execution_changes_folder_path)) + + # TODO verify file content + assert len(set(btec_files)) == 1 + + # Verify file permissions + verify_file_permission(os, folder_path=bls_to_execution_changes_folder_path, files=btec_files) + + # Clean up + clean_btec_folder(my_folder_path) + + +def test_existing_mnemonic_bls_withdrawal_interactive() -> None: + # Prepare folder + my_folder_path = prepare_testing_folder(os) + + runner = CliRunner() + inputs = [ + 'mainnet', # network/chain + 'sister protect peanut hill ready work profit fit wish want small inflict flip member tail between sick setup bright duck morning sell paper worry', # noqa: E501 + '0', # validator_start_index + '1', # validator_index + '0x00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de', + '0x3434343434343434343434343434343434343434', + '0x3434343434343434343434343434343434343434', + + ] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + # Check files + bls_to_execution_changes_folder_path = os.path.join(my_folder_path, DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME) + _, _, btec_files = next(os.walk(bls_to_execution_changes_folder_path)) + + # TODO verify file content + assert len(set(btec_files)) == 1 + + # Verify file permissions + verify_file_permission(os, folder_path=bls_to_execution_changes_folder_path, files=btec_files) + + # Clean up + clean_btec_folder(my_folder_path) + + +def test_existing_mnemonic_bls_withdrawal_multiple() -> None: + # Prepare folder + my_folder_path = prepare_testing_folder(os) + + runner = CliRunner() + inputs = [] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + '--non_interactive', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + '--chain', 'mainnet', + '--mnemonic', 'sister protect peanut hill ready work profit fit wish want small inflict flip member tail between sick setup bright duck morning sell paper worry', # noqa: E501 + '--bls_withdrawal_credentials_list', '0x00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de, 0x00a75d83f169fa6923f3dd78386d9608fab710d8f7fcf71ba9985893675d5382', # noqa: E501 + '--validator_start_index', '0', + '--validator_indices', '1,2', + '--execution_address', '0x3434343434343434343434343434343434343434', + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + # Check files + bls_to_execution_changes_folder_path = os.path.join(my_folder_path, DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME) + _, _, btec_files = next(os.walk(bls_to_execution_changes_folder_path)) + + # TODO verify file content + assert len(set(btec_files)) == 1 + + # Verify file permissions + verify_file_permission(os, folder_path=bls_to_execution_changes_folder_path, files=btec_files) + + # Clean up + clean_btec_folder(my_folder_path) diff --git a/tests/test_cli/test_new_mnemonic.py b/tests/test_cli/test_new_mnemonic.py index fb70a255..0e091dd3 100644 --- a/tests/test_cli/test_new_mnemonic.py +++ b/tests/test_cli/test_new_mnemonic.py @@ -10,7 +10,11 @@ from staking_deposit.cli import new_mnemonic from staking_deposit.deposit import cli from staking_deposit.key_handling.key_derivation.mnemonic import abbreviate_words -from staking_deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ETH1_ADDRESS_WITHDRAWAL_PREFIX +from staking_deposit.utils.constants import ( + BLS_WITHDRAWAL_PREFIX, + DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, + ETH1_ADDRESS_WITHDRAWAL_PREFIX, +) from staking_deposit.utils.intl import load_text from .helpers import clean_key_folder, get_permissions, get_uuid @@ -69,10 +73,10 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: os.mkdir(my_folder_path) runner = CliRunner() - inputs = ['english', '1', 'mainnet', 'MyPassword', 'MyPassword', + eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' + inputs = [eth1_withdrawal_address, 'english', '1', 'mainnet', 'MyPassword', 'MyPassword', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'] data = '\n'.join(inputs) - eth1_withdrawal_address = '0x00000000219ab540356cbb839cbe05303d7705fa' arguments = [ '--language', 'english', 'new-mnemonic', @@ -111,9 +115,159 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: clean_key_folder(my_folder_path) +def test_new_mnemonic_eth1_address_withdrawal_bad_checksum(monkeypatch) -> None: + # monkeypatch get_mnemonic + def mock_get_mnemonic(language, words_path, entropy=None) -> str: + return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + monkeypatch.setattr(new_mnemonic, "get_mnemonic", mock_get_mnemonic) + + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + + # NOTE: final 'A' needed to be an 'a' + wrong_eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705FA' + correct_eth1_withdrawal_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' + + inputs = [correct_eth1_withdrawal_address, correct_eth1_withdrawal_address, + 'english', '1', 'mainnet', 'MyPassword', 'MyPassword', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'new-mnemonic', + '--folder', my_folder_path, + '--eth1_withdrawal_address', wrong_eth1_withdrawal_address, + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + # Check files + validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + _, _, 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: + deposits_dict = json.load(f) + for deposit in deposits_dict: + withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) + assert withdrawal_credentials == ( + ETH1_ADDRESS_WITHDRAWAL_PREFIX + b'\x00' * 11 + decode_hex(correct_eth1_withdrawal_address) + ) + + all_uuid = [ + get_uuid(validator_keys_folder_path + '/' + key_file) + for key_file in key_files + if key_file.startswith('keystore') + ] + assert len(set(all_uuid)) == 1 + + # Verify file permissions + if os.name == 'posix': + for file_name in key_files: + assert get_permissions(validator_keys_folder_path, file_name) == '0o440' + + # Clean up + clean_key_folder(my_folder_path) + + +def test_new_mnemonic_eth1_address_withdrawal_alias(monkeypatch) -> None: + # monkeypatch get_mnemonic + def mock_get_mnemonic(language, words_path, entropy=None) -> str: + return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + monkeypatch.setattr(new_mnemonic, "get_mnemonic", mock_get_mnemonic) + + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + execution_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' + inputs = [execution_address, 'english', '1', 'mainnet', 'MyPassword', 'MyPassword', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'new-mnemonic', + '--folder', my_folder_path, + '--execution_address', execution_address, # execution_address and eth1_withdrawal_address are aliases + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + # Check files + validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + _, _, 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: + deposits_dict = json.load(f) + for deposit in deposits_dict: + withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) + assert withdrawal_credentials == ( + ETH1_ADDRESS_WITHDRAWAL_PREFIX + b'\x00' * 11 + decode_hex(execution_address) + ) + + all_uuid = [ + get_uuid(validator_keys_folder_path + '/' + key_file) + for key_file in key_files + if key_file.startswith('keystore') + ] + assert len(set(all_uuid)) == 1 + + # Verify file permissions + if os.name == 'posix': + for file_name in key_files: + assert get_permissions(validator_keys_folder_path, file_name) == '0o440' + + # Clean up + clean_key_folder(my_folder_path) + + +def test_new_mnemonic_eth1_address_withdrawal_double_params(monkeypatch) -> None: + # monkeypatch get_mnemonic + def mock_get_mnemonic(language, words_path, entropy=None) -> str: + return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + monkeypatch.setattr(new_mnemonic, "get_mnemonic", mock_get_mnemonic) + + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + execution_address = '0x00000000219ab540356cBB839Cbe05303d7705Fa' + inputs = [execution_address, 'english', '1', 'mainnet', 'MyPassword', 'MyPassword', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'] + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'new-mnemonic', + '--folder', my_folder_path, + '--execution_address', execution_address, + '--eth1_withdrawal_address', execution_address, # double param + ] + result = runner.invoke(cli, arguments, input=data) + + # FIXME: Should not allow it + assert result.exit_code == 0 + + @pytest.mark.asyncio -async def test_script() -> None: +async def test_script_bls_withdrawal() -> None: + # Prepare folder my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) if not os.path.exists(my_folder_path): os.mkdir(my_folder_path) @@ -167,6 +321,16 @@ async def test_script() -> None: validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) _, _, 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: + deposits_dict = json.load(f) + for deposit in deposits_dict: + withdrawal_credentials = bytes.fromhex(deposit['withdrawal_credentials']) + print('withdrawal_credentials', withdrawal_credentials) + assert withdrawal_credentials[:1] == BLS_WITHDRAWAL_PREFIX + + _, _, key_files = next(os.walk(validator_keys_folder_path)) + all_uuid = [ get_uuid(validator_keys_folder_path + '/' + key_file) for key_file in key_files @@ -185,7 +349,9 @@ async def test_script() -> None: @pytest.mark.asyncio async def test_script_abbreviated_mnemonic() -> None: + # Prepare folder my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_key_folder(my_folder_path) if not os.path.exists(my_folder_path): os.mkdir(my_folder_path) diff --git a/tests/test_utils/test_intl.py b/tests/test_utils/test_intl.py index de4ffc9c..6a1a09fc 100644 --- a/tests/test_utils/test_intl.py +++ b/tests/test_utils/test_intl.py @@ -18,7 +18,7 @@ @pytest.mark.parametrize( 'params, file_path, func, lang, found_str', [ (['arg_mnemonic_language', 'prompt'], os.path.join('staking_deposit', 'cli', 'new_mnemonic.json'), - 'new_mnemonic', 'en', 'Please choose your mnemonic language'), + 'new_mnemonic', 'en', 'Please choose the language of the mnemonic word list'), (['arg_mnemonic_language', 'prompt'], os.path.join('staking_deposit', 'cli', 'new_mnemonic.json'), 'new_mnemonic', 'ja', 'ニーモニックの言語を選択してください'), ] diff --git a/tests/test_utils/test_validation.py b/tests/test_utils/test_validation.py index c0b9c89f..9b918462 100644 --- a/tests/test_utils/test_validation.py +++ b/tests/test_utils/test_validation.py @@ -5,6 +5,7 @@ from staking_deposit.exceptions import ValidationError from staking_deposit.utils.validation import ( + normalize_input_list, validate_int_range, validate_password_strength, ) @@ -43,3 +44,19 @@ def test_validate_int_range(num: Any, low: int, high: int, valid: bool) -> None: else: with pytest.raises(ValidationError): validate_int_range(num, low, high) + + +@pytest.mark.parametrize( + 'input, result', + [ + ('1', ['1']), + ('1,2,3', ['1', '2', '3']), + ('[1,2,3]', ['1', '2', '3']), + ('(1,2,3)', ['1', '2', '3']), + ('{1,2,3}', ['1', '2', '3']), + ('1 2 3', ['1', '2', '3']), + ('1 2 3', ['1', '2', '3']), + ] +) +def test_normalize_input_list(input, result): + assert normalize_input_list(input) == result diff --git a/tox.ini b/tox.ini index 85356efe..843680d1 100644 --- a/tox.ini +++ b/tox.ini @@ -32,6 +32,7 @@ deps= -r{toxinidir}/requirements_test.txt # for async-pytest commands= python {toxinidir}/test_deposit_script.py + python {toxinidir}/test_btec_script.py [testenv:py310-core] deps={[common-core]deps}