diff --git a/README.md b/README.md index f7a5df4b..09ab48aa 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ You can use `new-mnemonic --help` to see all arguments. Note that if there are m | `--mnemonic_language` | String. Options: `czech`, `chinese_traditional`, `chinese_simplified`, `english`, `spanish`, `italian`, `korean`. Default to `english` | The mnemonic language | | `--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). | ###### `existing-mnemonic` Arguments @@ -132,6 +133,7 @@ You can use `existing-mnemonic --help` to see all arguments. Note that if there | `--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. | | `--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). | ###### Successful message diff --git a/eth2deposit/cli/generate_keys.py b/eth2deposit/cli/generate_keys.py index 8b85b3e1..4c12c3d9 100644 --- a/eth2deposit/cli/generate_keys.py +++ b/eth2deposit/cli/generate_keys.py @@ -5,6 +5,9 @@ Callable, ) +from eth_typing import HexAddress +from eth_utils import is_hex_address, to_normalized_address + from eth2deposit.credentials import ( CredentialList, ) @@ -57,6 +60,18 @@ def validate_password(cts: click.Context, param: Any, password: str) -> str: return password +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("The given Eth1 address is not in hexadecimal encoded form.") + + normalized_address = to_normalized_address(address) + click.echo(f'\n**[Warning] you are setting Eth1 address {normalized_address} as your withdrawal address. ' + 'Please ensure that you have control over this address.**\n') + 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 @@ -91,6 +106,14 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ 'to ask you for your mnemonic as otherwise it will appear in your shell history.)'), prompt='Type the password that secures your validator keystore(s)', ), + click.option( + '--eth1_withdrawal_address', + default=None, + callback=validate_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.'), + ), ] for decorator in reversed(decorators): function = decorator(function) @@ -100,7 +123,8 @@ def generate_keys_arguments_decorator(function: Callable[..., Any]) -> Callable[ @click.command() @click.pass_context def generate_keys(ctx: click.Context, validator_start_index: int, - num_validators: int, folder: str, chain: str, keystore_password: str, **kwargs: Any) -> None: + num_validators: int, folder: str, chain: str, keystore_password: str, + eth1_withdrawal_address: HexAddress, **kwargs: Any) -> None: mnemonic = ctx.obj['mnemonic'] mnemonic_password = ctx.obj['mnemonic_password'] amounts = [MAX_DEPOSIT_AMOUNT] * num_validators @@ -118,12 +142,13 @@ 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, ) keystore_filefolders = credentials.export_keystores(password=keystore_password, folder=folder) deposits_file = credentials.export_deposit_data_json(folder=folder) if not credentials.verify_keystores(keystore_filefolders=keystore_filefolders, password=keystore_password): raise ValidationError("Failed to verify the keystores.") - if not verify_deposit_data_json(deposits_file): + if not verify_deposit_data_json(deposits_file, credentials.credentials): raise ValidationError("Failed to verify the deposit data JSON files.") click.echo('\nSuccess!\nYour keys can be found at: %s' % folder) click.pause('\n\nPress any key.') diff --git a/eth2deposit/credentials.py b/eth2deposit/credentials.py index a7d864af..c76b26d0 100644 --- a/eth2deposit/credentials.py +++ b/eth2deposit/credentials.py @@ -1,8 +1,12 @@ import os import click +from enum import Enum import time import json -from typing import Dict, List +from typing import Dict, List, Optional + +from eth_typing import Address, HexAddress +from eth_utils import to_canonical_address from py_ecc.bls import G2ProofOfPossession as bls from eth2deposit.exceptions import ValidationError @@ -14,6 +18,7 @@ from eth2deposit.settings import DEPOSIT_CLI_VERSION, BaseChainSetting from eth2deposit.utils.constants import ( BLS_WITHDRAWAL_PREFIX, + ETH1_ADDRESS_WITHDRAWAL_PREFIX, ETH2GWEI, MAX_DEPOSIT_AMOUNT, MIN_DEPOSIT_AMOUNT, @@ -27,13 +32,19 @@ ) +class WithdrawalType(Enum): + BLS_WITHDRAWAL = 0 + ETH1_ADDRESS_WITHDRAWAL = 1 + + class Credential: """ A Credential object contains all of the information for a single validator and the corresponding functionality. Once created, it is the only object that should be required to perform any processing for a validator. """ def __init__(self, *, mnemonic: str, mnemonic_password: str, - index: int, amount: int, chain_setting: BaseChainSetting): + index: int, amount: int, chain_setting: BaseChainSetting, + hex_eth1_withdrawal_address: Optional[HexAddress]): # Set path as EIP-2334 format # https://eips.ethereum.org/EIPS/eip-2334 purpose = '12381' @@ -48,6 +59,7 @@ def __init__(self, *, mnemonic: str, mnemonic_password: str, mnemonic=mnemonic, path=self.signing_key_path, password=mnemonic_password) self.amount = amount self.chain_setting = chain_setting + self.hex_eth1_withdrawal_address = hex_eth1_withdrawal_address @property def signing_pk(self) -> bytes: @@ -57,10 +69,42 @@ def signing_pk(self) -> bytes: def withdrawal_pk(self) -> bytes: return bls.SkToPk(self.withdrawal_sk) + @property + def eth1_withdrawal_address(self) -> Optional[Address]: + if self.hex_eth1_withdrawal_address is None: + return None + return to_canonical_address(self.hex_eth1_withdrawal_address) + + @property + def withdrawal_prefix(self) -> bytes: + if self.eth1_withdrawal_address is not None: + return ETH1_ADDRESS_WITHDRAWAL_PREFIX + else: + return BLS_WITHDRAWAL_PREFIX + + @property + def withdrawal_type(self) -> WithdrawalType: + if self.withdrawal_prefix == BLS_WITHDRAWAL_PREFIX: + return WithdrawalType.BLS_WITHDRAWAL + elif self.withdrawal_prefix == ETH1_ADDRESS_WITHDRAWAL_PREFIX: + return WithdrawalType.ETH1_ADDRESS_WITHDRAWAL + else: + raise ValueError(f"Invalid withdrawal_prefix {self.withdrawal_prefix.hex()}") + @property def withdrawal_credentials(self) -> bytes: - withdrawal_credentials = BLS_WITHDRAWAL_PREFIX - withdrawal_credentials += SHA256(self.withdrawal_pk)[1:] + if self.withdrawal_type == WithdrawalType.BLS_WITHDRAWAL: + withdrawal_credentials = BLS_WITHDRAWAL_PREFIX + withdrawal_credentials += SHA256(self.withdrawal_pk)[1:] + elif ( + self.withdrawal_type == WithdrawalType.ETH1_ADDRESS_WITHDRAWAL + and self.eth1_withdrawal_address is not None + ): + withdrawal_credentials = ETH1_ADDRESS_WITHDRAWAL_PREFIX + withdrawal_credentials += b'\x00' * 11 + withdrawal_credentials += self.eth1_withdrawal_address + else: + raise ValueError(f"Invalid withdrawal_type {self.withdrawal_type}") return withdrawal_credentials @property @@ -129,7 +173,8 @@ def from_mnemonic(cls, num_keys: int, amounts: List[int], chain_setting: BaseChainSetting, - start_index: int) -> 'CredentialList': + start_index: int, + hex_eth1_withdrawal_address: Optional[HexAddress]) -> 'CredentialList': if len(amounts) != num_keys: raise ValueError( f"The number of keys ({num_keys}) doesn't equal to the corresponding deposit amounts ({len(amounts)})." @@ -138,7 +183,8 @@ def from_mnemonic(cls, with click.progressbar(key_indices, label='Creating your keys:\t\t', show_percent=False, show_pos=True) as indices: return cls([Credential(mnemonic=mnemonic, mnemonic_password=mnemonic_password, - index=index, amount=amounts[index - start_index], chain_setting=chain_setting) + index=index, amount=amounts[index - start_index], chain_setting=chain_setting, + hex_eth1_withdrawal_address=hex_eth1_withdrawal_address) for index in indices]) def export_keystores(self, password: str, folder: str) -> List[str]: diff --git a/eth2deposit/utils/constants.py b/eth2deposit/utils/constants.py index a0f40a5d..13d1d629 100644 --- a/eth2deposit/utils/constants.py +++ b/eth2deposit/utils/constants.py @@ -6,6 +6,7 @@ # Eth2-spec constants taken from https://github.com/ethereum/eth2.0-specs/blob/dev/specs/phase0/beacon-chain.md DOMAIN_DEPOSIT = bytes.fromhex('03000000') BLS_WITHDRAWAL_PREFIX = bytes.fromhex('00') +ETH1_ADDRESS_WITHDRAWAL_PREFIX = bytes.fromhex('01') ETH2GWEI = 10 ** 9 MIN_DEPOSIT_AMOUNT = 2 ** 0 * ETH2GWEI diff --git a/eth2deposit/utils/validation.py b/eth2deposit/utils/validation.py index 96b8d5ca..23a513d8 100644 --- a/eth2deposit/utils/validation.py +++ b/eth2deposit/utils/validation.py @@ -4,7 +4,7 @@ BLSPubkey, BLSSignature, ) -from typing import Any, Dict +from typing import Any, Dict, Sequence from py_ecc.bls import G2ProofOfPossession as bls @@ -15,13 +15,19 @@ DepositData, DepositMessage, ) +from eth2deposit.credentials import ( + Credential, +) from eth2deposit.utils.constants import ( MAX_DEPOSIT_AMOUNT, MIN_DEPOSIT_AMOUNT, + BLS_WITHDRAWAL_PREFIX, + ETH1_ADDRESS_WITHDRAWAL_PREFIX, ) +from eth2deposit.utils.crypto import SHA256 -def verify_deposit_data_json(filefolder: str) -> bool: +def verify_deposit_data_json(filefolder: str, credentials: Sequence[Credential]) -> bool: """ Validate every deposit found in the deposit-data JSON file folder. """ @@ -29,11 +35,11 @@ def verify_deposit_data_json(filefolder: str) -> bool: deposit_json = json.load(f) with click.progressbar(deposit_json, label='Verifying your deposits:\t', show_percent=False, show_pos=True) as deposits: - return all([validate_deposit(deposit) for deposit in deposits]) + return all([validate_deposit(deposit, credential) for deposit, credential in zip(deposits, credentials)]) return False -def validate_deposit(deposit_data_dict: Dict[str, Any]) -> bool: +def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential) -> bool: ''' Checks whether a deposit is valid based on the eth2 rules. https://github.com/ethereum/eth2.0-specs/blob/dev/specs/phase0/beacon-chain.md#deposits @@ -45,6 +51,28 @@ def validate_deposit(deposit_data_dict: Dict[str, Any]) -> bool: deposit_message_root = bytes.fromhex(deposit_data_dict['deposit_data_root']) fork_version = bytes.fromhex(deposit_data_dict['fork_version']) + # Verify pubkey + if len(pubkey) != 48: + return False + if 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:] != SHA256(credential.withdrawal_pk)[1:]: + return False + elif withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX == credential.withdrawal_prefix: + if withdrawal_credentials[1:12] != b'\x00' * 11: + return False + if credential.eth1_withdrawal_address is None: + return False + if withdrawal_credentials[12:] != credential.eth1_withdrawal_address: + return False + else: + return False + # Verify deposit amount if not MIN_DEPOSIT_AMOUNT < amount <= MAX_DEPOSIT_AMOUNT: return False diff --git a/tests/test_cli/test_existing_menmonic.py b/tests/test_cli/test_existing_menmonic.py index 5c3623d1..2ecc97c2 100644 --- a/tests/test_cli/test_existing_menmonic.py +++ b/tests/test_cli/test_existing_menmonic.py @@ -1,16 +1,18 @@ import asyncio +import json import os import pytest - from click.testing import CliRunner +from eth_utils import decode_hex + from eth2deposit.deposit import cli -from eth2deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME +from eth2deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ETH1_ADDRESS_WITHDRAWAL_PREFIX from.helpers import clean_key_folder, get_permissions, get_uuid -def test_existing_mnemonic() -> None: +def test_existing_mnemonic_bls_withdrawal() -> None: # Prepare folder my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') clean_key_folder(my_folder_path) @@ -46,6 +48,57 @@ def test_existing_mnemonic() -> None: clean_key_folder(my_folder_path) +def test_existing_mnemonic_eth1_address_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) + + runner = CliRunner() + inputs = [ + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + '2', '2', '5', 'mainnet', 'MyPassword', 'MyPassword', 'yes'] + data = '\n'.join(inputs) + eth1_withdrawal_address = '0x00000000219ab540356cbb839cbe05303d7705fa' + arguments = [ + 'existing-mnemonic', + '--folder', my_folder_path, + '--mnemonic-password', 'TREZOR', + '--eth1_withdrawal_address', 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(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_new_mnemonic.py b/tests/test_cli/test_new_mnemonic.py index d588db28..4ff1d644 100644 --- a/tests/test_cli/test_new_mnemonic.py +++ b/tests/test_cli/test_new_mnemonic.py @@ -1,16 +1,19 @@ import asyncio +import json import os import pytest - from click.testing import CliRunner + +from eth_utils import decode_hex + from eth2deposit.cli import new_mnemonic from eth2deposit.deposit import cli -from eth2deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME +from eth2deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ETH1_ADDRESS_WITHDRAWAL_PREFIX from .helpers import clean_key_folder, get_permissions, get_uuid -def test_new_mnemonic(monkeypatch) -> None: +def test_new_mnemonic_bls_withdrawal(monkeypatch) -> None: # monkeypatch get_mnemonic def mock_get_mnemonic(language, words_path, entropy=None) -> str: return "fakephrase" @@ -49,6 +52,60 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str: clean_key_folder(my_folder_path) +def test_new_mnemonic_eth1_address_withdrawal(monkeypatch) -> None: + # monkeypatch get_mnemonic + def mock_get_mnemonic(language, words_path, entropy=None) -> str: + return "fakephrase" + + 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() + inputs = ['english', '1', 'mainnet', 'MyPassword', 'MyPassword', 'fakephrase'] + data = '\n'.join(inputs) + eth1_withdrawal_address = '0x00000000219ab540356cbb839cbe05303d7705fa' + arguments = [ + 'new-mnemonic', + '--folder', my_folder_path, + '--eth1_withdrawal_address', 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(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) + + @pytest.mark.asyncio async def test_script() -> None: my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') diff --git a/tests/test_credentials.py b/tests/test_credentials.py index fa14b6be..53669301 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -13,4 +13,5 @@ def test_from_mnemonic() -> None: amounts=[32, 32], chain_setting=MainnetSetting, start_index=1, + hex_eth1_withdrawal_address=None, )