diff --git a/eth/_utils/bls.py b/eth/_utils/bls.py index 0a21bbc30b..7da86df998 100644 --- a/eth/_utils/bls.py +++ b/eth/_utils/bls.py @@ -170,15 +170,22 @@ def privtopub(k: int) -> int: def verify(message: bytes, pubkey: int, signature: bytes, domain: int) -> bool: - final_exponentiation = final_exponentiate( - pairing(FQP_point_to_FQ2_point(decompress_G2(signature)), G1, False) * - pairing( - FQP_point_to_FQ2_point(hash_to_G2(message, domain)), - neg(decompress_G1(pubkey)), - False + try: + final_exponentiation = final_exponentiate( + pairing( + FQP_point_to_FQ2_point(decompress_G2(signature)), + G1, + final_exponentiate=False, + ) * + pairing( + FQP_point_to_FQ2_point(hash_to_G2(message, domain)), + neg(decompress_G1(pubkey)), + final_exponentiate=False, + ) ) - ) - return final_exponentiation == FQ12.one() + return final_exponentiation == FQ12.one() + except (ValidationError, ValueError, AssertionError): + return False def aggregate_signatures(signatures: Sequence[bytes]) -> Tuple[int, int]: @@ -208,16 +215,19 @@ def verify_multiple(pubkeys: Sequence[int], ) ) - o = FQ12([1] + [0] * 11) - for m_pubs in set(messages): - # aggregate the pubs - group_pub = Z1 - for i in range(len_msgs): - if messages[i] == m_pubs: - group_pub = add(group_pub, decompress_G1(pubkeys[i])) - - o *= pairing(hash_to_G2(m_pubs, domain), group_pub, False) - o *= pairing(decompress_G2(signature), neg(G1), False) - - final_exponentiation = final_exponentiate(o) - return final_exponentiation == FQ12.one() + try: + o = FQ12([1] + [0] * 11) + for m_pubs in set(messages): + # aggregate the pubs + group_pub = Z1 + for i in range(len_msgs): + if messages[i] == m_pubs: + group_pub = add(group_pub, decompress_G1(pubkeys[i])) + + o *= pairing(hash_to_G2(m_pubs, domain), group_pub, final_exponentiate=False) + o *= pairing(decompress_G2(signature), neg(G1), final_exponentiate=False) + + final_exponentiation = final_exponentiate(o) + return final_exponentiation == FQ12.one() + except (ValidationError, ValueError, AssertionError): + return False diff --git a/eth/beacon/deposit_helpers.py b/eth/beacon/deposit_helpers.py new file mode 100644 index 0000000000..65b9b62db1 --- /dev/null +++ b/eth/beacon/deposit_helpers.py @@ -0,0 +1,159 @@ +from typing import ( + Sequence, + Tuple, +) + +from eth_typing import ( + Hash32, +) +from eth_utils import ( + ValidationError, +) + +from eth._utils import bls + +from eth.beacon.constants import ( + EMPTY_SIGNATURE, +) +from eth.beacon.enums import ( + SignatureDomain, +) +from eth.beacon.exceptions import ( + MinEmptyValidatorIndexNotFound, +) +from eth.beacon.types.deposit_input import DepositInput +from eth.beacon.types.states import BeaconState +from eth.beacon.types.validator_records import ValidatorRecord +from eth.beacon.helpers import ( + get_domain, +) + + +def get_min_empty_validator_index(validators: Sequence[ValidatorRecord], + current_slot: int, + zero_balance_validator_ttl: int) -> int: + for index, validator in enumerate(validators): + is_empty = ( + validator.balance == 0 and + validator.latest_status_change_slot + zero_balance_validator_ttl <= current_slot + ) + if is_empty: + return index + raise MinEmptyValidatorIndexNotFound() + + +def validate_proof_of_possession(state: BeaconState, + pubkey: int, + proof_of_possession: bytes, + withdrawal_credentials: Hash32, + randao_commitment: Hash32) -> None: + deposit_input = DepositInput( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + proof_of_possession=EMPTY_SIGNATURE, + ) + + is_valid_signature = bls.verify( + pubkey=pubkey, + # TODO: change to hash_tree_root(deposit_input) when we have SSZ tree hashing + message=deposit_input.root, + signature=proof_of_possession, + domain=get_domain( + state.fork_data, + state.slot, + SignatureDomain.DOMAIN_DEPOSIT, + ), + ) + + if not is_valid_signature: + raise ValidationError( + "BLS signature verification error" + ) + + +def add_pending_validator(state: BeaconState, + validator: ValidatorRecord, + zero_balance_validator_ttl: int) -> Tuple[BeaconState, int]: + """ + Add a validator to the existing minimum empty validator index or + append to ``validator_registry``. + """ + # Check if there's empty validator index in `validator_registry` + try: + index = get_min_empty_validator_index( + state.validator_registry, + state.slot, + zero_balance_validator_ttl, + ) + except MinEmptyValidatorIndexNotFound: + index = None + + # Append to the validator_registry + validator_registry = state.validator_registry + (validator,) + state = state.copy( + validator_registry=validator_registry, + ) + index = len(state.validator_registry) - 1 + else: + # Use the empty validator index + state = state.update_validator(index, validator) + + return state, index + + +def process_deposit(*, + state: BeaconState, + pubkey: int, + deposit: int, + proof_of_possession: bytes, + withdrawal_credentials: Hash32, + randao_commitment: Hash32, + zero_balance_validator_ttl: int) -> Tuple[BeaconState, int]: + """ + Process a deposit from Ethereum 1.0. + """ + validate_proof_of_possession( + state, + pubkey, + proof_of_possession, + withdrawal_credentials, + randao_commitment, + ) + + validator_pubkeys = tuple(v.pubkey for v in state.validator_registry) + if pubkey not in validator_pubkeys: + validator = ValidatorRecord.get_pending_validator( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + balance=deposit, + latest_status_change_slot=state.slot, + ) + + state, index = add_pending_validator( + state, + validator, + zero_balance_validator_ttl, + ) + else: + # Top-up - increase balance by deposit + index = validator_pubkeys.index(pubkey) + validator = state.validator_registry[index] + + if validator.withdrawal_credentials != withdrawal_credentials: + raise ValidationError( + "`withdrawal_credentials` are incorrect:\n" + "\texpected: %s, found: %s" % ( + validator.withdrawal_credentials, + validator.withdrawal_credentials, + ) + ) + + # Update validator's balance and state + validator = validator.copy( + balance=validator.balance + deposit, + ) + state = state.update_validator(index, validator) + + return state, index diff --git a/eth/beacon/exceptions.py b/eth/beacon/exceptions.py new file mode 100644 index 0000000000..d58c975fc3 --- /dev/null +++ b/eth/beacon/exceptions.py @@ -0,0 +1,10 @@ +from eth.exceptions import ( + PyEVMError, +) + + +class MinEmptyValidatorIndexNotFound(PyEVMError): + """ + No empty slot in the validator registry + """ + pass diff --git a/eth/beacon/helpers.py b/eth/beacon/helpers.py index 65abd723fe..7cc9ec9599 100644 --- a/eth/beacon/helpers.py +++ b/eth/beacon/helpers.py @@ -20,19 +20,13 @@ get_bitfield_length, has_voted, ) -from eth.beacon._utils.hash import ( - hash_eth2, -) from eth._utils.numeric import ( clamp, ) -from eth.beacon.block_committees_info import ( - BlockCommitteesInfo, -) -from eth.beacon.types.shard_committees import ( - ShardCommittee, -) +from eth.beacon.block_committees_info import BlockCommitteesInfo +from eth.beacon.types.shard_committees import ShardCommittee +from eth.beacon.types.validator_registry_delta_block import ValidatorRegistryDeltaBlock from eth.beacon._utils.random import ( shuffle, split, @@ -364,19 +358,19 @@ def get_effective_balance(validator: 'ValidatorRecord', max_deposit: int) -> int def get_new_validator_registry_delta_chain_tip(current_validator_registry_delta_chain_tip: Hash32, - index: int, + validator_index: int, pubkey: int, flag: int) -> Hash32: """ Compute the next hash in the validator registry delta hash chain. """ - return hash_eth2( - current_validator_registry_delta_chain_tip + - flag.to_bytes(1, 'big') + - index.to_bytes(3, 'big') + - # TODO: currently, we use 256-bit pubkey which is different form the spec - pubkey.to_bytes(32, 'big') - ) + # TODO: switch to SSZ tree hashing + return ValidatorRegistryDeltaBlock( + latest_registry_delta_root=current_validator_registry_delta_chain_tip, + validator_index=validator_index, + pubkey=pubkey, + flag=flag, + ).root def get_fork_version(fork_data: 'ForkData', diff --git a/eth/beacon/types/deposit_input.py b/eth/beacon/types/deposit_input.py index 6edcc810c0..8d75990608 100644 --- a/eth/beacon/types/deposit_input.py +++ b/eth/beacon/types/deposit_input.py @@ -14,6 +14,7 @@ hash32, uint384, ) +from eth.beacon._utils.hash import hash_eth2 class DepositInput(rlp.Serializable): @@ -23,12 +24,12 @@ class DepositInput(rlp.Serializable): fields = [ # BLS pubkey ('pubkey', uint384), - # BLS proof of possession (a BLS signature) - ('proof_of_possession', CountableList(uint384)), # Withdrawal credentials ('withdrawal_credentials', hash32), # Initial RANDAO commitment ('randao_commitment', hash32), + # BLS proof of possession (a BLS signature) + ('proof_of_possession', CountableList(uint384)), ] def __init__(self, @@ -37,8 +38,16 @@ def __init__(self, randao_commitment: Hash32, proof_of_possession: Sequence[int]=(0, 0)) -> None: super().__init__( - pubkey, - proof_of_possession, - withdrawal_credentials, - randao_commitment, + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + proof_of_possession=proof_of_possession, ) + + _root = None + + @property + def root(self) -> Hash32: + if self._root is None: + self._root = hash_eth2(rlp.encode(self)) + return self._root diff --git a/eth/beacon/types/states.py b/eth/beacon/types/states.py index 784cc9bd09..505b6997b5 100644 --- a/eth/beacon/types/states.py +++ b/eth/beacon/types/states.py @@ -157,3 +157,13 @@ def num_validators(self) -> int: @property def num_crosslinks(self) -> int: return len(self.latest_crosslinks) + + def update_validator(self, + validator_index: int, + validator: ValidatorRecord) -> 'BeaconState': + validator_registry = list(self.validator_registry) + validator_registry[validator_index] = validator + updated_state = self.copy( + validator_registry=tuple(validator_registry), + ) + return updated_state diff --git a/eth/beacon/types/validator_records.py b/eth/beacon/types/validator_records.py index 5c76f8552c..746a30e247 100644 --- a/eth/beacon/types/validator_records.py +++ b/eth/beacon/types/validator_records.py @@ -68,3 +68,24 @@ def is_active(self) -> bool: Returns ``True`` if the validator is active. """ return self.status in VALIDATOR_RECORD_ACTIVE_STATUSES + + @classmethod + def get_pending_validator(cls, + pubkey: int, + withdrawal_credentials: Hash32, + randao_commitment: Hash32, + balance: int, + latest_status_change_slot: int) -> 'ValidatorRecord': + """ + Return a new pending ``ValidatorRecord`` with the given fields. + """ + return cls( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + randao_layers=0, + balance=balance, + status=ValidatorStatusCode.PENDING_ACTIVATION, + latest_status_change_slot=latest_status_change_slot, + exit_count=0, + ) diff --git a/tests/beacon/test_deposit_helpers.py b/tests/beacon/test_deposit_helpers.py new file mode 100644 index 0000000000..f1fd83a7dd --- /dev/null +++ b/tests/beacon/test_deposit_helpers.py @@ -0,0 +1,285 @@ +import pytest + +from eth_utils import ( + denoms, + ValidationError, +) + +from eth._utils import bls + +from eth.beacon.constants import ( + EMPTY_SIGNATURE, +) +from eth.beacon.deposit_helpers import ( + add_pending_validator, + get_min_empty_validator_index, + process_deposit, + validate_proof_of_possession, +) +from eth.beacon.enums import ( + SignatureDomain, +) +from eth.beacon.exceptions import ( + MinEmptyValidatorIndexNotFound, +) +from eth.beacon.helpers import ( + get_domain, +) +from eth.beacon.types.states import BeaconState +from eth.beacon.types.deposit_input import DepositInput +from eth.beacon.types.validator_records import ValidatorRecord + + +def sign_proof_of_possession(deposit_input, privkey, domain): + return bls.sign(deposit_input.root, privkey, domain) + + +def make_deposit_input(pubkey, withdrawal_credentials, randao_commitment): + return DepositInput( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + proof_of_possession=EMPTY_SIGNATURE, + ) + + +@pytest.mark.parametrize( + "balance," + "latest_status_change_slot," + "zero_balance_validator_ttl," + "current_slot," + "expected", + ( + (0, 1, 1, 2, 0), + (1, 1, 1, 2, MinEmptyValidatorIndexNotFound()), # not (balance == 0) + (0, 1, 1, 1, MinEmptyValidatorIndexNotFound()), # not (validator.latest_status_change_slot + zero_balance_validator_ttl <= current_slot) # noqa: E501 + ), +) +def test_get_min_empty_validator_index(sample_validator_record_params, + balance, + latest_status_change_slot, + zero_balance_validator_ttl, + current_slot, + expected): + validators = [ + ValidatorRecord(**sample_validator_record_params).copy( + balance=balance, + latest_status_change_slot=latest_status_change_slot, + ) + for _ in range(10) + ] + if isinstance(expected, Exception): + with pytest.raises(MinEmptyValidatorIndexNotFound): + get_min_empty_validator_index( + validators=validators, + current_slot=current_slot, + zero_balance_validator_ttl=zero_balance_validator_ttl, + ) + else: + result = get_min_empty_validator_index( + validators=validators, + current_slot=current_slot, + zero_balance_validator_ttl=zero_balance_validator_ttl, + ) + assert result == expected + + +@pytest.mark.parametrize( + "validator_registry_len," + "min_empty_validator_index_result," + "expected_index", + ( + (10, 1, 1), + (10, 5, 5), + (10, None, 10), + ), +) +def test_add_pending_validator(monkeypatch, + sample_beacon_state_params, + sample_validator_record_params, + validator_registry_len, + min_empty_validator_index_result, + expected_index): + from eth.beacon import deposit_helpers + + def mock_get_min_empty_validator_index(validators, + current_slot, + zero_balance_validator_ttl): + if min_empty_validator_index_result is None: + raise MinEmptyValidatorIndexNotFound() + else: + return min_empty_validator_index_result + + monkeypatch.setattr( + deposit_helpers, + 'get_min_empty_validator_index', + mock_get_min_empty_validator_index + ) + + state = BeaconState(**sample_beacon_state_params).copy( + validator_registry=[ + ValidatorRecord(**sample_validator_record_params).copy( + balance=100, + ) + for _ in range(validator_registry_len) + ] + ) + validator = ValidatorRecord(**sample_validator_record_params).copy( + balance=5566, + ) + state, index = add_pending_validator( + state, + validator, + zero_balance_validator_ttl=0, # it's for `get_min_empty_validator_index` + ) + assert index == expected_index + assert state.validator_registry[index] == validator + + +@pytest.mark.parametrize( + "expected", + ( + (True), + (ValidationError), + ), +) +def test_validate_proof_of_possession(sample_beacon_state_params, pubkeys, privkeys, expected): + state = BeaconState(**sample_beacon_state_params) + + privkey = privkeys[0] + pubkey = pubkeys[0] + withdrawal_credentials = b'\x34' * 32 + randao_commitment = b'\x56' * 32 + domain = get_domain( + state.fork_data, + state.slot, + SignatureDomain.DOMAIN_DEPOSIT, + ) + + deposit_input = make_deposit_input( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + if expected is True: + proof_of_possession = sign_proof_of_possession(deposit_input, privkey, domain) + + validate_proof_of_possession( + state=state, + pubkey=pubkey, + proof_of_possession=proof_of_possession, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + else: + proof_of_possession = b'\x11' * 32 + with pytest.raises(ValidationError): + validate_proof_of_possession( + state=state, + pubkey=pubkey, + proof_of_possession=proof_of_possession, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + + +def test_process_deposit(sample_beacon_state_params, + zero_balance_validator_ttl, + privkeys, + pubkeys): + state = BeaconState(**sample_beacon_state_params).copy( + slot=zero_balance_validator_ttl + 1, + validator_registry=(), + ) + + privkey_1 = privkeys[0] + pubkey_1 = pubkeys[0] + deposit = 32 * denoms.gwei + withdrawal_credentials = b'\x34' * 32 + randao_commitment = b'\x56' * 32 + domain = get_domain( + state.fork_data, + state.slot, + SignatureDomain.DOMAIN_DEPOSIT, + ) + + deposit_input = make_deposit_input( + pubkey=pubkey_1, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + proof_of_possession = sign_proof_of_possession(deposit_input, privkey_1, domain) + # Add the first validator + result_state, index = process_deposit( + state=state, + pubkey=pubkey_1, + deposit=deposit, + proof_of_possession=proof_of_possession, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + zero_balance_validator_ttl=zero_balance_validator_ttl, + ) + + assert len(result_state.validator_registry) == 1 + index = 0 + assert result_state.validator_registry[0].pubkey == pubkey_1 + assert result_state.validator_registry[index].withdrawal_credentials == withdrawal_credentials + assert result_state.validator_registry[index].randao_commitment == randao_commitment + assert result_state.validator_registry[index].balance == deposit + # test immutable + assert len(state.validator_registry) == 0 + + # Add the second validator + privkey_2 = privkeys[1] + pubkey_2 = pubkeys[1] + deposit_input = make_deposit_input( + pubkey=pubkey_2, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + proof_of_possession = sign_proof_of_possession(deposit_input, privkey_2, domain) + result_state, index = process_deposit( + state=result_state, + pubkey=pubkey_2, + deposit=deposit, + proof_of_possession=proof_of_possession, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + zero_balance_validator_ttl=zero_balance_validator_ttl, + ) + assert len(result_state.validator_registry) == 2 + assert result_state.validator_registry[1].pubkey == pubkey_2 + + # Force the first validator exited -> a empty slot in state.validator_registry. + result_state = result_state.copy( + validator_registry=( + result_state.validator_registry[0].copy( + balance=0, + latest_status_change_slot=0, + ), + result_state.validator_registry[1], + ) + ) + + # Add the third validator. + # Should overwrite previously exited validator. + privkey_3 = privkeys[2] + pubkey_3 = pubkeys[2] + deposit_input = make_deposit_input( + pubkey=pubkey_3, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + ) + proof_of_possession = sign_proof_of_possession(deposit_input, privkey_3, domain) + result_state, index = process_deposit( + state=result_state, + pubkey=pubkey_3, + deposit=deposit, + proof_of_possession=proof_of_possession, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + zero_balance_validator_ttl=zero_balance_validator_ttl, + ) + # Overwrite the second validator. + assert len(result_state.validator_registry) == 2 + assert result_state.validator_registry[0].pubkey == pubkey_3 diff --git a/tests/beacon/test_helpers.py b/tests/beacon/test_helpers.py index b61fc8b629..fcdb39ac5f 100644 --- a/tests/beacon/test_helpers.py +++ b/tests/beacon/test_helpers.py @@ -467,10 +467,12 @@ def mock_get_shard_committees_at_slot(state, def test_get_active_validator_indices(sample_validator_record_params): - # 3 validators are ACTIVE by default. + # 3 validators are ACTIVE validators = [ ValidatorRecord( **sample_validator_record_params, + ).copy( + status=ValidatorStatusCode.ACTIVE, ) for i in range(3) ] @@ -613,7 +615,7 @@ def test_get_effective_balance(balance, max_deposit, expected, sample_validator_ @pytest.mark.parametrize( ( - 'index,' + 'validator_index,' 'pubkey,' 'flag,' 'expected' @@ -623,17 +625,17 @@ def test_get_effective_balance(balance, max_deposit, expected, sample_validator_ 1, 2 * 256 - 1, 1, - b'\xe8\xaaH\x14\xa3\xban\x8f^rn1\xdf\xfd\xe1\xed\xe9S*\x80\xf5\xe3\x03\x983\x15\xd1\x91t\xcc\xb4h' # noqa: E501 + b'\xb8K\xad[zDE\xef\x00Z\x9c\x04\xdc\x95\xff\x9c\xeaP\x15\xf5\xfb\xdd\x0f\x1c:\xd7U+\x81\x92:\xee' # noqa: E501 ), ] ) -def test_get_new_validator_registry_delta_chain_tip(index, +def test_get_new_validator_registry_delta_chain_tip(validator_index, pubkey, flag, expected): result = get_new_validator_registry_delta_chain_tip( current_validator_registry_delta_chain_tip=ZERO_HASH32, - index=index, + validator_index=validator_index, pubkey=pubkey, flag=flag, ) diff --git a/tests/beacon/types/test_states.py b/tests/beacon/types/test_states.py index c2f875bbdc..d12f716dce 100644 --- a/tests/beacon/types/test_states.py +++ b/tests/beacon/types/test_states.py @@ -99,3 +99,24 @@ def test_num_crosslink_records(expected, def test_hash(sample_beacon_state_params): state = BeaconState(**sample_beacon_state_params) assert state.root == hash_eth2(rlp.encode(state)) + + +def test_update_validator(sample_beacon_state_params, sample_validator_record_params, max_deposit): + state = BeaconState(**sample_beacon_state_params).copy( + validator_registry=[ + mock_validator_record( + pubkey, + max_deposit, + ) + for pubkey in range(10) + ] + ) + + new_pubkey = 100 + validator_index = 5 + validator = state.validator_registry[validator_index].copy( + pubkey=new_pubkey, + ) + result_state = state.update_validator(validator_index=validator_index, validator=validator) + assert result_state.validator_registry[validator_index].pubkey == new_pubkey + assert state.validator_registry[validator_index].pubkey != new_pubkey diff --git a/tests/beacon/types/test_validator_record.py b/tests/beacon/types/test_validator_record.py index 93f0e947c9..b7f26e8da0 100644 --- a/tests/beacon/types/test_validator_record.py +++ b/tests/beacon/types/test_validator_record.py @@ -33,3 +33,29 @@ def test_is_active(sample_validator_record_params, } validator = ValidatorRecord(**validator_record_params) assert validator.is_active == expected + + +def test_get_pending_validator(): + pubkey = 123 + withdrawal_credentials = b'\x11' * 32 + randao_commitment = b'\x22' * 32 + balance = 100 + latest_status_change_slot = 10 + + validator = ValidatorRecord.get_pending_validator( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + randao_commitment=randao_commitment, + balance=balance, + latest_status_change_slot=latest_status_change_slot, + ) + + assert validator.pubkey == pubkey + assert validator.withdrawal_credentials == withdrawal_credentials + assert validator.randao_commitment == randao_commitment + assert validator.balance == balance + assert validator.latest_status_change_slot == latest_status_change_slot + + assert validator.status == ValidatorStatusCode.PENDING_ACTIVATION + assert validator.randao_layers == 0 + assert validator.exit_count == 0