diff --git a/packages/testing/src/consensus_testing/__init__.py b/packages/testing/src/consensus_testing/__init__.py index bce77c54..3f2544a0 100644 --- a/packages/testing/src/consensus_testing/__init__.py +++ b/packages/testing/src/consensus_testing/__init__.py @@ -7,18 +7,24 @@ from .test_fixtures import ( ApiEndpointTest, BaseConsensusFixture, + DropComponentMessageBinding, ForkChoiceTest, GossipsubHandlerTest, + IncrementComponentSlot, IncrementEmittedSlot, JustifiabilityTest, NetworkingCodecTest, PoseidonPermutationTest, + RebindComponentToAlternateHeadRoot, RebindToAlternateHeadRoot, SlotClockTest, SSZTest, StateTransitionTest, + SwapComponentMessageBindings, + SwapComponentParticipantPublicKey, SwapParticipantPublicKey, SyncTest, + VerifyMultiMessageProofsTest, VerifySignaturesTest, VerifySingleMessageProofsTest, ) @@ -42,6 +48,7 @@ StateTransitionTestFiller = Type[StateTransitionTest] ForkChoiceTestFiller = Type[ForkChoiceTest] VerifySingleMessageProofsTestFiller = Type[VerifySingleMessageProofsTest] +VerifyMultiMessageProofsTestFiller = Type[VerifyMultiMessageProofsTest] VerifySignaturesTestFiller = Type[VerifySignaturesTest] SSZTestFiller = Type[SSZTest] NetworkingCodecTestFiller = Type[NetworkingCodecTest] @@ -70,6 +77,12 @@ "RebindToAlternateHeadRoot", "IncrementEmittedSlot", "SwapParticipantPublicKey", + "VerifyMultiMessageProofsTest", + "RebindComponentToAlternateHeadRoot", + "IncrementComponentSlot", + "SwapComponentParticipantPublicKey", + "SwapComponentMessageBindings", + "DropComponentMessageBinding", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", @@ -94,6 +107,7 @@ "StateTransitionTestFiller", "ForkChoiceTestFiller", "VerifySingleMessageProofsTestFiller", + "VerifyMultiMessageProofsTestFiller", "VerifySignaturesTestFiller", "SSZTestFiller", "NetworkingCodecTestFiller", diff --git a/packages/testing/src/consensus_testing/test_fixtures/__init__.py b/packages/testing/src/consensus_testing/test_fixtures/__init__.py index 9a617859..a1104618 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/__init__.py +++ b/packages/testing/src/consensus_testing/test_fixtures/__init__.py @@ -11,6 +11,14 @@ from .ssz import SSZTest from .state_transition import StateTransitionTest from .sync import SyncTest +from .verify_multi_message_proofs import ( + DropComponentMessageBinding, + IncrementComponentSlot, + RebindComponentToAlternateHeadRoot, + SwapComponentMessageBindings, + SwapComponentParticipantPublicKey, + VerifyMultiMessageProofsTest, +) from .verify_signatures import VerifySignaturesTest from .verify_single_message_proofs import ( IncrementEmittedSlot, @@ -27,6 +35,12 @@ "RebindToAlternateHeadRoot", "IncrementEmittedSlot", "SwapParticipantPublicKey", + "VerifyMultiMessageProofsTest", + "RebindComponentToAlternateHeadRoot", + "IncrementComponentSlot", + "SwapComponentParticipantPublicKey", + "SwapComponentMessageBindings", + "DropComponentMessageBinding", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", diff --git a/packages/testing/src/consensus_testing/test_fixtures/base.py b/packages/testing/src/consensus_testing/test_fixtures/base.py index e5f15fa5..f94d36c1 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/base.py +++ b/packages/testing/src/consensus_testing/test_fixtures/base.py @@ -44,3 +44,31 @@ def serialize_exception(self, value: type[Exception] | None) -> str | None: if value is None: return None return value.__name__ + + def assert_expected_outcome(self, exception_raised: Exception | None) -> None: + """Compare a self-verification outcome against the configured expectation. + + A fixture that self-verifies its own output catches the verifier exception. + It then hands the caught exception here to decide pass or fail. + + Args: + exception_raised: The exception the verifier raised, or None on success. + + Raises: + AssertionError: When the outcome disagrees with the expectation. + """ + # No expectation means the bundle is honest and must verify. + if self.expect_exception is None: + if exception_raised is not None: + raise AssertionError(f"Verifier rejected an honest bundle: {exception_raised}") + # An expectation that produced no exception means the tamper went undetected. + elif exception_raised is None: + raise AssertionError( + f"Expected {self.expect_exception.__name__} but verification succeeded" + ) + # A wrong exception type means the rejection fired for the wrong reason. + elif not isinstance(exception_raised, self.expect_exception): + raise AssertionError( + f"Expected {self.expect_exception.__name__} but got " + f"{type(exception_raised).__name__}: {exception_raised}" + ) diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py new file mode 100644 index 00000000..5acd269d --- /dev/null +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py @@ -0,0 +1,324 @@ +"""Fixture format for multi-message aggregate proof verification vectors.""" + +from __future__ import annotations + +from typing import ClassVar + +from pydantic import BaseModel, Field + +from lean_spec.spec.crypto.merkleization import hash_tree_root +from lean_spec.spec.crypto.xmss.containers import PublicKey +from lean_spec.spec.forks import ( + AggregationBits, + Checkpoint, + Slot, + ValidatorIndex, + ValidatorIndices, +) +from lean_spec.spec.forks.lstar.containers import ( + AttestationData, + MultiMessageAggregate, + SingleMessageAggregate, +) +from lean_spec.spec.ssz import ByteList512KiB, Bytes32 + +from ..keys import XmssKeyManager +from .base import BaseConsensusFixture + +ALTERNATE_HEAD_ROOT: Bytes32 = Bytes32(b"\xee" * 32) +"""Sentinel head root used by the rebind tamper to bind one component off-target.""" + + +class RebindComponentToAlternateHeadRoot(BaseModel): + """Rebind one component's proof to an alternate head root. + + The honest attestation data is still emitted for every component. + Only the targeted component's proof bytes carry the alternate binding. + """ + + component_index: int + """Index of the component whose proof is rebound.""" + + +class IncrementComponentSlot(BaseModel): + """Bump one component's emitted slot while its proof stays bound to the original slot.""" + + component_index: int + """Index of the component whose emitted slot is bumped.""" + + +class SwapComponentParticipantPublicKey(BaseModel): + """Replace one participant's public key with another validator's attestation key. + + The honest proof is still emitted. + Only the targeted component's public key layout carries the swap. + """ + + component_index: int + """Index of the component whose participant list is edited.""" + + participant_index: int + """Position in that component's participant list whose key is replaced.""" + + with_validator_index: ValidatorIndex + """Validator whose attestation key replaces the original.""" + + +class SwapComponentMessageBindings(BaseModel): + """Swap the emitted message-slot bindings of two components. + + The merged proof and the per-component key layout stay honest. + Each component's proof is then checked against the other component's binding. + A conforming verifier rejects this transposition. + """ + + first_component_index: int + """Index of one component whose emitted message-slot binding is swapped.""" + + second_component_index: int + """Index of the other component whose emitted message-slot binding is swapped.""" + + +class DropComponentMessageBinding(BaseModel): + """Drop one component's emitted message-slot binding while keeping its keys. + + The emitted binding list ends up shorter than the per-component key list. + A conforming verifier rejects the length mismatch. + """ + + component_index: int + """Index of the component whose emitted message-slot binding is removed.""" + + +Tamper = ( + RebindComponentToAlternateHeadRoot + | IncrementComponentSlot + | SwapComponentParticipantPublicKey + | SwapComponentMessageBindings + | DropComponentMessageBinding +) +"""Union of post-generation mutations that each produce a rejection vector.""" + + +class VerifyMultiMessageProofsTest(BaseConsensusFixture): + """Verify a multi-message aggregate proof against precomputed bytes.""" + + format_name: ClassVar[str] = "verify_multi_message_proofs_test" + + description: ClassVar[str] = ( + "Tests multi-message aggregate proof verification against precomputed proof bytes." + ) + + validator_indices_per_message: list[list[ValidatorIndex]] = Field(exclude=True) + """Per-component validator lists contributing raw signatures.""" + + attestation_data_per_message: list[AttestationData] + """Signed object for each component.""" + + tamper: Tamper | None = Field(default=None, exclude=True) + """Optional post-generation mutation that produces a rejection vector.""" + + # Fields below are populated during generation. + # + # Together they form the client-visible portion of the JSON vector. + + public_keys_per_message: list[list[PublicKey]] | None = None + """Attestation public keys per component, parallel to the participation bits.""" + + aggregation_bits_per_message: list[AggregationBits] | None = None + """Per-component participation bitfields naming each component's contributors.""" + + messages: list[Bytes32] | None = None + """Hash tree root per component, bound into the proof.""" + + slots: list[Slot] | None = None + """Slot per component, bound into the proof.""" + + proof: ByteList512KiB | None = None + """Aggregated multi-message proof bytes for clients to verify.""" + + def make_fixture(self) -> VerifyMultiMessageProofsTest: + """Generate the merged proof, optionally tamper one binding, self-verify, return self. + + Raises: + AssertionError: If the verifier outcome disagrees with the configured expectation. + ValueError: If the tamper is misconfigured or the input has no components. + """ + key_manager = XmssKeyManager.shared() + component_count = len(self.attestation_data_per_message) + if component_count == 0: + raise ValueError("at least one component is required for a multi-message vector") + if len(self.validator_indices_per_message) != component_count: + raise ValueError( + f"validator_indices_per_message length {len(self.validator_indices_per_message)} " + f"does not match attestation_data_per_message length {component_count}" + ) + + # Phase 1: derive the honest bundle for each component. + messages: list[Bytes32] = [] + slots: list[Slot] = [] + public_keys_per_message: list[list[PublicKey]] = [] + aggregation_bits_per_message: list[AggregationBits] = [] + components: list[SingleMessageAggregate] = [] + + for validator_indices, attestation_data in zip( + self.validator_indices_per_message, + self.attestation_data_per_message, + strict=True, + ): + messages.append(hash_tree_root(attestation_data)) + slots.append(attestation_data.slot) + public_keys = [key_manager.get_public_keys(i)[0] for i in validator_indices] + public_keys_per_message.append(public_keys) + aggregation_bits_per_message.append( + ValidatorIndices(data=validator_indices).to_aggregation_bits() + ) + components.append( + self._single_message_aggregate( + key_manager, attestation_data, validator_indices, public_keys + ) + ) + + # Phase 2: honest merge. + merged = MultiMessageAggregate.aggregate( + components, + public_keys_per_part=public_keys_per_message, + ) + + # Phase 3: optionally mutate exactly one binding of the bundle. + match self.tamper: + case RebindComponentToAlternateHeadRoot(component_index=component_index): + self._check_component_index(component_index, component_count) + # Regenerate the targeted component against an alternate head root and re-merge. + # The emitted attestation data, message, slot, keys, and bits stay honest. + # Only the merged proof bytes carry the alternate binding for this component. + honest = self.attestation_data_per_message[component_index] + alt_data = AttestationData( + slot=honest.slot, + head=Checkpoint(root=ALTERNATE_HEAD_ROOT, slot=honest.slot), + target=honest.target, + source=honest.source, + ) + components[component_index] = self._single_message_aggregate( + key_manager, + alt_data, + self.validator_indices_per_message[component_index], + public_keys_per_message[component_index], + ) + merged = MultiMessageAggregate.aggregate( + components, + public_keys_per_part=public_keys_per_message, + ) + + case IncrementComponentSlot(component_index=component_index): + self._check_component_index(component_index, component_count) + bumped = slots[component_index] + Slot(1) + # A bumped slot landing on another component's slot would make the rejection + # ambiguous, since the verifier could then fail on the wrong binding. + if any( + other_index != component_index and other_slot == bumped + for other_index, other_slot in enumerate(slots) + ): + raise ValueError( + f"incremented slot {bumped} collides with another component's slot; " + f"pick component slots that stay distinct after the bump" + ) + slots[component_index] = bumped + + case SwapComponentParticipantPublicKey( + component_index=component_index, + participant_index=position, + with_validator_index=replacement_index, + ): + self._check_component_index(component_index, component_count) + public_keys = public_keys_per_message[component_index] + if not 0 <= position < len(public_keys): + raise ValueError( + f"participant_index {position} out of range " + f"for component {component_index} with {len(public_keys)} keys" + ) + replacement = key_manager.get_public_keys(replacement_index)[0] + # A replacement matching the original key would leave the bundle honest. + # The verifier would then accept and the rejection would be a false positive. + if replacement == public_keys[position]: + raise ValueError( + f"participant key replacement at component {component_index} " + f"position {position} matches the original; " + f"pick a with_validator_index distinct from the participant there" + ) + # The honest merge already bound the proof to the honest keys. + # Editing the emitted key here without re-merging is what breaks verification. + public_keys[position] = replacement + + case SwapComponentMessageBindings( + first_component_index=first_index, + second_component_index=second_index, + ): + self._check_component_index(first_index, component_count) + self._check_component_index(second_index, component_count) + if first_index == second_index: + raise ValueError("swap message bindings requires two distinct components") + # Swap each component's emitted message and slot so its proof faces the other's + # binding, while the merged proof and key layout stay honest. + messages[first_index], messages[second_index] = ( + messages[second_index], + messages[first_index], + ) + slots[first_index], slots[second_index] = ( + slots[second_index], + slots[first_index], + ) + + case DropComponentMessageBinding(component_index=component_index): + self._check_component_index(component_index, component_count) + # Remove one component's emitted message and slot but keep its keys. + # The binding list is now shorter than the per-component key list. + del messages[component_index] + del slots[component_index] + + # Phase 4: self-verify and assert the outcome against the configured expectation. + exception_raised: Exception | None = None + # Catch any exception so a verifier raising the wrong type still produces + # a comparable "expected X got Y" message instead of crashing the filler. + try: + merged.verify( + public_keys_per_message=public_keys_per_message, + messages=list(zip(messages, slots, strict=True)), + ) + except Exception as exception: + exception_raised = exception + self.assert_expected_outcome(exception_raised) + + # Phase 5: publish the client-visible outputs and return self. + self.messages = messages + self.slots = slots + self.public_keys_per_message = public_keys_per_message + self.aggregation_bits_per_message = aggregation_bits_per_message + self.proof = merged.proof + return self + + @staticmethod + def _check_component_index(component_index: int, component_count: int) -> None: + """Reject a tamper that targets a component outside the bundle.""" + if not 0 <= component_index < component_count: + raise ValueError( + f"component_index {component_index} out of range for {component_count} components" + ) + + def _single_message_aggregate( + self, + key_manager: XmssKeyManager, + attestation_data: AttestationData, + validator_indices: list[ValidatorIndex], + public_keys: list[PublicKey], + ) -> SingleMessageAggregate: + """Aggregate raw signatures from each validator into a single-message component.""" + signatures = [ + key_manager.sign_attestation_data(i, attestation_data) for i in validator_indices + ] + return SingleMessageAggregate.aggregate( + children=[], + raw_xmss=list(zip(validator_indices, public_keys, signatures, strict=True)), + message=hash_tree_root(attestation_data), + slot=attestation_data.slot, + ) diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py index 9424fb10..cd5e665c 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py @@ -157,19 +157,7 @@ def make_fixture(self) -> VerifySingleMessageProofsTest: candidate.verify(public_keys, message, slot) except Exception as exception: exception_raised = exception - - if self.expect_exception is None: - if exception_raised is not None: - raise AssertionError(f"Verifier rejected an honest bundle: {exception_raised}") - elif exception_raised is None: - raise AssertionError( - f"Expected {self.expect_exception.__name__} but verification succeeded" - ) - elif not isinstance(exception_raised, self.expect_exception): - raise AssertionError( - f"Expected {self.expect_exception.__name__} but got " - f"{type(exception_raised).__name__}: {exception_raised}" - ) + self.assert_expected_outcome(exception_raised) # Phase 4: publish the client-visible outputs and return self. self.message = message diff --git a/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py new file mode 100644 index 00000000..2a467e73 --- /dev/null +++ b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py @@ -0,0 +1,164 @@ +"""Multi-message aggregate proof verification vectors — rejection cases.""" + +import pytest +from consensus_testing import ( + DropComponentMessageBinding, + IncrementComponentSlot, + RebindComponentToAlternateHeadRoot, + SwapComponentMessageBindings, + SwapComponentParticipantPublicKey, + VerifyMultiMessageProofsTestFiller, +) + +from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.containers import AggregationError, AttestationData +from lean_spec.spec.ssz import Bytes32 + +pytestmark = pytest.mark.valid_until("Lstar") + + +def test_multi_message_wrong_message_in_one_component( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """One component rebound to an alternate head root must fail multi-message verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(17), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(17)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(17)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(18), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(18)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(18)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + expect_exception=AggregationError, + tamper=RebindComponentToAlternateHeadRoot(component_index=1), + ) + + +def test_multi_message_wrong_slot_in_one_component( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """One component's emitted slot bumped past its bound slot must fail multi-message verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(19), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(19)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(19)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(20), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(20)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(20)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + expect_exception=AggregationError, + tamper=IncrementComponentSlot(component_index=1), + ) + + +def test_multi_message_wrong_public_key_in_one_component( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """One participant's key swapped for another validator's must fail multi-message verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(22), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(22)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(22)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(23), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(23)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(23)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + expect_exception=AggregationError, + tamper=SwapComponentParticipantPublicKey( + component_index=1, + participant_index=0, + with_validator_index=ValidatorIndex(2), + ), + ) + + +def test_multi_message_components_with_swapped_bindings( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """Two components whose message-slot bindings are transposed must fail multi-message verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(24), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(24)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(24)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(25), + head=Checkpoint(root=Bytes32(b"\x44" * 32), slot=Slot(25)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(25)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + expect_exception=AggregationError, + tamper=SwapComponentMessageBindings( + first_component_index=0, + second_component_index=1, + ), + ) + + +def test_multi_message_missing_one_component_binding( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """A binding list shorter than the key list must fail multi-message verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(26), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(26)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(26)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(27), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(27)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(27)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + expect_exception=AggregationError, + tamper=DropComponentMessageBinding(component_index=1), + ) diff --git a/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py b/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py new file mode 100644 index 00000000..88e54d0e --- /dev/null +++ b/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py @@ -0,0 +1,140 @@ +"""Multi-message aggregate proof verification vectors — valid cases.""" + +import pytest +from consensus_testing import VerifyMultiMessageProofsTestFiller + +from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.containers import AttestationData +from lean_spec.spec.ssz import Bytes32 + +pytestmark = pytest.mark.valid_until("Lstar") + + +def test_multi_message_single_component_single_validator( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """A single-component bundle with one validator must verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(1), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(1)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(1)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + ) + + +def test_multi_message_two_components_single_validator( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """Two components, each with one validator, must verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(10), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(10)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(10)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(11), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(11)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(11)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + ) + + +def test_multi_message_two_components_four_validators( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """Two components, each with a full four-validator committee, must verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)], + [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(12), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(12)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(12)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(13), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(13)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(13)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + ) + + +def test_multi_message_three_components_mixed_sizes( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """Three components with varying participant counts must verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0), ValidatorIndex(2)], + [ValidatorIndex(1), ValidatorIndex(3)], + [ValidatorIndex(0)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(14), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(14)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(14)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(15), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(15)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(15)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(16), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(16)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(16)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + ) + + +def test_multi_message_component_partial_participation( + verify_multi_message_proofs_test: VerifyMultiMessageProofsTestFiller, +) -> None: + """A non-contiguous committee whose aggregation bits resolve to [1, 0, 1, 1] must verify.""" + verify_multi_message_proofs_test( + validator_indices_per_message=[ + [ValidatorIndex(0), ValidatorIndex(2), ValidatorIndex(3)], + [ValidatorIndex(1)], + ], + attestation_data_per_message=[ + AttestationData( + slot=Slot(17), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(17)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(17)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + AttestationData( + slot=Slot(18), + head=Checkpoint(root=Bytes32(b"\x11" * 32), slot=Slot(18)), + target=Checkpoint(root=Bytes32(b"\x22" * 32), slot=Slot(18)), + source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), + ), + ], + )