From cf4bea0680583f58998d1fd23eaf62a2730c74ee Mon Sep 17 00:00:00 2001 From: valefar-on-discord <124839138+valefar-on-discord@users.noreply.github.com> Date: Wed, 28 Aug 2024 06:42:00 -0500 Subject: [PATCH] Adding command to create deposit with validator keystore --- .gitignore | 1 + README.md | 39 ++- docs/src/SUMMARY.md | 1 + docs/src/landing.md | 2 + docs/src/partial_deposit.md | 28 +++ docs/src/quick_setup.md | 2 + .../cli/exit_transaction_keystore.py | 2 +- ethstaker_deposit/cli/partial_deposit.py | 180 ++++++++++++++ ethstaker_deposit/credentials.py | 10 +- ethstaker_deposit/deposit.py | 2 + .../intl/en/cli/partial_deposit.json | 37 +++ .../intl/en/utils/validation.json | 7 + ethstaker_deposit/utils/constants.py | 6 +- ethstaker_deposit/utils/deposit.py | 11 + .../{ => utils}/exit_transaction.py | 0 ethstaker_deposit/utils/validation.py | 56 ++++- tests/test_cli/helpers.py | 11 +- tests/test_cli/test_partial_deposit.py | 224 ++++++++++++++++++ tests/test_utils/test_validation.py | 26 ++ 19 files changed, 616 insertions(+), 29 deletions(-) create mode 100644 docs/src/partial_deposit.md create mode 100644 ethstaker_deposit/cli/partial_deposit.py create mode 100644 ethstaker_deposit/intl/en/cli/partial_deposit.json create mode 100644 ethstaker_deposit/utils/deposit.py rename ethstaker_deposit/{ => utils}/exit_transaction.py (100%) create mode 100644 tests/test_cli/test_partial_deposit.py diff --git a/.gitignore b/.gitignore index 91998314..4fa66586 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bls_to_execution_changes exit_transactions +partial_deposits validator_keys # Python testing & linting: diff --git a/README.md b/README.md index 62639421..b5e85432 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - [`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) + - [`partial-deposit` Arguments](#partial-deposit-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) @@ -154,6 +155,7 @@ The CLI offers different commands depending on what you want to do with the tool | `generate-bls-to-execution-change` | This command is used to generate BLS to execution address change message. This is used to add a withdrawal address to a validator that does not currently have one. | | `exit-transaction-keystore` | This command is used to create an exit transaction using a keystore file. | | `exit-transaction-mnemonic` | This command is used to create an exit transaction using a mnemonic phrase. | +| `partial-deposit` | This command is used to create a deposit file using a keystore file. | ###### `new-mnemonic` Arguments @@ -200,7 +202,7 @@ 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. +You can use `generate-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 | | -------- | -------- | -------- | @@ -241,6 +243,19 @@ You can use `exit-transaction-mnemonic --help` to see all arguments. Note that i | `--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 | +###### `partial-deposit` Arguments + +You can use `partial-deposit --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 deposit to. | +| `--keystore_password` | String | The password that is used to encrypt the provided keystore. Note: It's not your mnemonic password. | +| `--amount` | Float. `32` by default | The amount you wish to deposit. Must be in ether, at least 1 ether, and can not have higher precision than 1 gwei. | +| `--withdrawal_address` | String. Ethereum execution address in hexadecimal encoded form | The withdrawal address of the existing validator or the desired withdrawal address. | +| `--output_folder` | String. Pointing to `./partial_deposit` by default | The folder path for the `deposit-*` JSON file | + #### Option 2. Build `deposit-cli` with native Python ##### Step 0. Python version checking @@ -302,7 +317,8 @@ 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 +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments\ +See [here](#partial-deposit-arguments) for `partial-deposit` arguments ###### Successful message See [here](#successful-message) @@ -376,7 +392,8 @@ 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 +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments\ +See [here](#partial-deposit-arguments) for `partial-deposit` arguments #### Option 4. Use published docker image @@ -492,7 +509,8 @@ 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 +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments\ +See [here](#partial-deposit-arguments) for `partial-deposit` arguments #### Option 2. Build `deposit-cli` with native Python @@ -552,9 +570,13 @@ 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](#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\ +See [here](#partial-deposit-arguments) for `partial-deposit` arguments #### Option 3. Build `deposit-cli` with `virtualenv` @@ -619,7 +641,8 @@ 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 +See [here](#exit-transaction-mnemonic-arguments) for `exit-transaction-mnemonic` arguments\ +See [here](#partial-deposit-arguments) for `partial-deposit` arguments ## Development diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fcc9cc1b..19c0f441 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,4 +7,5 @@ - [Generate BLS to Execution Change](generate_bls_to_execution_change.md) - [Exit Transaction Keystore](exit_transaction_keystore.md) - [Exit Transaction Mnemonic](exit_transaction_mnemonic.md) +- [Partial Deposit](partial_deposit.md) - [Local Development](local_development.md) diff --git a/docs/src/landing.md b/docs/src/landing.md index 86a531e1..dafcce8f 100644 --- a/docs/src/landing.md +++ b/docs/src/landing.md @@ -52,6 +52,8 @@ If there is a specific command you would like to understand more, please choose - **[exit-transaction-mnemonic](exit_transaction_mnemonic.md)**: Generate an exit message using the mnemonic of your validators. +- **[partial-deposit](partial_deposit.md)**: Generate a deposit file with an existing validator key. Can be used to initiate a validator or deposit to an existing validator. + ## Canonical Deposit Contract and Launchpad Ethstaker confirms the canonical Ethereum staking deposit contract addresses and launchpad URLs. diff --git a/docs/src/partial_deposit.md b/docs/src/partial_deposit.md new file mode 100644 index 00000000..979cf27e --- /dev/null +++ b/docs/src/partial_deposit.md @@ -0,0 +1,28 @@ +# partial-deposit + +{{#include ./snippet/warning_message.md}} + +## Description +Creates a deposit file with an existing validator key. Can be used to initiate a validator or deposit to an existing validator. +If you wish to create a validator with 0x00 credentials, you must use the **[new-mnemonic](new_mnemonic.md)** or the **[existing-mnemonic](existing_mnemonic.md)** command. + +## Optional Arguments + +- **`--chain`**: The chain to use for generating the deposit data. Options are: 'mainnet', 'holesky', etc. + +- **`--keystore`**: The keystore file associating with the validator you wish to deposit to. + +- **`--keystore_password`**: The password that is used to encrypt the provided keystore. Note: It's not your mnemonic password. + +- **`--amount`**: The amount you wish to deposit in ether. Must be at least 1 and can not have precision beyond 1 gwei. Defaults to 32 ether. + +- **`--withdrawal_address`**: The withdrawal address of the existing validator or the desired withdrawal address. + +- **`--output_folder`**: The folder path for the `deposit-*` JSON file. + + +## Example Usage + +```sh +./deposit partial-deposit --keystore /path/to/keystore.json +``` diff --git a/docs/src/quick_setup.md b/docs/src/quick_setup.md index c1c3ff21..6ee27f40 100644 --- a/docs/src/quick_setup.md +++ b/docs/src/quick_setup.md @@ -50,6 +50,8 @@ Determine which command best suites what you would like to accomplish: - **[exit-transaction-mnemonic](exit_transaction_mnemonic.md)**: Generate an exit message using the mnemonic of your validators. +- **[partial-deposit](partial_deposit.md)**: Generate a partial deposit using a validator keystore. + --- If you encounter any issues, please check the [issues page](https://github.com/eth-educators/ethstaker-deposit-cli/issues) for help or to report a problem. You may also contact us on the [Ethstaker discord](https://dsc.gg/ethstaker). diff --git a/ethstaker_deposit/cli/exit_transaction_keystore.py b/ethstaker_deposit/cli/exit_transaction_keystore.py index f2812c67..28f8b364 100644 --- a/ethstaker_deposit/cli/exit_transaction_keystore.py +++ b/ethstaker_deposit/cli/exit_transaction_keystore.py @@ -3,7 +3,7 @@ import time from typing import Any -from ethstaker_deposit.exit_transaction import exit_transaction_generation, export_exit_transaction_json +from ethstaker_deposit.utils.exit_transaction import exit_transaction_generation, export_exit_transaction_json from ethstaker_deposit.key_handling.keystore import Keystore from ethstaker_deposit.settings import ( MAINNET, diff --git a/ethstaker_deposit/cli/partial_deposit.py b/ethstaker_deposit/cli/partial_deposit.py new file mode 100644 index 00000000..7dd1dbfe --- /dev/null +++ b/ethstaker_deposit/cli/partial_deposit.py @@ -0,0 +1,180 @@ +import json +import click +import os +import time + +from eth_typing import HexAddress +from eth_utils import to_canonical_address +from py_ecc.bls import G2ProofOfPossession as bls +from typing import Any + +from ethstaker_deposit.key_handling.keystore import Keystore +from ethstaker_deposit.settings import ( + DEPOSIT_CLI_VERSION, + MAINNET, + ALL_CHAIN_KEYS, + get_chain_setting, +) +from ethstaker_deposit.utils.click import ( + captive_prompt_callback, + choice_prompt_func, + jit_option, +) +from ethstaker_deposit.utils.constants import DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME, EXECUTION_ADDRESS_WITHDRAWAL_PREFIX +from ethstaker_deposit.utils.deposit import export_deposit_data_json +from ethstaker_deposit.utils.intl import ( + closest_match, + load_text, +) +from ethstaker_deposit.utils.ssz import ( + DepositData, + DepositMessage, + compute_deposit_domain, + compute_signing_root, +) +from ethstaker_deposit.utils.validation import ( + validate_deposit, + validate_keystore_file, + validate_partial_deposit_amount, + validate_withdrawal_address, +) + + +FUNC_NAME = 'partial_deposit' + + +@click.command( + help=load_text(['arg_partial_deposit', 'help'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: closest_match(x, ALL_CHAIN_KEYS), + choice_prompt_func( + lambda: load_text(['arg_partial_deposit_chain', 'prompt'], func=FUNC_NAME), + ALL_CHAIN_KEYS + ), + ), + default=MAINNET, + help=lambda: load_text(['arg_partial_deposit_chain', 'help'], func=FUNC_NAME), + param_decls='--chain', + prompt=choice_prompt_func( + lambda: load_text(['arg_partial_deposit_chain', 'prompt'], func=FUNC_NAME), + ALL_CHAIN_KEYS + ), +) +@jit_option( + callback=captive_prompt_callback( + lambda file: validate_keystore_file(file), + lambda: load_text(['arg_partial_deposit_keystore', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_partial_deposit_keystore', 'help'], func=FUNC_NAME), + param_decls='--keystore', + prompt=lambda: load_text(['arg_partial_deposit_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_partial_deposit_keystore_password', 'prompt'], func=FUNC_NAME), + None, + lambda: load_text(['arg_partial_deposit_keystore_password', 'invalid'], func=FUNC_NAME), + True, + ), + help=lambda: load_text(['arg_partial_deposit_keystore_password', 'help'], func=FUNC_NAME), + hide_input=True, + param_decls='--keystore_password', + prompt=lambda: load_text(['arg_partial_deposit_keystore_password', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda amount: validate_partial_deposit_amount(amount), + lambda: load_text(['arg_partial_deposit_amount', 'prompt'], func=FUNC_NAME), + default="32", + prompt_if_none=True, + ), + default="32", + help=lambda: load_text(['arg_partial_deposit_amount', 'help'], func=FUNC_NAME), + param_decls='--amount', + prompt=False, # the callback handles the prompt, to avoid second callback with gwei +) +@jit_option( + callback=captive_prompt_callback( + lambda address: validate_withdrawal_address(None, None, address, True), + lambda: load_text(['arg_withdrawal_address', 'prompt'], func=FUNC_NAME), + lambda: load_text(['arg_withdrawal_address', 'confirm'], func=FUNC_NAME), + lambda: load_text(['arg_withdrawal_address', 'mismatch'], func=FUNC_NAME), + prompt_if_none=True, + ), + help=lambda: load_text(['arg_withdrawal_address', 'help'], func=FUNC_NAME), + param_decls=['--withdrawal_address', '--execution_address', '--eth1_withdrawal_credentials'], + prompt=False, # the callback handles the prompt +) +@jit_option( + default=os.getcwd(), + help=lambda: load_text(['arg_partial_deposit_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 partial_deposit( + ctx: click.Context, + chain: str, + keystore: Keystore, + keystore_password: str, + amount: int, + withdrawal_address: HexAddress, + output_folder: str, + **kwargs: Any) -> None: + try: + secret_bytes = keystore.decrypt(keystore_password) + except ValueError: + click.echo(load_text(['arg_partial_deposit_keystore_password', 'mismatch'])) + exit(1) + + signing_key = int.from_bytes(secret_bytes, 'big') + chain_settings = get_chain_setting(chain) + + withdrawal_credentials = EXECUTION_ADDRESS_WITHDRAWAL_PREFIX + withdrawal_credentials += b'\x00' * 11 + withdrawal_credentials += to_canonical_address(withdrawal_address) + + deposit_message = DepositMessage( # type: ignore[no-untyped-call] + pubkey=bls.SkToPk(signing_key), + withdrawal_credentials=withdrawal_credentials, + amount=amount + ) + + domain = compute_deposit_domain(fork_version=chain_settings.GENESIS_FORK_VERSION) + + signing_root = compute_signing_root(deposit_message, domain) + signature = bls.Sign(signing_key, signing_root) + + signed_deposit = DepositData( # type: ignore[no-untyped-call] + **deposit_message.as_dict(), # type: ignore[no-untyped-call] + signature=signature + ) + + folder = os.path.join(output_folder, DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME) + if not os.path.exists(folder): + os.mkdir(folder) + + click.echo(load_text(['msg_partial_deposit_creation'])) + deposit_data = signed_deposit.as_dict() # type: ignore[no-untyped-call] + deposit_data.update({'deposit_message_root': deposit_message.hash_tree_root}) + deposit_data.update({'deposit_data_root': signed_deposit.hash_tree_root}) + deposit_data.update({'fork_version': chain_settings.GENESIS_FORK_VERSION}) + deposit_data.update({'network_name': chain_settings.NETWORK_NAME}) + deposit_data.update({'deposit_cli_version': DEPOSIT_CLI_VERSION}) + saved_folder = export_deposit_data_json(folder, time.time(), [deposit_data]) + + click.echo(load_text(['msg_verify_partial_deposit'])) + deposit_json = [] + with open(saved_folder, 'r', encoding='utf-8') as f: + deposit_json = json.load(f) + + if (not validate_deposit(deposit_json[0])): + click.echo(load_text(['err_verify_partial_deposit'])) + return + + click.echo(load_text(['msg_creation_success']) + saved_folder) + click.pause(load_text(['msg_pause'])) diff --git a/ethstaker_deposit/credentials.py b/ethstaker_deposit/credentials.py index 233aa040..d0874bc6 100644 --- a/ethstaker_deposit/credentials.py +++ b/ethstaker_deposit/credentials.py @@ -11,7 +11,7 @@ from py_ecc.bls import G2ProofOfPossession as bls from ethstaker_deposit.exceptions import ValidationError -from ethstaker_deposit.exit_transaction import exit_transaction_generation, export_exit_transaction_json +from ethstaker_deposit.utils.exit_transaction import exit_transaction_generation, export_exit_transaction_json from ethstaker_deposit.key_handling.key_derivation.path import mnemonic_and_path_to_key from ethstaker_deposit.key_handling.keystore import ( Keystore, @@ -27,6 +27,7 @@ MIN_DEPOSIT_AMOUNT, ) from ethstaker_deposit.utils.crypto import SHA256 +from ethstaker_deposit.utils.deposit import export_deposit_data_json as export_deposit_data_json_util from ethstaker_deposit.utils.intl import load_text from ethstaker_deposit.utils.ssz import ( compute_deposit_domain, @@ -326,12 +327,7 @@ def export_deposit_data_json(self, folder: str, timestamp: float) -> str: deposit_data.append(datum_dict) bar.update(1) - filefolder = os.path.join(folder, 'deposit_data-%i.json' % timestamp) - with open(filefolder, 'w') as f: - json.dump(deposit_data, f, default=lambda x: x.hex()) - if os.name == 'posix': - os.chmod(filefolder, int('440', 8)) # Read for owner & group - return filefolder + return export_deposit_data_json_util(folder, timestamp, deposit_data) def verify_keystores(self, keystore_filefolders: List[str], password: str) -> bool: all_valid_keystores = True diff --git a/ethstaker_deposit/deposit.py b/ethstaker_deposit/deposit.py index 39831215..604d827b 100644 --- a/ethstaker_deposit/deposit.py +++ b/ethstaker_deposit/deposit.py @@ -9,6 +9,7 @@ from ethstaker_deposit.cli.exit_transaction_mnemonic import exit_transaction_mnemonic from ethstaker_deposit.cli.generate_bls_to_execution_change import generate_bls_to_execution_change from ethstaker_deposit.cli.new_mnemonic import new_mnemonic +from ethstaker_deposit.cli.partial_deposit import partial_deposit from ethstaker_deposit.exceptions import ValidationError from ethstaker_deposit.utils.click import ( captive_prompt_callback, @@ -52,6 +53,7 @@ def check_connectivity() -> None: generate_bls_to_execution_change, exit_transaction_keystore, exit_transaction_mnemonic, + partial_deposit, ] diff --git a/ethstaker_deposit/intl/en/cli/partial_deposit.json b/ethstaker_deposit/intl/en/cli/partial_deposit.json new file mode 100644 index 00000000..d2a1d9b4 --- /dev/null +++ b/ethstaker_deposit/intl/en/cli/partial_deposit.json @@ -0,0 +1,37 @@ +{ + "partial_deposit": { + "arg_partial_deposit" :{ + "help": "Generate a partial deposit with any amount at least 1 ether which will be signed by the provided validator keystore. This will append to the balance of the provided validator or initiate the creation of one." + }, + "arg_partial_deposit_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_partial_deposit_amount": { + "help": "The amount to deposit to this validator in ether denomination. Must be at least 1 ether and can not have greater precision than 1 gwei. Default is 32 ether.", + "prompt": "Please enter the amount you wish to deposit to this validator. Must be at least 1 ether and can not have greater precision than 1 gwei. 32 is required to activate a new validator" + }, + "arg_partial_deposit_keystore": { + "help": "The keystore file associated with the validator you wish to sign with and deposit to.", + "prompt": "Please enter the location of your keystore file." + }, + "arg_partial_deposit_keystore_password": { + "help": "The password that is used to encrypt the provided keystore. Note: It's not your mnemonic password. (It is recommended not to use this argument, and wait for the CLI to ask you for your password as otherwise it will appear in your shell history.)", + "prompt": "Enter the password that is used to encrypt the provided keystore.", + "mismatch": "Error: The provided keystore password was unable to decrypt this keystore file. Make sure you have the correct password and try again." + }, + "arg_partial_deposit_output_folder": { + "help": "The folder path where the partial deposit will be saved to. Pointing to `./partial_deposits` by default." + }, + "arg_withdrawal_address": { + "help": "The withdrawal address of the validator. If you wish to create a validator with 0x00 credentials use the new-mnemonic or existing-mnemonic command.", + "confirm": "Repeat the withdrawal address for confirmation.", + "prompt": "Please enter the 20-byte execution address withdrawal credentials of the validator. If you wish to create a validator with 0x00 credentials use the new-mnemonic or existing-mnemonic command." + }, + "msg_partial_deposit_creation": "\nCreating your partial deposit...", + "msg_verify_partial_deposit": "\nVerifying your partial deposit...", + "err_verify_partial_deposit": "\nThere was a problem verifying your partial deposit.\nPlease try again", + "msg_creation_success": "\nSuccess!\nYour partial deposit file can be found at: ", + "msg_pause": "\n\nPress any key." + } +} diff --git a/ethstaker_deposit/intl/en/utils/validation.json b/ethstaker_deposit/intl/en/utils/validation.json index f5e915ad..8a58b94a 100644 --- a/ethstaker_deposit/intl/en/utils/validation.json +++ b/ethstaker_deposit/intl/en/utils/validation.json @@ -14,8 +14,15 @@ "validate_withdrawal_address": { "err_invalid_ECDSA_hex_addr": "The given execution address is not in hexadecimal encoded form.", "err_invalid_ECDSA_hex_addr_checksum": "The given execution address is not in checksum form.", + "err_missing_address": "An address must must be providing", "msg_ECDSA_hex_addr_withdrawal": "**[Warning] you are setting an execution address as your withdrawal address. Please ensure that you have control over this address.**" }, + "validate_partial_deposit_amount": { + "err_invalid_amount": "Invalid amount provided. Please provide a value of at least 1 ether of how much you wish to deposit.", + "err_not_gwei_denomination": "Invalid amount provided. Please provide a value can not have precision beyond 1 gwei.", + "err_min_deposit": "Invalid amount provided. Please provide a value of at least 1 ether.", + "err_max_deposit": "Invalid amount provided. Value is too large. Please provide a lesser amount." + }, "validate_bls_withdrawal_credentials": { "err_is_already_01_form": "The given withdrawal credentials is already in 0x01 form. Have you already set the withdrawal address?", "err_not_bls_form": "The given withdrawal credentials is not in BLS_WITHDRAWAL_PREFIX form." diff --git a/ethstaker_deposit/utils/constants.py b/ethstaker_deposit/utils/constants.py index d0653d69..9c21582f 100644 --- a/ethstaker_deposit/utils/constants.py +++ b/ethstaker_deposit/utils/constants.py @@ -13,17 +13,21 @@ DOMAIN_BLS_TO_EXECUTION_CHANGE = bytes.fromhex('0A000000') BLS_WITHDRAWAL_PREFIX = bytes.fromhex('00') EXECUTION_ADDRESS_WITHDRAWAL_PREFIX = bytes.fromhex('01') +COMPOUNDING_WITHDRAWAL_PREFIX = bytes.fromhex('02') ETH2GWEI = 10 ** 9 +# TODO: check if user will can deposit a new validator with more than 32 and 0x02 credentials or 0x00 and 0x01 only MIN_DEPOSIT_AMOUNT = 2 ** 0 * ETH2GWEI MAX_DEPOSIT_AMOUNT = 2 ** 5 * ETH2GWEI - +# Deposit max https://github.com/ethereum/consensus-specs/blob/dev/solidity_deposit_contract/deposit_contract.sol#L116 +GWEI_DEPOSIT_LIMIT = 2**64 - 1 # File/folder constants WORD_LISTS_PATH = os.path.join('ethstaker_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' +DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME = 'partial_deposits' # Internationalisation constants INTL_CONTENT_PATH = os.path.join('ethstaker_deposit', 'intl') diff --git a/ethstaker_deposit/utils/deposit.py b/ethstaker_deposit/utils/deposit.py new file mode 100644 index 00000000..2ce78aef --- /dev/null +++ b/ethstaker_deposit/utils/deposit.py @@ -0,0 +1,11 @@ +import json +import os + + +def export_deposit_data_json(folder: str, timestamp: float, deposit_data: list[dict[str, bytes]]) -> str: + file_folder = os.path.join(folder, 'deposit_data-%i.json' % timestamp) + with open(file_folder, 'w') as f: + json.dump(deposit_data, f, default=lambda x: x.hex()) + if os.name == 'posix': + os.chmod(file_folder, int('440', 8)) # Read for owner & group + return file_folder diff --git a/ethstaker_deposit/exit_transaction.py b/ethstaker_deposit/utils/exit_transaction.py similarity index 100% rename from ethstaker_deposit/exit_transaction.py rename to ethstaker_deposit/utils/exit_transaction.py diff --git a/ethstaker_deposit/utils/validation.py b/ethstaker_deposit/utils/validation.py index 042dada1..b8524c90 100644 --- a/ethstaker_deposit/utils/validation.py +++ b/ethstaker_deposit/utils/validation.py @@ -1,3 +1,4 @@ +from decimal import Decimal, InvalidOperation import click import json import re @@ -30,10 +31,12 @@ Credential, ) from ethstaker_deposit.utils.constants import ( - MAX_DEPOSIT_AMOUNT, - MIN_DEPOSIT_AMOUNT, BLS_WITHDRAWAL_PREFIX, + COMPOUNDING_WITHDRAWAL_PREFIX, + ETH2GWEI, EXECUTION_ADDRESS_WITHDRAWAL_PREFIX, + GWEI_DEPOSIT_LIMIT, + MIN_DEPOSIT_AMOUNT, ) from ethstaker_deposit.utils.crypto import SHA256 from ethstaker_deposit.settings import BaseChainSetting @@ -65,7 +68,7 @@ def verify_deposit_data_json(filefolder: str, credentials: Sequence[Credential]) return all_valid_deposits -def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential) -> bool: +def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential = None) -> bool: ''' Checks whether a deposit is valid based on the staking deposit rules. https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#deposits @@ -80,27 +83,34 @@ def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential) # Verify pubkey if len(pubkey) != 48: return False - if pubkey != credential.signing_pk: + if credential and pubkey != credential.signing_pk: return False # Verify withdrawal credential if len(withdrawal_credentials) != 32: return False - if withdrawal_credentials[:1] == BLS_WITHDRAWAL_PREFIX == credential.withdrawal_prefix: + if withdrawal_credentials[:1] == BLS_WITHDRAWAL_PREFIX: + if credential and withdrawal_credentials[:1] != credential.withdrawal_prefix: + return False if withdrawal_credentials[1:] != SHA256(credential.withdrawal_pk)[1:]: return False - elif withdrawal_credentials[:1] == EXECUTION_ADDRESS_WITHDRAWAL_PREFIX == credential.withdrawal_prefix: + elif ( + withdrawal_credentials[:1] == EXECUTION_ADDRESS_WITHDRAWAL_PREFIX + or withdrawal_credentials[:1] == COMPOUNDING_WITHDRAWAL_PREFIX + ): + if credential and withdrawal_credentials[:1] != credential.withdrawal_prefix: + return False if withdrawal_credentials[1:12] != b'\x00' * 11: return False - if credential.withdrawal_address is None: + if credential and credential.withdrawal_address is None: return False - if withdrawal_credentials[12:] != credential.withdrawal_address: + if credential and withdrawal_credentials[12:] != credential.withdrawal_address: return False else: return False # Verify deposit amount - if not MIN_DEPOSIT_AMOUNT < amount <= MAX_DEPOSIT_AMOUNT: + if not MIN_DEPOSIT_AMOUNT <= amount <= GWEI_DEPOSIT_LIMIT: return False # Verify deposit signature && pubkey @@ -109,6 +119,7 @@ def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential) withdrawal_credentials=withdrawal_credentials, amount=amount ) + domain = compute_deposit_domain(fork_version) signing_root = compute_signing_root(deposit_message, domain) if not bls.Verify(pubkey, signing_root, signature): @@ -145,8 +156,10 @@ def validate_int_range(num: Any, low: int, high: int) -> int: raise ValidationError(load_text(['err_not_positive_integer'])) -def validate_withdrawal_address(cts: click.Context, param: Any, address: str) -> HexAddress: +def validate_withdrawal_address(cts: click.Context, param: Any, address: str, require: bool = False) -> HexAddress: if address in ("", None): + if require: + raise ValidationError(load_text(['err_missing_address'])) return None if not is_hex_address(address): raise ValidationError(load_text(['err_invalid_ECDSA_hex_addr'])) @@ -158,6 +171,29 @@ def validate_withdrawal_address(cts: click.Context, param: Any, address: str) -> return normalized_address +def validate_partial_deposit_amount(amount: str) -> int: + ''' + Verifies that `amount` is a valid gwei denomination and 1 ether <= amount <= GWEI_DEPOSIT_LIMIT gwei + Amount is expected to be in ether and the returned value will be converted to gwei and represented as an int + ''' + try: + decimal_ether = Decimal(amount) + amount_gwei = decimal_ether * Decimal(ETH2GWEI) + + if amount_gwei % 1 != 0: + raise ValidationError(load_text(['err_not_gwei_denomination'])) + + if amount_gwei < 1 * ETH2GWEI: + raise ValidationError(load_text(['err_min_deposit'])) + + if amount_gwei > GWEI_DEPOSIT_LIMIT: + raise ValidationError(load_text(['err_max_deposit'])) + + return int(amount_gwei) + except InvalidOperation: + raise ValidationError(load_text(['err_invalid_amount'])) + + # # BLSToExecutionChange # diff --git a/tests/test_cli/helpers.py b/tests/test_cli/helpers.py index 565a1ce9..13f8ae80 100644 --- a/tests/test_cli/helpers.py +++ b/tests/test_cli/helpers.py @@ -5,6 +5,7 @@ from ethstaker_deposit.utils.constants import ( DEFAULT_BLS_TO_EXECUTION_CHANGES_FOLDER_NAME, DEFAULT_EXIT_TRANSACTION_FOLDER_NAME, + DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ) @@ -14,6 +15,11 @@ def clean_key_folder(my_folder_path: str) -> None: clean_folder(my_folder_path, sub_folder_path) +def clean_partial_deposit_folder(my_folder_path: str) -> None: + sub_folder_path = os.path.join(my_folder_path, DEFAULT_PARTIAL_DEPOSIT_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) @@ -24,7 +30,7 @@ def clean_exit_transaction_folder(my_folder_path: str) -> None: clean_folder(my_folder_path, sub_folder_path) -def clean_folder(primary_folder_path: str, sub_folder_path: str) -> None: +def clean_folder(primary_folder_path: str, sub_folder_path: str, ignore_primary: bool = False) -> None: if not os.path.exists(sub_folder_path): return @@ -32,7 +38,8 @@ def clean_folder(primary_folder_path: str, sub_folder_path: str) -> None: for key_file_name in key_files: os.remove(os.path.join(sub_folder_path, key_file_name)) os.rmdir(sub_folder_path) - os.rmdir(primary_folder_path) + if not ignore_primary: + os.rmdir(primary_folder_path) def get_uuid(key_file: str) -> str: diff --git a/tests/test_cli/test_partial_deposit.py b/tests/test_cli/test_partial_deposit.py new file mode 100644 index 00000000..20769594 --- /dev/null +++ b/tests/test_cli/test_partial_deposit.py @@ -0,0 +1,224 @@ +import json +import os +import pytest +import time + +from click.testing import CliRunner + +from eth_utils import to_normalized_address + +from ethstaker_deposit.credentials import Credential +from ethstaker_deposit.deposit import cli +from ethstaker_deposit.settings import get_chain_setting +from ethstaker_deposit.utils.constants import ( + DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME, + DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, +) +from .helpers import clean_folder, clean_key_folder, clean_partial_deposit_folder, get_permissions + + +@pytest.mark.parametrize( + 'amount', + [ + ("32"), + ("1"), + ("432.123456789"), + ("18446744073.709551615"), + ] +) +def test_partial_deposit(amount: str) -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + partial_deposit_folder = os.path.join(my_folder_path, DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME) + clean_partial_deposit_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + if not os.path.exists(partial_deposit_folder): + os.mkdir(partial_deposit_folder) + + chain_settings = get_chain_setting() + password = "Password1" + withdrawal_address = "0xcd60A5f152724480c3a95E4Ff4dacEEf4074854d" + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + credential = Credential(mnemonic=mnemonic, + mnemonic_password="", + index=0, + amount=32000000000, + chain_setting=chain_settings, + hex_withdrawal_address=to_normalized_address(withdrawal_address)) + + keystore_file_folder = credential.save_signing_keystore(password, partial_deposit_folder, time.time()) + + runner = CliRunner() + inputs = ['english', 'mainnet', password, amount, withdrawal_address, withdrawal_address] + data = '\n'.join(inputs) + arguments = [ + '--ignore_connectivity', + 'partial-deposit', + '--keystore', keystore_file_folder, + '--output_folder', my_folder_path, + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + _, _, folder_files = next(os.walk(partial_deposit_folder)) + + deposit_files = [deposit_file for deposit_file in folder_files if deposit_file.startswith('deposit')] + + assert len(deposit_files) == 1 + + if os.name == 'posix': + assert get_permissions(partial_deposit_folder, deposit_files[0]) == '0o440' + + clean_partial_deposit_folder(my_folder_path) + + +def test_partial_deposit_matches_existing_mnemonic_deposit() -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + validator_key_folder = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + partial_deposit_folder = os.path.join(my_folder_path, DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME) + clean_partial_deposit_folder(my_folder_path) + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + password = "Password1" + withdrawal_address = "0xcd60A5f152724480c3a95E4Ff4dacEEf4074854d" + + runner = CliRunner() + arguments = [ + '--language', 'english', + '--non_interactive', + 'existing-mnemonic', + '--num_validators', '1', + '--mnemonic', "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + '--validator_start_index', '1', + '--chain', 'mainnet', + '--keystore_password', password, + '--withdrawal_address', f"{withdrawal_address}", + '--folder', my_folder_path, + ] + result = runner.invoke(cli, arguments) + assert result.exit_code == 0 + + _, _, validator_key_files = next(os.walk(validator_key_folder)) + + key_files = [key_file for key_file in validator_key_files if key_file.startswith('keystore')] + + deposit_files = [key_file for key_file in validator_key_files if key_file.startswith('deposit')] + + assert len(key_files) == 1 + assert len(deposit_files) == 1 + key_file_location = os.path.join(validator_key_folder, key_files[0]) + + inputs = ['english', 'mainnet', password, "32", withdrawal_address, withdrawal_address] + data = '\n'.join(inputs) + arguments = [ + '--ignore_connectivity', + 'partial-deposit', + '--keystore', key_file_location, + '--output_folder', my_folder_path, + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + _, _, folder_files = next(os.walk(partial_deposit_folder)) + + partial_deposit_files = [deposit_file for deposit_file in folder_files if deposit_file.startswith('deposit')] + + assert len(partial_deposit_files) == 1 + + with open(os.path.join(validator_key_folder, deposit_files[0]), 'r') as file1: + deposit_contents = file1.read() + + with open(os.path.join(partial_deposit_folder, partial_deposit_files[0]), 'r') as file2: + partial_deposit_contents = file2.read() + + assert deposit_contents == partial_deposit_contents + + if os.name == 'posix': + assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o440' + + clean_folder(my_folder_path, validator_key_folder, True) + clean_partial_deposit_folder(my_folder_path) + + +def test_partial_deposit_does_not_match_if_amount_differs() -> None: + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + validator_key_folder = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME) + partial_deposit_folder = os.path.join(my_folder_path, DEFAULT_PARTIAL_DEPOSIT_FOLDER_NAME) + clean_partial_deposit_folder(my_folder_path) + clean_key_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + password = "Password1" + withdrawal_address = "0xcd60A5f152724480c3a95E4Ff4dacEEf4074854d" + + runner = CliRunner() + arguments = [ + '--language', 'english', + '--non_interactive', + 'existing-mnemonic', + '--num_validators', '1', + '--mnemonic', "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + '--validator_start_index', '1', + '--chain', 'mainnet', + '--keystore_password', password, + '--withdrawal_address', f"{withdrawal_address}", + '--folder', my_folder_path, + ] + result = runner.invoke(cli, arguments) + assert result.exit_code == 0 + + _, _, validator_key_files = next(os.walk(validator_key_folder)) + + key_files = [key_file for key_file in validator_key_files if key_file.startswith('keystore')] + + deposit_files = [key_file for key_file in validator_key_files if key_file.startswith('deposit')] + + assert len(key_files) == 1 + assert len(deposit_files) == 1 + key_file_location = os.path.join(validator_key_folder, key_files[0]) + + inputs = ['english', 'mainnet', password, "33", withdrawal_address, withdrawal_address] + data = '\n'.join(inputs) + arguments = [ + '--ignore_connectivity', + 'partial-deposit', + '--keystore', key_file_location, + '--output_folder', my_folder_path, + ] + result = runner.invoke(cli, arguments, input=data) + assert result.exit_code == 0 + + _, _, folder_files = next(os.walk(partial_deposit_folder)) + + partial_deposit_files = [deposit_file for deposit_file in folder_files if deposit_file.startswith('deposit')] + + assert len(partial_deposit_files) == 1 + + with open(os.path.join(validator_key_folder, deposit_files[0]), 'r') as file1: + deposit_contents = file1.read() + + with open(os.path.join(partial_deposit_folder, partial_deposit_files[0]), 'r') as file2: + partial_deposit_contents = file2.read() + + assert deposit_contents != partial_deposit_contents + deposit_contents_dict = json.loads(deposit_contents)[0] + partial_deposit_contents_dict = json.loads(partial_deposit_contents)[0] + assert deposit_contents_dict["pubkey"] == partial_deposit_contents_dict["pubkey"] + assert deposit_contents_dict["withdrawal_credentials"] == partial_deposit_contents_dict["withdrawal_credentials"] + assert deposit_contents_dict["amount"] != partial_deposit_contents_dict["amount"] + assert deposit_contents_dict["signature"] != partial_deposit_contents_dict["signature"] + assert deposit_contents_dict["deposit_message_root"] != partial_deposit_contents_dict["deposit_message_root"] + assert deposit_contents_dict["deposit_data_root"] != partial_deposit_contents_dict["deposit_data_root"] + assert deposit_contents_dict["fork_version"] == partial_deposit_contents_dict["fork_version"] + assert deposit_contents_dict["network_name"] == partial_deposit_contents_dict["network_name"] + assert deposit_contents_dict["deposit_cli_version"] == partial_deposit_contents_dict["deposit_cli_version"] + + if os.name == 'posix': + assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o440' + + clean_folder(my_folder_path, validator_key_folder, True) + clean_partial_deposit_folder(my_folder_path) diff --git a/tests/test_utils/test_validation.py b/tests/test_utils/test_validation.py index 2f1658ef..ee00473d 100644 --- a/tests/test_utils/test_validation.py +++ b/tests/test_utils/test_validation.py @@ -8,6 +8,7 @@ from ethstaker_deposit.utils.validation import ( normalize_input_list, validate_int_range, + validate_partial_deposit_amount, validate_password_strength, validate_signed_exit, ) @@ -48,6 +49,31 @@ def test_validate_int_range(num: Any, low: int, high: int, valid: bool) -> None: validate_int_range(num, low, high) +@pytest.mark.parametrize( + 'amount, valid', + [ + ('-1', False), + ('0', False), + ('0.99999', False), + ('1', True), + ('1.000000001', True), + ('1.0000000001', False), + ('32', True), + ('18446744073.709551615', True), + ('18446744073.709551616', False), + ('18446744073.7095516151', False), + ('a', False), + (' ', False) + ] +) +def test_validate_partial_deposit_amount(amount: str, valid: bool) -> None: + if valid: + validate_partial_deposit_amount(amount) + else: + with pytest.raises(ValidationError): + validate_partial_deposit_amount(amount) + + @pytest.mark.parametrize( 'input, result', [