Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Eth1 address withdrawal support #195

Merged
merged 7 commits into from
Mar 30, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 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. |

###### `existing-mnemonic` Arguments

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):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the prefix 0x is optional here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hello word

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.')
55 changes: 49 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,39 @@ 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:
withdrawal_credentials = ETH1_ADDRESS_WITHDRAWAL_PREFIX
withdrawal_credentials += b'\x00' * 11
withdrawal_credentials += self.eth1_withdrawal_address
hwwhww marked this conversation as resolved.
Show resolved Hide resolved
else:
raise ValueError(f"Invalid withdrawal_type {self.withdrawal_type}")
return withdrawal_credentials

@property
Expand Down Expand Up @@ -129,7 +170,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 +180,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
CarlBeek marked this conversation as resolved.
Show resolved Hide resolved
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
46 changes: 45 additions & 1 deletion tests/test_cli/test_new_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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"
Expand Down Expand Up @@ -49,6 +49,50 @@ 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)
args = [
'new-mnemonic',
'--folder', my_folder_path,
'--eth1_withdrawal_address', '0x00000000219ab540356cbb839cbe05303d7705fa'
]
result = runner.invoke(cli, args, 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))

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')
Expand Down
1 change: 1 addition & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ def test_from_mnemonic() -> None:
amounts=[32, 32],
chain_setting=MainnetSetting,
start_index=1,
hex_eth1_withdrawal_address=None,
)