|
| 1 | +"""Signed block with attestation test fixture format.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from functools import lru_cache |
| 6 | +from typing import Any, ClassVar, Dict |
| 7 | + |
| 8 | +from pydantic import model_serializer, model_validator |
| 9 | + |
| 10 | +from lean_spec.subspecs.containers.block.block import ( |
| 11 | + BlockWithAttestation, |
| 12 | + SignedBlockWithAttestation, |
| 13 | +) |
| 14 | +from lean_spec.subspecs.containers.block.types import BlockSignatures |
| 15 | +from lean_spec.subspecs.containers.slot import Slot |
| 16 | +from lean_spec.subspecs.containers.state import Validators |
| 17 | +from lean_spec.subspecs.containers.state.state import State |
| 18 | +from lean_spec.subspecs.xmss.interface import TEST_SIGNATURE_SCHEME |
| 19 | +from lean_spec.types import ValidatorIndex |
| 20 | + |
| 21 | +from ..keys import XmssKeyManager |
| 22 | +from .base import BaseConsensusFixture |
| 23 | + |
| 24 | + |
| 25 | +@lru_cache(maxsize=1) |
| 26 | +def _get_shared_key_manager() -> XmssKeyManager: |
| 27 | + """ |
| 28 | + Get or create the shared XMSS key manager for reusing keys across tests. |
| 29 | +
|
| 30 | + Uses functools.lru_cache to create a singleton instance that's shared |
| 31 | + across all test fixture generations within a session. This optimizes |
| 32 | + performance by reusing keys when possible. |
| 33 | +
|
| 34 | + Returns: |
| 35 | + Shared XmssKeyManager instance with max_slot=2. |
| 36 | + """ |
| 37 | + return XmssKeyManager(max_slot=Slot(2)) |
| 38 | + |
| 39 | + |
| 40 | +class SignedBlockWithAttestationTest(BaseConsensusFixture): |
| 41 | + """ |
| 42 | + Test fixture for verifying signed block with attestation signature correctness. |
| 43 | +
|
| 44 | + This fixture tests the cryptographic correctness of a single SignedBlockWithAttestation: |
| 45 | + - Block + proposer attestation + aggregated signatures |
| 46 | +
|
| 47 | + The fixture verifies that: |
| 48 | + 1. Signatures are correctly generated using XMSS keys |
| 49 | + 2. Signatures can be verified using the validators' public keys |
| 50 | + 3. Invalid signatures are properly rejected |
| 51 | + 4. Signature aggregation is correct |
| 52 | +
|
| 53 | + Structure: |
| 54 | + anchor_state: Initial state with validator public keys |
| 55 | + container: Single signed block with attestation to test |
| 56 | + valid: Whether all signatures should verify (default: True) |
| 57 | + """ |
| 58 | + |
| 59 | + format_name: ClassVar[str] = "signed_block_with_attestation_test" |
| 60 | + description: ClassVar[str] = "Tests signed block with attestation signature correctness" |
| 61 | + |
| 62 | + anchor_state: State | None = None |
| 63 | + """ |
| 64 | + The initial consensus state with validator public keys. |
| 65 | +
|
| 66 | + If not provided, the framework will use the genesis fixture from pytest. |
| 67 | + This allows tests to omit genesis for simpler test code while still |
| 68 | + allowing customization when needed. |
| 69 | + """ |
| 70 | + |
| 71 | + signed_block_with_attestation: SignedBlockWithAttestation |
| 72 | + """ |
| 73 | + Single signed block with attestation to verify. |
| 74 | +
|
| 75 | + The block will be verified for signature correctness against the |
| 76 | + validator public keys in anchor_state. |
| 77 | + """ |
| 78 | + |
| 79 | + valid: bool = True |
| 80 | + """ |
| 81 | + Whether all signatures are expected to be valid. |
| 82 | +
|
| 83 | + If True, all signatures must verify successfully. |
| 84 | + If False, at least one signature must fail verification. |
| 85 | + """ |
| 86 | + |
| 87 | + max_slot: Slot | None = None |
| 88 | + """ |
| 89 | + Maximum slot for which XMSS keys should be valid. |
| 90 | +
|
| 91 | + If not provided, will be auto-calculated from the container. This determines |
| 92 | + how many slots worth of XMSS signatures can be generated. |
| 93 | + """ |
| 94 | + |
| 95 | + @model_serializer(mode="wrap", when_used="json") |
| 96 | + def _serialize_model(self, serializer: Any) -> Dict[str, Any]: |
| 97 | + """ |
| 98 | + Custom serializer for JSON output. |
| 99 | +
|
| 100 | + Outputs: |
| 101 | + - blockWithAttestation: The unsigned block with proposer attestation |
| 102 | + - attester_pubkeys: Array of public keys for attestations in block body (in order) |
| 103 | + - proposer_pubkey: The proposer's public key |
| 104 | + - maxSlot: Maximum slot value |
| 105 | + - signedBlockWithAttestation: The complete signed block with signatures |
| 106 | + """ |
| 107 | + # Get default serialization |
| 108 | + data = serializer(self) |
| 109 | + |
| 110 | + if self.anchor_state is not None: |
| 111 | + # Collect attester pubkeys for all attestations in the block body |
| 112 | + attester_pubkeys = [] |
| 113 | + for attestation in self.signed_block_with_attestation.message.block.body.attestations: |
| 114 | + validator_index = ValidatorIndex(int(attestation.validator_id)) |
| 115 | + validator = self.anchor_state.validators[int(validator_index)] |
| 116 | + attester_pubkeys.append( |
| 117 | + validator.pubkey.hex() |
| 118 | + if isinstance(validator.pubkey, bytes) |
| 119 | + else validator.pubkey |
| 120 | + ) |
| 121 | + |
| 122 | + # Get proposer pubkey |
| 123 | + proposer_index = ValidatorIndex(int(self.signed_block_with_attestation.message.block.proposer_index)) |
| 124 | + proposer = self.anchor_state.validators[int(proposer_index)] |
| 125 | + proposer_pubkey = ( |
| 126 | + proposer.pubkey.hex() |
| 127 | + if isinstance(proposer.pubkey, bytes) |
| 128 | + else proposer.pubkey |
| 129 | + ) |
| 130 | + |
| 131 | + data["blockWithAttestation"] = self.signed_block_with_attestation.message.model_dump(mode="json") |
| 132 | + data["attester_pubkeys"] = attester_pubkeys |
| 133 | + data["proposer_pubkey"] = proposer_pubkey |
| 134 | + data["signedBlockWithAttestation"] = self.signed_block_with_attestation.model_dump(mode="json") |
| 135 | + data.pop("container", None) |
| 136 | + data.pop("anchorState", None) |
| 137 | + |
| 138 | + return data |
| 139 | + |
| 140 | + @model_validator(mode="after") |
| 141 | + def set_max_slot_default(self) -> SignedBlockWithAttestationTest: |
| 142 | + """ |
| 143 | + Auto-calculate max_slot from signed_block_with_attestation if not provided. |
| 144 | +
|
| 145 | + Uses the slot value from the block to ensure XMSS keys are |
| 146 | + generated with sufficient capacity. |
| 147 | + """ |
| 148 | + if self.max_slot is None: |
| 149 | + self.max_slot = Slot(int(self.signed_block_with_attestation.message.block.slot)) |
| 150 | + |
| 151 | + return self |
| 152 | + |
| 153 | + def make_fixture(self) -> SignedBlockWithAttestationTest: |
| 154 | + """ |
| 155 | + Generate the fixture by building and verifying the signed block. |
| 156 | +
|
| 157 | + This validates the test by: |
| 158 | + 1. Setting up XMSS key manager with validator keys |
| 159 | + 2. Updating anchor_state with generated public keys |
| 160 | + 3. Generating signatures using XMSS keys |
| 161 | + 4. Verifying signatures against validator public keys |
| 162 | + 5. Checking that valid/invalid expectation is met |
| 163 | +
|
| 164 | + Returns |
| 165 | + ------- |
| 166 | + SignedBlockWithAttestationTest |
| 167 | + The validated fixture with properly signed block. |
| 168 | +
|
| 169 | + Raises |
| 170 | + ------ |
| 171 | + AssertionError |
| 172 | + If signature verification doesn't match expected validity. |
| 173 | + """ |
| 174 | + # Ensure anchor_state is set |
| 175 | + assert self.anchor_state is not None, "anchor_state must be set before make_fixture" |
| 176 | + assert self.max_slot is not None, "max_slot must be set before make_fixture" |
| 177 | + |
| 178 | + # Use shared key manager if it has sufficient capacity, otherwise create a new one |
| 179 | + shared_key_manager = _get_shared_key_manager() |
| 180 | + key_manager = ( |
| 181 | + shared_key_manager |
| 182 | + if self.max_slot <= shared_key_manager.max_slot |
| 183 | + else XmssKeyManager(max_slot=self.max_slot, scheme=TEST_SIGNATURE_SCHEME) |
| 184 | + ) |
| 185 | + |
| 186 | + # Update validator pubkeys to match key_manager's generated keys |
| 187 | + updated_validators = [ |
| 188 | + validator.model_copy( |
| 189 | + update={ |
| 190 | + "pubkey": key_manager[ValidatorIndex(i)].public.to_bytes( |
| 191 | + key_manager.scheme.config |
| 192 | + ) |
| 193 | + } |
| 194 | + ) |
| 195 | + for i, validator in enumerate(self.anchor_state.validators) |
| 196 | + ] |
| 197 | + |
| 198 | + self.anchor_state = self.anchor_state.model_copy( |
| 199 | + update={"validators": Validators(data=updated_validators)} |
| 200 | + ) |
| 201 | + |
| 202 | + # Build signed block with correct signatures |
| 203 | + signed_block = self._build_signed_block(self.signed_block_with_attestation.message, key_manager) |
| 204 | + self.signed_block_with_attestation = signed_block |
| 205 | + |
| 206 | + # Verify all signatures in the block |
| 207 | + is_valid = signed_block.verify_signatures(self.anchor_state) |
| 208 | + |
| 209 | + if self.valid and not is_valid: |
| 210 | + raise AssertionError( |
| 211 | + "SignedBlockWithAttestation: expected valid signatures but verification failed" |
| 212 | + ) |
| 213 | + elif not self.valid and is_valid: |
| 214 | + raise AssertionError( |
| 215 | + "SignedBlockWithAttestation: expected invalid signatures but verification succeeded" |
| 216 | + ) |
| 217 | + |
| 218 | + return self |
| 219 | + |
| 220 | + def _build_signed_block( |
| 221 | + self, block_with_attestation: BlockWithAttestation, key_manager: XmssKeyManager |
| 222 | + ) -> SignedBlockWithAttestation: |
| 223 | + """ |
| 224 | + Build a SignedBlockWithAttestation with correct XMSS signatures. |
| 225 | +
|
| 226 | + This generates signatures for: |
| 227 | + 1. All attestations in the block body |
| 228 | + 2. The proposer's attestation |
| 229 | +
|
| 230 | + Parameters |
| 231 | + ---------- |
| 232 | + block_with_attestation : BlockWithAttestation |
| 233 | + The block and proposer attestation to sign. |
| 234 | + key_manager : XmssKeyManager |
| 235 | + Key manager for generating XMSS signatures. |
| 236 | +
|
| 237 | + Returns |
| 238 | + ------- |
| 239 | + SignedBlockWithAttestation |
| 240 | + The block with valid XMSS signatures. |
| 241 | + """ |
| 242 | + block = block_with_attestation.block |
| 243 | + proposer_attestation = block_with_attestation.proposer_attestation |
| 244 | + |
| 245 | + # Sign all attestations in the block body |
| 246 | + signature_list = [] |
| 247 | + for attestation in block.body.attestations: |
| 248 | + signature_list.append(key_manager.sign_attestation(attestation)) |
| 249 | + |
| 250 | + # Sign the proposer attestation |
| 251 | + proposer_signature = key_manager.sign_attestation(proposer_attestation) |
| 252 | + signature_list.append(proposer_signature) |
| 253 | + |
| 254 | + return SignedBlockWithAttestation( |
| 255 | + message=block_with_attestation, signature=BlockSignatures(data=signature_list) |
| 256 | + ) |
0 commit comments