diff --git a/.gitignore b/.gitignore index e4add855e..d8c216b70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ validator_keys +bls_to_execution_changes # Python testing & linting: build/ 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 000000000..172627236 --- /dev/null +++ b/staking_deposit/cli/generate_bls_to_execution_change.py @@ -0,0 +1,180 @@ +import os +import click +from typing import ( + Any, +) + +from eth_typing import HexAddress + +from staking_deposit.credentials import ( + CredentialList, +) +from staking_deposit.utils.validation import ( + validate_bls_withdrawal_credentials, + validate_bls_withdrawal_credentials_matching, + validate_eth1_withdrawal_address, + validate_int_range, +) +from staking_deposit.utils.constants import ( + BTEC_FORK_VERSIONS, + CAPELLA, + 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.utils.intl import ( + closest_match, + load_text, +) +from staking_deposit.settings import ( + ALL_CHAINS, + MAINNET, + PRATER, + get_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() +@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) + ), +) +@jit_option( + callback=captive_prompt_callback( + lambda x: closest_match(x, list(BTEC_FORK_VERSIONS.keys())), + choice_prompt_func( + lambda: load_text(['arg_fork', 'prompt'], func=FUNC_NAME), + list(BTEC_FORK_VERSIONS.keys()) + ), + ), + default=CAPELLA, + help=lambda: load_text(['arg_fork', 'help'], func=FUNC_NAME), + param_decls='--fork', + prompt=choice_prompt_func( + lambda: load_text(['arg_fork', 'prompt'], func=FUNC_NAME), + list(key for key in BTEC_FORK_VERSIONS.keys()) + ), +) +@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), + lambda: load_text(['arg_validator_start_index', 'confirm'], 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 num: validate_int_range(num, 0, 2**32), + lambda: load_text(['arg_validator_index', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_validator_index', 'help'], func=FUNC_NAME), + param_decls='--validator_index', + prompt=lambda: load_text(['arg_validator_index', 'prompt'], func=FUNC_NAME), +) +@jit_option( + callback=captive_prompt_callback( + lambda bls_withdrawal_credentials: validate_bls_withdrawal_credentials(bls_withdrawal_credentials), + lambda: load_text(['arg_bls_withdrawal_credentials', 'prompt'], func=FUNC_NAME), + ), + help=lambda: load_text(['arg_bls_withdrawal_credentials', 'help'], func=FUNC_NAME), + param_decls='--bls_withdrawal_credentials', + prompt=lambda: load_text(['arg_bls_withdrawal_credentials', '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), + ), + help=lambda: load_text(['arg_execution_address', 'help'], func=FUNC_NAME), + param_decls='--execution_address', + prompt=lambda: load_text(['arg_execution_address', 'prompt'], func=FUNC_NAME), +) +@click.pass_context +def generate_bls_to_execution_change( + ctx: click.Context, + bls_to_execution_changes_folder: str, + chain: str, + fork: str, + mnemonic: str, + mnemonic_password: str, + validator_start_index: int, + validator_index: int, + bls_withdrawal_credentials: bytes, + execution_address: HexAddress, + **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) + + # Get FORK_VERSION + fork_version = BTEC_FORK_VERSIONS[fork] + + # TODO: generate multiple? + num_validators = 1 + 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, + btec_fork_version=fork_version, + ) + + if len(credentials.credentials) != 1: + raise ValueError(f"It should only generate one credential, but get {len(credentials.credentials)}.") + + # Check if the given old bls_withdrawal_credentials is as same as the mnemonic generated + validate_bls_withdrawal_credentials_matching(bls_withdrawal_credentials, credentials.credentials[0]) + + credentials.export_bls_to_execution_change_json(bls_to_execution_changes_folder, validator_index) + + click.pause(load_text(['msg_pause'])) diff --git a/staking_deposit/credentials.py b/staking_deposit/credentials.py index 80a4ef862..413f3d52c 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 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, ) @@ -45,7 +48,8 @@ class Credential: """ def __init__(self, *, mnemonic: str, mnemonic_password: str, index: int, amount: int, chain_setting: BaseChainSetting, - hex_eth1_withdrawal_address: Optional[HexAddress]): + hex_eth1_withdrawal_address: Optional[HexAddress], + btec_fork_version: Optional[bytes]=None): # Set path as EIP-2334 format # https://eips.ethereum.org/EIPS/eip-2334 purpose = '12381' @@ -61,6 +65,7 @@ def __init__(self, *, mnemonic: str, mnemonic_password: str, self.amount = amount self.chain_setting = chain_setting self.hex_eth1_withdrawal_address = hex_eth1_withdrawal_address + self.btec_fork_version = btec_fork_version @property def signing_pk(self) -> bytes: @@ -158,6 +163,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.") + + if self.btec_fork_version is None: + raise ValueError("The BLSToExecutionChange signing fork version 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.btec_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 = { + 'valdiator_index': signed_bls_to_execution_change.message.validator_index, + 'from_bls_pubkey': signed_bls_to_execution_change.message.from_bls_pubkey.hex(), + 'to_execution_address': signed_bls_to_execution_change.message.to_execution_address.hex(), + } + result_dict.update({'message': message}) + result_dict.update({'signature': signed_bls_to_execution_change.signature}) + + # meta + result_dict.update({'fork_version': self.btec_fork_version}) + result_dict.update({'network_name': self.chain_setting.NETWORK_NAME}) + result_dict.update({'genesis_validators_root': self.chain_setting.GENESIS_VALIDATORS_ROOT}) + result_dict.update({'deposit_cli_version': DEPOSIT_CLI_VERSION}) + return result_dict + class CredentialList: """ @@ -175,7 +222,8 @@ def from_mnemonic(cls, amounts: List[int], chain_setting: BaseChainSetting, start_index: int, - hex_eth1_withdrawal_address: Optional[HexAddress]) -> 'CredentialList': + hex_eth1_withdrawal_address: Optional[HexAddress], + btec_fork_version: Optional[bytes]=None) -> '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)})." @@ -185,7 +233,8 @@ def from_mnemonic(cls, 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, - hex_eth1_withdrawal_address=hex_eth1_withdrawal_address) + hex_eth1_withdrawal_address=hex_eth1_withdrawal_address, + btec_fork_version=btec_fork_version) for index in indices]) def export_keystores(self, password: str, folder: str) -> List[str]: @@ -210,3 +259,15 @@ 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_index: 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_index) for cred in credentials] + + filefolder = os.path.join(folder, 'bls_to_execution_change-%i-%i.json' % (validator_index, time.time())) + with open(filefolder, 'w') as f: + json.dump(bls_to_execution_changes, f, default=lambda x: x.hex()) + 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 c224bfd2c..9bb3da215 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, @@ -53,6 +54,7 @@ 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__': 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 000000000..464a965ed --- /dev/null +++ b/staking_deposit/intl/en/cli/generate_bls_to_execution_change.json @@ -0,0 +1,39 @@ +{ + "generate_bls_to_execution_change": { + "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", + "confirm": "Repeat your execution address for confirmation." + }, + "arg_validator_index": { + "help": "The validator index of the certain validator", + "prompt": "Please enter the validator index of your validator" + }, + "arg_bls_withdrawal_credentials": { + "help": "The 32-byte old BLS withdrawal credentials of the certain validator", + "prompt": "Please enter the old BLS withdrawal credentials of your validator" + }, + "arg_validator_start_index": { + "help": "The index (key number) of the signging key you want to use with this mnemonic", + "prompt": "Please enter the index (key number) of the signging key you want to use with this mnemonic.", + "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_validator_keys_folder": { + "help": "The folder path for the keystore(s). Pointing to `./validator_keys` by default." + }, + "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 data can be found at: ", + "msg_pause": "\n\nPress any key." + } +} diff --git a/staking_deposit/intl/en/credentials.json b/staking_deposit/intl/en/credentials.json index da6f65598..85d726126 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 80a241105..80296adcc 100644 --- a/staking_deposit/intl/en/utils/validation.json +++ b/staking_deposit/intl/en/utils/validation.json @@ -14,5 +14,11 @@ "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.**" + }, + "validate_bls_withdrawal_credentials": { + "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 is matching the old BLS withdrawal credentials that mnemonic generated." } -} \ No newline at end of file +} diff --git a/staking_deposit/settings.py b/staking_deposit/settings.py index 3f09010ef..e3f4ab39c 100644 --- a/staking_deposit/settings.py +++ b/staking_deposit/settings.py @@ -7,6 +7,7 @@ class BaseChainSetting(NamedTuple): NETWORK_NAME: str GENESIS_FORK_VERSION: bytes + GENESIS_VALIDATORS_ROOT: bytes MAINNET = 'mainnet' @@ -16,17 +17,29 @@ class BaseChainSetting(NamedTuple): KILN = 'kiln' SEPOLIA = 'sepolia' +# FIXME: use the real testnet genesis_validators_root +GENESIS_VALIDATORS_ROOT_STUB = bytes.fromhex('4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95') # Mainnet setting -MainnetSetting = BaseChainSetting(NETWORK_NAME=MAINNET, GENESIS_FORK_VERSION=bytes.fromhex('00000000')) +MainnetSetting = BaseChainSetting( + NETWORK_NAME=MAINNET, GENESIS_FORK_VERSION=bytes.fromhex('00000000'), + GENESIS_VALIDATORS_ROOT=bytes.fromhex('4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95')) # Ropsten setting -RopstenSetting = BaseChainSetting(NETWORK_NAME=ROPSTEN, GENESIS_FORK_VERSION=bytes.fromhex('80000069')) +RopstenSetting = BaseChainSetting( + NETWORK_NAME=ROPSTEN, GENESIS_FORK_VERSION=bytes.fromhex('80000069'), + GENESIS_VALIDATORS_ROOT=GENESIS_VALIDATORS_ROOT_STUB) # 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=GENESIS_VALIDATORS_ROOT_STUB) # Merge Testnet (spec v1.1.9) -KilnSetting = BaseChainSetting(NETWORK_NAME=KILN, GENESIS_FORK_VERSION=bytes.fromhex('70000069')) +KilnSetting = BaseChainSetting( + NETWORK_NAME=KILN, GENESIS_FORK_VERSION=bytes.fromhex('70000069'), + GENESIS_VALIDATORS_ROOT=GENESIS_VALIDATORS_ROOT_STUB) # 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=GENESIS_VALIDATORS_ROOT_STUB) ALL_CHAINS: Dict[str, BaseChainSetting] = { diff --git a/staking_deposit/utils/constants.py b/staking_deposit/utils/constants.py index 12f6122a0..a055275e1 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,11 +21,19 @@ # 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') +# BLSToExecutionChange signing fork versions +CAPELLA = 'capella' +BTEC_FORK_VERSIONS: Dict[str, bytes] = { + CAPELLA: bytes.fromhex('03000000'), +} + + def _add_index_to_options(d: Dict[str, List[str]]) -> Dict[str, List[str]]: ''' Adds the (1 indexed) index (in the dict) to the first element of value list. diff --git a/staking_deposit/utils/ssz.py b/staking_deposit/utils/ssz.py index e079a007d..513b00966 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 d3bdd7ab3..4b762ba29 100644 --- a/staking_deposit/utils/validation.py +++ b/staking_deposit/utils/validation.py @@ -1,11 +1,13 @@ import click import json +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, to_normalized_address from py_ecc.bls import G2ProofOfPossession as bls from staking_deposit.exceptions import ValidationError @@ -112,3 +114,30 @@ 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 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 validate_bls_withdrawal_credentials(bls_withdrawal_credentials: str) -> bytes: + bls_withdrawal_credentials_bytes = bytes.fromhex(bls_withdrawal_credentials) + 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'])) + + return bls_withdrawal_credentials_bytes + + +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'])) diff --git a/tests/test_cli/helpers.py b/tests/test_cli/helpers.py index 127e04a1d..8d5aa8b9d 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: diff --git a/tests/test_cli/test_existing_menmonic.py b/tests/test_cli/test_existing_menmonic.py index bcd5a8a18..4e62d65db 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: 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 000000000..3a1c0e51b --- /dev/null +++ b/tests/test_cli/test_generate_bls_to_execution_change.py @@ -0,0 +1,51 @@ +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, + get_permissions, +) + + +def test_existing_mnemonic_bls_withdrawal() -> None: + # Prepare folder + my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER') + clean_btec_folder(my_folder_path) + if not os.path.exists(my_folder_path): + os.mkdir(my_folder_path) + + runner = CliRunner() + inputs = ['0'] # confirm `validator_start_index` + data = '\n'.join(inputs) + arguments = [ + '--language', 'english', + 'generate-bls-to-execution-change', + '--bls_to_execution_changes_folder', my_folder_path, + '--chain', 'mainnet', + '--fork', 'capella', + '--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', '00bd0b5a34de5fb17df08410b5e615dda87caf4fb72d0aac91ce5e52fc6aa8de', + '--validator_start_index', '0', + '--validator_index', '1', + '--execution_address', '3434343434343434343434343434343434343434', + ] + 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 + if os.name == 'posix': + for file_name in btec_files: + assert get_permissions(bls_to_execution_changes_folder_path, file_name) == '0o440' + + # Clean up + clean_btec_folder(my_folder_path)