diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_on_payload_attestation_message.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_on_payload_attestation_message.py new file mode 100644 index 00000000000..86091cbce82 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_on_payload_attestation_message.py @@ -0,0 +1,418 @@ +from eth_consensus_specs.test.context import ( + spec_state_test, + with_gloas_and_later, +) +from eth_consensus_specs.test.helpers.block import ( + build_empty_block_for_next_slot, +) +from eth_consensus_specs.test.helpers.fork_choice import ( + add_payload_attestation_message, + add_payload_vote_checks, + get_genesis_forkchoice_store_and_block, + on_tick_and_append_step, + tick_and_add_block, +) +from eth_consensus_specs.test.helpers.keys import privkeys +from eth_consensus_specs.test.helpers.state import ( + state_transition_and_sign_block, +) + + +def _build_signed_payload_attestation_message( + spec, + state, + block_root, + validator_index, + payload_present=True, + blob_data_available=True, + slot=None, +): + """ + Build a signed PayloadAttestationMessage for a given block root and validator. + """ + if slot is None: + slot = state.slot + data = spec.PayloadAttestationData( + beacon_block_root=block_root, + slot=slot, + payload_present=payload_present, + blob_data_available=blob_data_available, + ) + + domain = spec.get_domain(state, spec.DOMAIN_PTC_ATTESTER, spec.compute_epoch_at_slot(data.slot)) + signing_root = spec.compute_signing_root(data, domain) + signature = spec.bls.Sign(privkeys[validator_index], signing_root) + + return spec.PayloadAttestationMessage( + validator_index=validator_index, + data=data, + signature=signature, + ) + + +def _move_store_to_slot(spec, store, slot, test_steps): + slot_time = store.genesis_time + slot * (spec.config.SLOT_DURATION_MS // 1000) + if store.time < slot_time: + on_tick_and_append_step(spec, store, slot_time, test_steps) + + +def _setup_test(spec, state): + test_steps = [] + + # Build genesis store + 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.SLOT_DURATION_MS // 1000) + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + + # Apply one block at slot 1 + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block, test_steps) + block_root = signed_block.message.hash_tree_root() + block_state = store.block_states[block_root] + ptc = spec.get_ptc(block_state, block_state.slot) + assert len(ptc) > 0 + + _move_store_to_slot(spec, store, block_state.slot, test_steps) + return store, block_root, block_state, ptc, test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_unknown_block_root(spec, state): + """ + Test that messages for an unknown beacon_block_root are rejected. + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Sign the message over an unknown block root + unknown_root = spec.Root(b"\xff" * 32) + ptc_message = _build_signed_payload_attestation_message( + spec, + block_state, + unknown_root, + ptc[0], + payload_present=True, + ) + + yield from add_payload_attestation_message( + spec, + store, + ptc_message, + test_steps, + valid=False, + ) + + # Vote arrays for the known block must remain at their default values + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_slot_mismatch(spec, state): + """ + Test that a message whose slot doesn't match the block's slot is dropped without error. + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Build message signed over a mismatching slot + ptc_message = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc[0], + payload_present=True, + slot=spec.Slot(block_state.slot + 1), + ) + + # Spec function runs without error but returns early on the slot mismatch + yield from add_payload_attestation_message(spec, store, ptc_message, test_steps) + + # Vote arrays must remain at their default values + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_not_ptc_member(spec, state): + """ + Test that a message from a validator outside the PTC is rejected. + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Pick a validator outside the PTC + ptc_set = set(ptc) + non_ptc_member = next(i for i in range(len(state.validators)) if i not in ptc_set) + ptc_message = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + non_ptc_member, + payload_present=True, + ) + + yield from add_payload_attestation_message( + spec, + store, + ptc_message, + test_steps, + valid=False, + ) + + # Assert rejected message didn't mutate the block vote arrays + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_current_slot_and_signature(spec, state): + """ + Test that current slot and signature checks reject invalid messages. + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Bad signature + invalid_sig_msg = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc[0], + payload_present=True, + ) + invalid_sig_msg.signature = spec.BLSSignature() + yield from add_payload_attestation_message( + spec, + store, + invalid_sig_msg, + test_steps, + valid=False, + ) + + # Vote arrays must remain at their default values + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + add_payload_vote_checks(store, block_root, test_steps) + + # Valid signature, stale slot + valid_msg = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc[0], + payload_present=True, + ) + _move_store_to_slot(spec, store, block_state.slot + 1, test_steps) + yield from add_payload_attestation_message( + spec, + store, + valid_msg, + test_steps, + valid=False, + ) + + # Rejected message must not partially mutate vote arrays + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_valid(spec, state): + """ + Test that valid messages update attestations. + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Initial state: no votes recorded for this block + assert all(v == None for v in store.payload_timeliness_vote[block_root]) + assert all(v == None for v in store.payload_data_availability_vote[block_root]) + + ptc_member = ptc[0] + voter_positions = [i for i, v in enumerate(ptc) if v == ptc_member] + other_positions = [i for i in range(len(ptc)) if i not in voter_positions] + + # Attest both values + msg_1 = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc_member, + payload_present=True, + blob_data_available=True, + ) + yield from add_payload_attestation_message(spec, store, msg_1, test_steps) + + for i in voter_positions: + assert store.payload_timeliness_vote[block_root][i] == True + assert store.payload_data_availability_vote[block_root][i] == True + for i in other_positions: + assert store.payload_timeliness_vote[block_root][i] == None + assert store.payload_data_availability_vote[block_root][i] == None + add_payload_vote_checks(store, block_root, test_steps) + + # Re-vote with both fields False + msg_2 = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc_member, + payload_present=False, + blob_data_available=False, + ) + yield from add_payload_attestation_message(spec, store, msg_2, test_steps) + + for i in voter_positions: + assert store.payload_timeliness_vote[block_root][i] == False + assert store.payload_data_availability_vote[block_root][i] == False + for i in other_positions: + assert store.payload_timeliness_vote[block_root][i] == None + assert store.payload_data_availability_vote[block_root][i] == None + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_multiple_ptc_members_vote_independently(spec, state): + """ + Test that two different PTC members voting for the same block update independent vote sets. + """ + # Set two different validators on the PTC + block_slot = state.slot + 1 + window_idx = spec.SLOTS_PER_EPOCH + block_slot % spec.SLOTS_PER_EPOCH + state.ptc_window[window_idx][0] = spec.ValidatorIndex(0) + state.ptc_window[window_idx][1] = spec.ValidatorIndex(1) + + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + ptc_member_a = ptc[0] + ptc_member_b = ptc[1] + assert ptc_member_a != ptc_member_b + + # Compute every position each validator appears at + positions_a = [i for i, v in enumerate(ptc) if v == ptc_member_a] + positions_b = [i for i, v in enumerate(ptc) if v == ptc_member_b] + other_positions = [i for i in range(len(ptc)) if i not in positions_a and i not in positions_b] + + msg_a = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc_member_a, + payload_present=True, + blob_data_available=True, + ) + yield from add_payload_attestation_message(spec, store, msg_a, test_steps) + + msg_b = _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + ptc_member_b, + payload_present=True, + blob_data_available=False, + ) + yield from add_payload_attestation_message(spec, store, msg_b, test_steps) + + timeliness = store.payload_timeliness_vote[block_root] + availability = store.payload_data_availability_vote[block_root] + + # Validator A's votes landed at every position A occupies + for i in positions_a: + assert timeliness[i] == True + assert availability[i] == True + + # Validator B's votes landed at every position B occupies + for i in positions_b: + assert timeliness[i] == True + assert availability[i] == False + + # Other positions stayed at their default values + for i in other_positions: + assert timeliness[i] == None + assert availability[i] == None + + add_payload_vote_checks(store, block_root, test_steps) + + yield "steps", test_steps + + +@with_gloas_and_later +@spec_state_test +def test_on_payload_attestation_message_from_block(spec, state): + """ + Test that a PayloadAttestation included in a block updates the vote arrays + """ + store, block_root, block_state, ptc, test_steps = yield from _setup_test(spec, state) + + # Pick a subset of the PTC to vote + ptc_list = list(ptc) + voters = ptc_list[:3] + voter_set = set(voters) + + # Build the validator messages + ptc_messages = [] + for validator_index in voters: + ptc_messages.append( + _build_signed_payload_attestation_message( + spec, + block_state, + block_root, + validator_index, + payload_present=True, + blob_data_available=True, + ) + ) + + # Build the PayloadAttestation aggregate + aggregation_bits = spec.Bitvector[spec.PTC_SIZE]() + for i, validator_index in enumerate(ptc_list): + if validator_index in voter_set: + aggregation_bits[i] = True + + sig_by_index = {m.validator_index: m.signature for m in ptc_messages} + aggregate_sig = spec.bls.Aggregate([sig_by_index[v] for v in ptc_list if v in voter_set]) + + aggregate = spec.PayloadAttestation( + aggregation_bits=aggregation_bits, + data=ptc_messages[0].data, + signature=aggregate_sig, + ) + + # Build the next block and add the aggregate + block_n1 = build_empty_block_for_next_slot(spec, state) + block_n1.body.payload_attestations.append(aggregate) + signed_block_n1 = state_transition_and_sign_block(spec, state, block_n1) + + # Apply block + yield from tick_and_add_block(spec, store, signed_block_n1, test_steps) + + # Votes landed at every PTC position each voter occupies + for i, validator_index in enumerate(ptc_list): + if validator_index in voter_set: + assert store.payload_timeliness_vote[block_root][i] == True + assert store.payload_data_availability_vote[block_root][i] == True + else: + assert store.payload_timeliness_vote[block_root][i] == None + assert store.payload_data_availability_vote[block_root][i] == None + + add_payload_vote_checks(store, block_root, test_steps) + yield "steps", test_steps diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py index 061a9a0cf96..19479154208 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py @@ -299,7 +299,7 @@ def get_sidecar_file_name(sidecar: DataColumnSidecar) -> str: def get_payload_attestation_message_file_name(ptc_message): - return f"payload_attestation_{encode_hex(ptc_message.hash_tree_root())}" + return f"payload_attestation_message_{encode_hex(ptc_message.hash_tree_root())}" def on_tick_and_append_step(spec, store, time, test_steps): @@ -498,13 +498,34 @@ def add_payload_attestation_message(spec, store, ptc_message, test_steps, valid= ptc_file_name = get_payload_attestation_message_file_name(ptc_message) yield ptc_file_name, ptc_message + run_on_payload_attestation_message(spec, store, ptc_message, valid=valid) + step = {"payload_attestation_message": ptc_file_name} + if not valid: - expect_assertion_error(lambda: spec.on_payload_attestation_message(store, ptc_message)) - test_steps.append({"payload_attestation": ptc_file_name, "valid": False}) - return + step["valid"] = False + test_steps.append(step) + - run_on_payload_attestation_message(spec, store, ptc_message) - test_steps.append({"payload_attestation": ptc_file_name}) +def add_payload_vote_checks(store, block_root, test_steps): + timeliness = [None if v is None else bool(v) for v in store.payload_timeliness_vote[block_root]] + availability = [ + None if v is None else bool(v) for v in store.payload_data_availability_vote[block_root] + ] + + test_steps.append( + { + "checks": { + "payload_timeliness_vote": { + "block_root": encode_hex(block_root), + "votes": timeliness, + }, + "payload_data_availability_vote": { + "block_root": encode_hex(block_root), + "votes": availability, + }, + } + } + ) def _get_head_root(spec, store): diff --git a/tests/formats/fork_choice/README.md b/tests/formats/fork_choice/README.md index 78bfaa667e8..e3e81f746da 100644 --- a/tests/formats/fork_choice/README.md +++ b/tests/formats/fork_choice/README.md @@ -17,12 +17,12 @@ components of the fork choice. - [`on_attester_slashing` execution step](#on_attester_slashing-execution-step) - [`on_payload_info` execution step](#on_payload_info-execution-step) - [`on_execution_payload_envelope` execution step](#on_execution_payload_envelope-execution-step) - - [`on_payload_attestation` execution step](#on_payload_attestation-execution-step) + - [`on_payload_attestation_message` execution step](#on_payload_attestation_message-execution-step) - [Checks step](#checks-step) - [`attestation_<32-byte-root>.ssz_snappy`](#attestation_32-byte-rootssz_snappy) - [`block_<32-byte-root>.ssz_snappy`](#block_32-byte-rootssz_snappy) - [`execution_payload_envelope_<32-byte-root>.ssz_snappy`](#execution_payload_envelope_32-byte-rootssz_snappy) - - [`payload_attestation_<32-byte-root>.ssz_snappy`](#payload_attestation_32-byte-rootssz_snappy) + - [`payload_attestation_message_<32-byte-root>.ssz_snappy`](#payload_attestation_message_32-byte-rootssz_snappy) - [Condition](#condition) @@ -202,17 +202,17 @@ The file is located in the same folder (see below). After this step, the `store` object may have been updated. -#### `on_payload_attestation` execution step +#### `on_payload_attestation_message` execution step The parameter that is required for executing `on_payload_attestation_message(store, ptc_message)`. ```yaml { - payload_attestation: string -- the name of the `payload_attestation_<32-byte-root>.ssz_snappy` file. - To execute `on_payload_attestation_message(store, ptc_message)` with the given message. - valid: bool -- optional, default to `true`. - If it's `false`, this execution step is expected to be invalid. + payload_attestation_message: string -- the name of the `payload_attestation_message_<32-byte-root>.ssz_snappy` file. + To execute `on_payload_attestation_message(store, ptc_message)` with the given message. + valid: bool -- optional, default to `true`. + If it's `false`, this execution step is expected to be invalid. } ``` @@ -267,6 +267,14 @@ should_override_forkchoice_update: { -- [New in Bellatrix] result: bool, -- The result of `should_override_forkchoice_update(store, head_root)`, where head_root is the result value from get_head(store) } head_payload_status: int -- The payload_status field from the ForkChoiceNode returned by get_head(store) +payload_timeliness_vote: { -- [New in Gloas] + block_root: string, -- Encoded 32-byte beacon block root + votes: [bool | null, ...] -- Votes ordered by PTC positions. Length is `PTC_SIZE`. +} +payload_data_availability_vote: { -- [New in Gloas] + block_root: string, -- Encoded 32-byte beacon block root + votes: [bool | null, ...] -- Votes ordered by PTC positions. Length is `PTC_SIZE`. +} ``` For example: @@ -308,7 +316,7 @@ Each file is an SSZ-snappy encoded `SignedBeaconBlock`. Each file is an SSZ-snappy encoded `SignedExecutionPayloadEnvelope`. -### `payload_attestation_<32-byte-root>.ssz_snappy` +### `payload_attestation_message_<32-byte-root>.ssz_snappy` `<32-byte-root>` is the hash tree root of the given payload attestation message. @@ -334,8 +342,8 @@ Each file is an SSZ-snappy encoded `PayloadAttestationMessage`. - For the `on_execution_payload_envelope` execution step: look up the corresponding `execution_payload_envelope_.ssz_snappy` file and execute `on_execution_payload_envelope(store, signed_envelope)`. - - For the `on_payload_attestation` execution step: look up the - corresponding `payload_attestation_.ssz_snappy` file and execute - `on_payload_attestation_message(store, ptc_message)`. + - For the `on_payload_attestation_message` execution step: look up the + corresponding `payload_attestation_message_.ssz_snappy` file and + execute `on_payload_attestation_message(store, ptc_message)`. - For each `checks` step, the assertions on the current store must be satisfied. diff --git a/tests/generators/compliance_runners/fork_choice/instantiators/test_case.py b/tests/generators/compliance_runners/fork_choice/instantiators/test_case.py index 4235a27e7c5..bdf68cf19aa 100644 --- a/tests/generators/compliance_runners/fork_choice/instantiators/test_case.py +++ b/tests/generators/compliance_runners/fork_choice/instantiators/test_case.py @@ -360,7 +360,10 @@ def yield_test_parts(spec, store, test_data: FCTestData, events): event_data ) test_steps.append( - {"payload_attestation": _payload_attestation_id, "valid": True} + { + "payload_attestation_message": _payload_attestation_id, + "valid": True, + } ) else: assert False @@ -404,7 +407,10 @@ def yield_test_parts(spec, store, test_data: FCTestData, events): ) assert recovery test_steps.append( - {"payload_attestation": _payload_attestation_id, "valid": True} + { + "payload_attestation_message": _payload_attestation_id, + "valid": True, + } ) else: assert False @@ -437,7 +443,7 @@ def yield_test_parts(spec, store, test_data: FCTestData, events): ptc_message = data ptc_message_id = get_payload_attestation_message_file_name(ptc_message) valid = scheduler.process_payload_attestation_message(ptc_message, is_from_block=False) - test_steps.append({"payload_attestation": ptc_message_id, "valid": valid}) + test_steps.append({"payload_attestation_message": ptc_message_id, "valid": valid}) output_store_checks(spec, store, test_steps) else: raise ValueError(f"not implemented {kind}") diff --git a/tests/generators/compliance_runners/fork_choice/runner/test_run.py b/tests/generators/compliance_runners/fork_choice/runner/test_run.py index 8e914f0de74..9eb78f967a3 100644 --- a/tests/generators/compliance_runners/fork_choice/runner/test_run.py +++ b/tests/generators/compliance_runners/fork_choice/runner/test_run.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from eth_utils import decode_hex from ruamel.yaml import YAML from snappy import uncompress @@ -53,7 +54,7 @@ def get_prefix(p): }, { get_prefix(b): spec.PayloadAttestationMessage.decode_bytes(read_ssz_snappy(b)) - for b in glob(f"{td}/payload_attestation_*.ssz_snappy") + for b in glob(f"{td}/payload_attestation_message_*.ssz_snappy") }, read_yaml(f"{td}/steps.yaml"), ) @@ -136,8 +137,8 @@ def run_test(test_info): expect_assertion_error( lambda: spec.on_execution_payload_envelope(store, signed_envelope) ) - elif "payload_attestation" in step: - ptc_message_id = step["payload_attestation"] + elif "payload_attestation_message" in step: + ptc_message_id = step["payload_attestation_message"] valid = step.get("valid", True) ptc_message = payload_atts[ptc_message_id] if valid: @@ -185,6 +186,9 @@ def get_head(): elif check == "head_payload_status": head = get_head() assert head.payload_status == value + elif check in ("payload_timeliness_vote", "payload_data_availability_vote"): + target_root = spec.Root(decode_hex(value["block_root"])) + assert list(getattr(store, check)[target_root]) == value["votes"] else: assert False else: