diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index d3b0e2dc5..de0a2e785 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -267,6 +267,7 @@ def get_head(store: Store) -> Root: if len(children) == 0: return head # Sort by latest attesting balance with ties broken lexicographically + # Ties broken by favoring block with lexicographically higher root head = max(children, key=lambda root: (get_latest_attesting_balance(store, root), root)) ``` diff --git a/tests/core/pyspec/eth2spec/test/context.py b/tests/core/pyspec/eth2spec/test/context.py index 184c0d609..260cb4d7d 100644 --- a/tests/core/pyspec/eth2spec/test/context.py +++ b/tests/core/pyspec/eth2spec/test/context.py @@ -1,6 +1,7 @@ import pytest -from copy import deepcopy from dataclasses import dataclass +import importlib +from eth_utils import encode_hex from eth2spec.phase0 import mainnet as spec_phase0_mainnet, minimal as spec_phase0_minimal from eth2spec.altair import mainnet as spec_altair_mainnet, minimal as spec_altair_minimal @@ -85,10 +86,9 @@ class SpecForks(TypedDict, total=False): def _prepare_state(balances_fn: Callable[[Any], Sequence[int]], threshold_fn: Callable[[Any], int], spec: Spec, phases: SpecForks): - phase = phases[spec.fork] - balances = balances_fn(phase) - activation_threshold = threshold_fn(phase) - state = create_genesis_state(spec=phase, validator_balances=balances, + balances = balances_fn(spec) + activation_threshold = threshold_fn(spec) + state = create_genesis_state(spec=spec, validator_balances=balances, activation_threshold=activation_threshold) return state @@ -464,6 +464,32 @@ def with_presets(preset_bases, reason=None): return decorator +def _get_basic_dict(ssz_dict: Dict[str, Any]) -> Dict[str, Any]: + """ + Get dict of Python built-in types from a dict of SSZ objects. + """ + result = {} + for k, v in ssz_dict.items(): + if isinstance(v, int): + value = int(v) + elif isinstance(v, bytes): + value = encode_hex(v) + else: + value = str(v) + result[k] = value + return result + + +def _get_copy_of_spec(spec): + fork = spec.fork + preset = spec.config.PRESET_BASE + module_path = f"eth2spec.{fork}.{preset}" + module_spec = importlib.util.find_spec(module_path) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + def with_config_overrides(config_overrides): """ WARNING: the spec_test decorator must wrap this, to ensure the decorated test actually runs. @@ -474,20 +500,20 @@ def with_config_overrides(config_overrides): """ def decorator(fn): def wrapper(*args, spec: Spec, **kw): - # remember the old config - old_config = spec.config + spec = _get_copy_of_spec(spec) # apply our overrides to a copy of it, and apply it to the spec - tmp_config = deepcopy(old_config._asdict()) # not a private method, there are multiple - tmp_config.update(config_overrides) + config = spec.config._asdict() + config.update(config_overrides) config_types = spec.Configuration.__annotations__ - # Retain types of all config values - test_config = {k: config_types[k](v) for k, v in tmp_config.items()} + modified_config = {k: config_types[k](v) for k, v in config.items()} - # Output the config for test vectors (TODO: check config YAML encoding) - yield 'config', 'data', test_config + # To output the changed config to could be serialized with yaml test vectors, + # the dict SSZ objects have to be converted into Python built-in types. + output_config = _get_basic_dict(modified_config) + yield 'config', 'data', output_config - spec.config = spec.Configuration(**test_config) + spec.config = spec.Configuration(**modified_config) # Run the function out = fn(*args, spec=spec, **kw) @@ -495,10 +521,6 @@ def with_config_overrides(config_overrides): # it's generating things, and we need to complete it before setting back the config. if out is not None: yield from out - - # Restore the old config and apply it - spec.config = old_config - return wrapper return decorator diff --git a/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py b/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py index 0b06f283f..f056b9acd 100644 --- a/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py +++ b/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py @@ -42,6 +42,12 @@ def tick_and_add_block(spec, store, signed_block, test_steps, valid=True, return post_state +def add_attestation(spec, store, attestation, test_steps, is_from_block=False): + spec.on_attestation(store, attestation, is_from_block=is_from_block) + yield get_attestation_file_name(attestation), attestation + test_steps.append({'attestation': get_attestation_file_name(attestation)}) + + def tick_and_run_on_attestation(spec, store, attestation, test_steps, is_from_block=False): parent_block = store.blocks[attestation.data.beacon_block_root] pre_state = store.block_states[spec.hash_tree_root(parent_block)] @@ -52,9 +58,7 @@ def tick_and_run_on_attestation(spec, store, attestation, test_steps, is_from_bl spec.on_tick(store, next_epoch_time) test_steps.append({'tick': int(next_epoch_time)}) - spec.on_attestation(store, attestation, is_from_block=is_from_block) - yield get_attestation_file_name(attestation), attestation - test_steps.append({'attestation': get_attestation_file_name(attestation)}) + yield from add_attestation(spec, store, attestation, test_steps, is_from_block) def run_on_attestation(spec, store, attestation, is_from_block=False, valid=True): diff --git a/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_ex_ante.py b/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_ex_ante.py new file mode 100644 index 000000000..d93101156 --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_ex_ante.py @@ -0,0 +1,421 @@ +from eth2spec.test.context import ( + MAINNET, + spec_state_test, + with_all_phases, + with_presets, +) +from eth2spec.test.helpers.attestations import ( + get_valid_attestation, + sign_attestation, +) +from eth2spec.test.helpers.block import ( + build_empty_block, +) +from eth2spec.test.helpers.fork_choice import ( + get_genesis_forkchoice_store_and_block, + on_tick_and_append_step, + add_attestation, + add_block, + tick_and_add_block, +) +from eth2spec.test.helpers.state import ( + state_transition_and_sign_block, +) + + +def _apply_base_block_a(spec, state, store, test_steps): + # On receiving block A at slot `N` + block = build_empty_block(spec, state, slot=state.slot + 1) + signed_block_a = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block_a, test_steps) + assert spec.get_head(store) == signed_block_a.message.hash_tree_root() + + +@with_all_phases +@spec_state_test +def test_ex_ante_vanilla(spec, state): + """ + With a single adversarial attestation + Objects: + Block A - slot N + Block B (parent A) - slot N+1 + Block C (parent A) - slot N+2 + Attestation_1 (Block B); size `1` - slot N+1 + Steps: + Block A received at N — A is head + Block C received at N+2 — C is head + Block B received at N+2 — C is head + Attestation_1 received at N+2 — C is head + """ + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving block A at slot `N` + yield from _apply_base_block_a(spec, state, store, test_steps) + state_a = state.copy() + + # Block B at slot `N + 1`, parent is A + state_b = state_a.copy() + block = build_empty_block(spec, state_a, slot=state_a.slot + 1) + signed_block_b = state_transition_and_sign_block(spec, state_b, block) + + # Block C at slot `N + 2`, parent is A + state_c = state_a.copy() + block = build_empty_block(spec, state_c, slot=state_a.slot + 2) + signed_block_c = state_transition_and_sign_block(spec, state_c, block) + + # Attestation_1 at slot `N + 1` voting for block B + def _filter_participant_set(participants): + return [next(iter(participants))] + + attestation = get_valid_attestation( + spec, state_b, slot=state_b.slot, signed=False, filter_participant_set=_filter_participant_set + ) + attestation.data.beacon_block_root = signed_block_b.message.hash_tree_root() + assert len([i for i in attestation.aggregation_bits if i == 1]) == 1 + sign_attestation(spec, state_b, attestation) + + # Block C received at N+2 — C is head + time = state_c.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_c, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block B received at N+2 — C is head due to proposer score boost + yield from add_block(spec, store, signed_block_b, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Attestation_1 received at N+2 — C is head + yield from add_attestation(spec, store, attestation, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + yield 'steps', test_steps + + +def _get_greater_than_proposer_boost_score(spec, store, state, proposer_boost_root, root): + """ + Return the minimum attestation participant count such that attestation_score > proposer_score + """ + # calculate proposer boost score + block = store.blocks[root] + proposer_score = 0 + if spec.get_ancestor(store, root, block.slot) == proposer_boost_root: + num_validators = len(spec.get_active_validator_indices(state, spec.get_current_epoch(state))) + avg_balance = spec.get_total_active_balance(state) // num_validators + committee_size = num_validators // spec.SLOTS_PER_EPOCH + committee_weight = committee_size * avg_balance + proposer_score = (committee_weight * spec.config.PROPOSER_SCORE_BOOST) // 100 + + # calculate minimum participant count such that attestation_score > proposer_score + base_effective_balance = state.validators[0].effective_balance + + return proposer_score // base_effective_balance + 1 + + +@with_all_phases +@with_presets([MAINNET], reason="to create non-duplicate committee") +@spec_state_test +def test_ex_ante_attestations_is_greater_than_proposer_boost_with_boost(spec, state): + """ + Adversarial attestations > proposer boost + Objects: + Block A - slot N + Block B (parent A) - slot N+1 + Block C (parent A) - slot N+2 + Attestation_set_1 (Block B); size `proposer_boost + 1` - slot N+1 + Steps: + Block A received at N — A is head + Block C received at N+2 — C is head + Block B received at N+2 — C is head + Attestation_1 received at N+2 — B is head + """ + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving block A at slot `N` + yield from _apply_base_block_a(spec, state, store, test_steps) + state_a = state.copy() + + # Block B at slot `N + 1`, parent is A + state_b = state_a.copy() + block = build_empty_block(spec, state_a, slot=state_a.slot + 1) + signed_block_b = state_transition_and_sign_block(spec, state_b, block) + + # Block C at slot `N + 2`, parent is A + state_c = state_a.copy() + block = build_empty_block(spec, state_c, slot=state_a.slot + 2) + signed_block_c = state_transition_and_sign_block(spec, state_c, block) + + # Block C received at N+2 — C is head + time = state_c.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_c, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block B received at N+2 — C is head due to proposer score boost + yield from add_block(spec, store, signed_block_b, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Attestation_set_1 at slot `N + 1` voting for block B + proposer_boost_root = signed_block_b.message.hash_tree_root() + root = signed_block_b.message.hash_tree_root() + participant_num = _get_greater_than_proposer_boost_score(spec, store, state, proposer_boost_root, root) + + def _filter_participant_set(participants): + return [index for i, index in enumerate(participants) if i < participant_num] + + attestation = get_valid_attestation( + spec, state_b, slot=state_b.slot, signed=False, filter_participant_set=_filter_participant_set + ) + attestation.data.beacon_block_root = signed_block_b.message.hash_tree_root() + assert len([i for i in attestation.aggregation_bits if i == 1]) == participant_num + sign_attestation(spec, state_b, attestation) + + # Attestation_set_1 received at N+2 — B is head because B's attestation_score > C's proposer_score. + # (B's proposer_score = C's attestation_score = 0) + yield from add_attestation(spec, store, attestation, test_steps) + assert spec.get_head(store) == signed_block_b.message.hash_tree_root() + + yield 'steps', test_steps + + +@with_all_phases +@spec_state_test +def test_ex_ante_sandwich_without_attestations(spec, state): + """ + Simple Sandwich test with boost and no attestations. + Obejcts: + Block A - slot N + Block B (parent A) - slot N+1 + Block C (parent A) - slot N+2 + Block D (parent B) - slot N+3 + Steps: + Block A received at N — A is head + Block C received at N+2 — C is head + Block B received at N+2 — C is head (with boost) + Block D received at N+3 — D is head (with boost) + """ + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving block A at slot `N` + yield from _apply_base_block_a(spec, state, store, test_steps) + state_a = state.copy() + + # Block B at slot `N + 1`, parent is A + state_b = state_a.copy() + block = build_empty_block(spec, state_a, slot=state_a.slot + 1) + signed_block_b = state_transition_and_sign_block(spec, state_b, block) + + # Block C at slot `N + 2`, parent is A + state_c = state_a.copy() + block = build_empty_block(spec, state_c, slot=state_a.slot + 2) + signed_block_c = state_transition_and_sign_block(spec, state_c, block) + + # Block D at slot `N + 3`, parent is B + state_d = state_b.copy() + block = build_empty_block(spec, state_d, slot=state_a.slot + 3) + signed_block_d = state_transition_and_sign_block(spec, state_d, block) + + # Block C received at N+2 — C is head + time = state_c.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_c, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block B received at N+2 — C is head, it has proposer score boost + yield from add_block(spec, store, signed_block_b, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block D received at N+3 - D is head, it has proposer score boost + time = state_d.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_d, test_steps) + assert spec.get_head(store) == signed_block_d.message.hash_tree_root() + + yield 'steps', test_steps + + +@with_all_phases +@spec_state_test +def test_ex_ante_sandwich_with_honest_attestation(spec, state): + """ + Boosting necessary to sandwich attack. + Objects: + Block A - slot N + Block B (parent A) - slot N+1 + Block C (parent A) - slot N+2 + Block D (parent B) - slot N+3 + Attestation_1 (Block C); size 1 - slot N+2 (honest) + Steps: + Block A received at N — A is head + Block C received at N+2 — C is head + Block B received at N+2 — C is head + Attestation_1 received at N+3 — C is head + Block D received at N+3 — D is head + + """ + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving block A at slot `N` + yield from _apply_base_block_a(spec, state, store, test_steps) + state_a = state.copy() + + # Block B at slot `N + 1`, parent is A + state_b = state_a.copy() + block = build_empty_block(spec, state_a, slot=state_a.slot + 1) + signed_block_b = state_transition_and_sign_block(spec, state_b, block) + + # Block C at slot `N + 2`, parent is A + state_c = state_a.copy() + block = build_empty_block(spec, state_c, slot=state_a.slot + 2) + signed_block_c = state_transition_and_sign_block(spec, state_c, block) + + # Attestation_1 at N+2 voting for block C + def _filter_participant_set(participants): + return [next(iter(participants))] + + attestation = get_valid_attestation( + spec, state_c, slot=state_c.slot, signed=False, filter_participant_set=_filter_participant_set + ) + attestation.data.beacon_block_root = signed_block_c.message.hash_tree_root() + assert len([i for i in attestation.aggregation_bits if i == 1]) == 1 + sign_attestation(spec, state_c, attestation) + + # Block D at slot `N + 3`, parent is B + state_d = state_b.copy() + block = build_empty_block(spec, state_d, slot=state_a.slot + 3) + signed_block_d = state_transition_and_sign_block(spec, state_d, block) + + # Block C received at N+2 — C is head + time = state_c.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_c, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block B received at N+2 — C is head, it has proposer score boost + yield from add_block(spec, store, signed_block_b, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Attestation_1 received at N+3 — C is head + time = state_d.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_attestation(spec, store, attestation, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block D received at N+3 - D is head, it has proposer score boost + yield from add_block(spec, store, signed_block_d, test_steps) + assert spec.get_head(store) == signed_block_d.message.hash_tree_root() + + yield 'steps', test_steps + + +@with_all_phases +@with_presets([MAINNET], reason="to create non-duplicate committee") +@spec_state_test +def test_ex_ante_sandwich_with_boost_not_sufficient(spec, state): + """ + Boost not sufficient to sandwich attack. + Objects: + Block A - slot N + Block B (parent A) - slot N+1 + Block C (parent A) - slot N+2 + Block D (parent B) - slot N+3 + Attestation_set_1 (Block C); size proposer_boost + 1 - slot N+2 + Steps: + Block A received at N — A is head + Block C received at N+2 — C is head + Block B received at N+2 — C is head + Attestation_set_1 received — C is head + Block D received at N+3 — C is head + """ + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving block A at slot `N` + yield from _apply_base_block_a(spec, state, store, test_steps) + state_a = state.copy() + + # Block B at slot `N + 1`, parent is A + state_b = state_a.copy() + block = build_empty_block(spec, state_a, slot=state_a.slot + 1) + signed_block_b = state_transition_and_sign_block(spec, state_b, block) + + # Block C at slot `N + 2`, parent is A + state_c = state_a.copy() + block = build_empty_block(spec, state_c, slot=state_a.slot + 2) + signed_block_c = state_transition_and_sign_block(spec, state_c, block) + + # Block D at slot `N + 3`, parent is B + state_d = state_b.copy() + block = build_empty_block(spec, state_d, slot=state_a.slot + 3) + signed_block_d = state_transition_and_sign_block(spec, state_d, block) + + # Block C received at N+2 — C is head + time = state_c.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_block(spec, store, signed_block_c, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block B received at N+2 — C is head, it has proposer score boost + yield from add_block(spec, store, signed_block_b, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Attestation_set_1 at N+2 voting for block C + proposer_boost_root = signed_block_c.message.hash_tree_root() + root = signed_block_c.message.hash_tree_root() + participant_num = _get_greater_than_proposer_boost_score(spec, store, state, proposer_boost_root, root) + + def _filter_participant_set(participants): + return [index for i, index in enumerate(participants) if i < participant_num] + + attestation = get_valid_attestation( + spec, state_c, slot=state_c.slot, signed=False, filter_participant_set=_filter_participant_set + ) + attestation.data.beacon_block_root = signed_block_c.message.hash_tree_root() + assert len([i for i in attestation.aggregation_bits if i == 1]) == participant_num + sign_attestation(spec, state_c, attestation) + + # Attestation_1 received at N+3 — B is head because B's attestation_score > C's proposer_score. + # (B's proposer_score = C's attestation_score = 0) + time = state_d.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, time, test_steps) + yield from add_attestation(spec, store, attestation, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + # Block D received at N+3 - C is head, D's boost not sufficient! + yield from add_block(spec, store, signed_block_d, test_steps) + assert spec.get_head(store) == signed_block_c.message.hash_tree_root() + + yield 'steps', test_steps diff --git a/tests/generators/fork_choice/main.py b/tests/generators/fork_choice/main.py index 562f851d1..b194dc3bd 100644 --- a/tests/generators/fork_choice/main.py +++ b/tests/generators/fork_choice/main.py @@ -6,6 +6,7 @@ if __name__ == "__main__": phase_0_mods = {key: 'eth2spec.test.phase0.fork_choice.test_' + key for key in [ 'get_head', 'on_block', + 'ex_ante', ]} # No additional Altair specific finality tests, yet. altair_mods = phase_0_mods