Skip to content

Commit

Permalink
Merge pull request #195 from ethereum/eth1-address-withdrawal
Browse files Browse the repository at this point in the history
Add Eth1 address withdrawal support
  • Loading branch information
hwwhww committed Mar 30, 2021
2 parents d27f7e2 + 40b78dc commit 27abb5c
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 18 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
29 changes: 27 additions & 2 deletions eth2deposit/cli/generate_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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.')
58 changes: 52 additions & 6 deletions eth2deposit/credentials.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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'
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)})."
Expand All @@ -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]:
Expand Down
1 change: 1 addition & 0 deletions eth2deposit/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 32 additions & 4 deletions eth2deposit/utils/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,25 +15,31 @@
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.
"""
with open(filefolder, 'r') as f:
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
Expand All @@ -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
Expand Down
59 changes: 56 additions & 3 deletions tests/test_cli/test_existing_menmonic.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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')
Expand Down
Loading

0 comments on commit 27abb5c

Please sign in to comment.