Skip to content

Commit 7ed65b1

Browse files
refactor(lstar): freeze constructed-once SSZ containers (leanEthereum#842)
Set frozen=True on the lstar containers that are built once and never mutated after construction: GenesisConfig, Validator, AggregatedAttestation, and SignedAggregatedAttestation. This completes the freeze begun on Checkpoint, AttestationData, Attestation, SingleMessageAggregate, and MultiMessageAggregate, so accidental post-construction mutation now raises instead of silently succeeding. Freezing Validator required one call-site change: the fork-choice test fixture injected public keys by mutating each validator in place. It now rebuilds each validator with model_copy(update=...), which is byte-identical in hash_tree_root and leaves the originals untouched. Block, BlockHeader, BlockBody, and SignedBlock stay mutable for now. Their state_root and body are patched in place during block production and the state transition (block_production.py and state_transition.py), a two-pass pattern entangled with the larger state-transition refactor. State and Store remain mutable by design. Frozen is an enforcement aid, not a hard immutability guarantee: model_config is itself a mutable dict and an unfreeze-then-assign sequence can still leave a field mutable (pydantic#12361). It catches accidental mutation; it is not a soundness boundary. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a257b3c commit 7ed65b1

5 files changed

Lines changed: 113 additions & 4 deletions

File tree

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,14 @@ def make_fixture(self) -> Self:
229229
for i, validator in enumerate(self.anchor_state.validators):
230230
index = ValidatorIndex(i)
231231
attestation_public_key, proposal_public_key = key_manager.get_public_keys(index)
232-
validator.attestation_public_key = attestation_public_key.encode_bytes()
233-
validator.proposal_public_key = proposal_public_key.encode_bytes()
234-
updated_validators.append(validator)
232+
updated_validators.append(
233+
validator.model_copy(
234+
update={
235+
"attestation_public_key": attestation_public_key.encode_bytes(),
236+
"proposal_public_key": proposal_public_key.encode_bytes(),
237+
}
238+
)
239+
)
235240

236241
# Updating validators changes the state root.
237242
# We must also update the anchor block to match.

src/lean_spec/spec/forks/lstar/containers/attestation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class SignedAttestation(Attestation):
3131
class AggregatedAttestation(Container):
3232
"""Aggregated attestation consisting of participation bits and message."""
3333

34+
model_config = Container.model_config | {"frozen": True}
35+
3436
aggregation_bits: AggregationBits
3537
"""Bitfield indicating which validators participated in the aggregation."""
3638

@@ -49,6 +51,8 @@ class SignedAggregatedAttestation(Container):
4951
Contains the attestation data and the aggregated signature proof.
5052
"""
5153

54+
model_config = Container.model_config | {"frozen": True}
55+
5256
data: AttestationData
5357
"""Combined attestation data similar to the beacon chain format."""
5458

src/lean_spec/spec/forks/lstar/containers/validator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ class GenesisConfig(Container):
1414
in the absence of more complex mechanisms like RANDAO or deposits.
1515
"""
1616

17+
model_config = Container.model_config | {"frozen": True}
18+
1719
genesis_time: Uint64
1820
"""The timestamp of the genesis block."""
1921

2022

2123
class Validator(Container):
2224
"""Represents a validator's static metadata and operational interface."""
2325

26+
model_config = Container.model_config | {"frozen": True}
27+
2428
attestation_public_key: Bytes52
2529
"""XMSS public key for signing attestations."""
2630

tests/lean_spec/spec/forks/lstar/containers/test_attestation.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Tests for AggregatedAttestation structure."""
22

3+
import pytest
4+
from pydantic import ValidationError
5+
36
from lean_spec.spec.forks import (
47
AggregationBits,
58
Checkpoint,
@@ -10,8 +13,10 @@
1013
from lean_spec.spec.forks.lstar.containers import (
1114
AggregatedAttestation,
1215
AttestationData,
16+
SignedAggregatedAttestation,
17+
SingleMessageAggregate,
1318
)
14-
from lean_spec.spec.ssz import Bytes32
19+
from lean_spec.spec.ssz import ByteList512KiB, Bytes32
1520

1621

1722
class TestAggregatedAttestation:
@@ -51,3 +56,55 @@ def test_aggregated_attestation_with_many_validators(self) -> None:
5156

5257
recovered = aggregate.aggregation_bits.to_validator_indices()
5358
assert recovered == validator_indices
59+
60+
61+
class TestAggregatedAttestationImmutability:
62+
"""Frozen-model semantics forbid post-construction mutation."""
63+
64+
def test_assigning_data_raises(self) -> None:
65+
"""Assigning new data on a constructed aggregated attestation raises."""
66+
attestation_data = AttestationData(
67+
slot=Slot(5),
68+
head=Checkpoint(root=Bytes32.zero(), slot=Slot(4)),
69+
target=Checkpoint(root=Bytes32.zero(), slot=Slot(3)),
70+
source=Checkpoint(root=Bytes32.zero(), slot=Slot(2)),
71+
)
72+
aggregate = AggregatedAttestation(
73+
aggregation_bits=AggregationBits.from_indices([ValidatorIndex(1)]),
74+
data=attestation_data,
75+
)
76+
with pytest.raises(ValidationError, match="frozen"):
77+
aggregate.data = attestation_data
78+
79+
def test_assigning_aggregation_bits_raises(self) -> None:
80+
"""Assigning new bits on a constructed aggregated attestation raises."""
81+
attestation_data = AttestationData(
82+
slot=Slot(5),
83+
head=Checkpoint(root=Bytes32.zero(), slot=Slot(4)),
84+
target=Checkpoint(root=Bytes32.zero(), slot=Slot(3)),
85+
source=Checkpoint(root=Bytes32.zero(), slot=Slot(2)),
86+
)
87+
bits = AggregationBits.from_indices([ValidatorIndex(1)])
88+
aggregate = AggregatedAttestation(aggregation_bits=bits, data=attestation_data)
89+
with pytest.raises(ValidationError, match="frozen"):
90+
aggregate.aggregation_bits = bits
91+
92+
93+
class TestSignedAggregatedAttestationImmutability:
94+
"""Frozen-model semantics forbid post-construction mutation."""
95+
96+
def test_assigning_proof_raises(self) -> None:
97+
"""Assigning a new proof on a constructed signed aggregated attestation raises."""
98+
attestation_data = AttestationData(
99+
slot=Slot(5),
100+
head=Checkpoint(root=Bytes32.zero(), slot=Slot(4)),
101+
target=Checkpoint(root=Bytes32.zero(), slot=Slot(3)),
102+
source=Checkpoint(root=Bytes32.zero(), slot=Slot(2)),
103+
)
104+
proof = SingleMessageAggregate(
105+
participants=AggregationBits.from_indices([ValidatorIndex(1)]),
106+
proof=ByteList512KiB(data=b""),
107+
)
108+
signed = SignedAggregatedAttestation(data=attestation_data, proof=proof)
109+
with pytest.raises(ValidationError, match="frozen"):
110+
signed.proof = proof
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Tests for the GenesisConfig and Validator containers."""
2+
3+
import pytest
4+
from pydantic import ValidationError
5+
6+
from lean_spec.spec.forks.lstar.containers import GenesisConfig, Validator
7+
from lean_spec.spec.ssz import Bytes52, Uint64
8+
9+
10+
class TestGenesisConfigImmutability:
11+
"""Frozen-model semantics forbid post-construction mutation."""
12+
13+
def test_assigning_genesis_time_raises(self) -> None:
14+
"""Assigning a new genesis time on a constructed config raises."""
15+
config = GenesisConfig(genesis_time=Uint64(1_700_000_000))
16+
with pytest.raises(ValidationError, match="frozen"):
17+
config.genesis_time = Uint64(1_700_000_001)
18+
19+
20+
class TestValidatorImmutability:
21+
"""Frozen-model semantics forbid post-construction mutation."""
22+
23+
def test_assigning_attestation_public_key_raises(self) -> None:
24+
"""Assigning a new attestation key on a constructed validator raises."""
25+
validator = Validator(
26+
attestation_public_key=Bytes52.zero(),
27+
proposal_public_key=Bytes52.zero(),
28+
)
29+
with pytest.raises(ValidationError, match="frozen"):
30+
validator.attestation_public_key = Bytes52(b"\xff" * 52)
31+
32+
def test_assigning_proposal_public_key_raises(self) -> None:
33+
"""Assigning a new proposal key on a constructed validator raises."""
34+
validator = Validator(
35+
attestation_public_key=Bytes52.zero(),
36+
proposal_public_key=Bytes52.zero(),
37+
)
38+
with pytest.raises(ValidationError, match="frozen"):
39+
validator.proposal_public_key = Bytes52(b"\xff" * 52)

0 commit comments

Comments
 (0)