Skip to content

Commit e8014f9

Browse files
refactor(lstar): freeze the Block family (leanEthereum#843)
* refactor(lstar): freeze constructed-once SSZ containers 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> * refactor(lstar): freeze the Block family Set frozen=True on BlockBody, BlockHeader, Block, and SignedBlock, the last mutable containers in the lstar object graph apart from State and Store. With this, every consensus container except the State accumulator and the fork-choice Store is immutable: accidental post-construction mutation now raises instead of silently corrupting a value that may already be referenced elsewhere or committed to a root. These four were deferred from the initial freeze because each was patched in place after construction. Every such site is migrated to model_copy(update=...), which is byte-identical in hash_tree_root: - Block production builds the block with a zero state root, processes it, then rebuilds the block with the real root. - The slot transition rebuilds the latest header with its cached state root and reassigns it on the still-mutable State, instead of mutating the header in place. - The test-framework builders and the signature-tampering helpers rebuild blocks and bodies through layered model_copy, matching the pattern the attestation-swap tamper already used. State and Store stay mutable. State is the deepcopy-and-mutate accumulator at the heart of the state transition; converting it to return a new value is a larger, separate change. Store is fork-choice scratch state. As with the earlier container freeze, frozen is an enforcement aid, not a hard immutability guarantee (model_config is a mutable dict; pydantic#12361). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ed65b1 commit e8014f9

11 files changed

Lines changed: 212 additions & 42 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ def make_fixture(self) -> Self:
241241
# Updating validators changes the state root.
242242
# We must also update the anchor block to match.
243243
self.anchor_state.validators = Validators(data=updated_validators)
244-
self.anchor_block.state_root = hash_tree_root(self.anchor_state)
244+
self.anchor_block = self.anchor_block.model_copy(
245+
update={"state_root": hash_tree_root(self.anchor_state)}
246+
)
245247

246248
# Store initialization
247249
#

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,16 +278,24 @@ def _build_block_from_spec(
278278
)
279279
for fa in spec.forced_attestations
280280
]
281-
block.body.attestations = AggregatedAttestations(
282-
data=[*block.body.attestations.data, *forced]
281+
block = block.model_copy(
282+
update={
283+
"body": block.body.model_copy(
284+
update={
285+
"attestations": AggregatedAttestations(
286+
data=[*block.body.attestations.data, *forced]
287+
)
288+
}
289+
)
290+
}
283291
)
284292

285293
# The body changed, so re-run the transition to get the correct
286294
# post-state and state root.
287295
if post_state is not None:
288296
post_state = LstarSpec().process_slots(state, spec.slot)
289297
post_state = LstarSpec().process_block(post_state, block)
290-
block.state_root = hash_tree_root(post_state)
298+
block = block.model_copy(update={"state_root": hash_tree_root(post_state)})
291299

292300
return block, post_state
293301

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

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,13 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock:
165165
value = self.tamper.get("value")
166166
if value is None:
167167
raise ValueError("set_proposer_index requires a value")
168-
signed_block.block.proposer_index = ValidatorIndex(int(value))
169-
return signed_block
168+
return signed_block.model_copy(
169+
update={
170+
"block": signed_block.block.model_copy(
171+
update={"proposer_index": ValidatorIndex(int(value))}
172+
)
173+
}
174+
)
170175

171176
if operation == "clear_first_attestation_bits":
172177
original = signed_block.block.body.attestations.data
@@ -175,18 +180,32 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock:
175180
first = original[0]
176181
empty_bits = AggregationBits(data=[Boolean(False)] * len(first.aggregation_bits.data))
177182
cleared = AggregatedAttestation(aggregation_bits=empty_bits, data=first.data)
178-
signed_block.block.body.attestations = AggregatedAttestations(
179-
data=[cleared, *original[1:]]
183+
return signed_block.model_copy(
184+
update={
185+
"block": signed_block.block.model_copy(
186+
update={
187+
"body": signed_block.block.body.model_copy(
188+
update={
189+
"attestations": AggregatedAttestations(
190+
data=[cleared, *original[1:]]
191+
)
192+
}
193+
)
194+
}
195+
)
196+
}
180197
)
181-
return signed_block
182198

183199
if operation == "corrupt_proof":
184200
# Replace the merged proof with a short bogus payload.
185201
# The verifier rejects the malformed proof bytes.
186-
signed_block.proof = MultiMessageAggregate(
187-
proof=ByteList512KiB(data=b"\x00\x01\x02\x03"),
202+
return signed_block.model_copy(
203+
update={
204+
"proof": MultiMessageAggregate(
205+
proof=ByteList512KiB(data=b"\x00\x01\x02\x03"),
206+
)
207+
}
188208
)
189-
return signed_block
190209

191210
if operation == "append_phantom_attestation":
192211
# Add a body attestation with no matching proof component.
@@ -203,19 +222,35 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock:
203222
aggregation_bits=AggregationBits(data=[Boolean(True)]),
204223
data=phantom_data,
205224
)
206-
signed_block.block.body.attestations = AggregatedAttestations(
207-
data=[*signed_block.block.body.attestations.data, phantom]
225+
return signed_block.model_copy(
226+
update={
227+
"block": signed_block.block.model_copy(
228+
update={
229+
"body": signed_block.block.body.model_copy(
230+
update={
231+
"attestations": AggregatedAttestations(
232+
data=[*signed_block.block.body.attestations.data, phantom]
233+
)
234+
}
235+
)
236+
}
237+
)
238+
}
208239
)
209-
return signed_block
210240

211241
if operation == "mutate_state_root":
212242
# Change a block field after signing so the block root differs.
213243
# The proposer component's bound message no longer matches the
214244
# recomputed block root, even though the signature is honest.
215245
# This is the repackaging vector: an honest proof reused under
216246
# a different message.
217-
signed_block.block.state_root = Bytes32(b"\xff" * 32)
218-
return signed_block
247+
return signed_block.model_copy(
248+
update={
249+
"block": signed_block.block.model_copy(
250+
update={"state_root": Bytes32(b"\xff" * 32)}
251+
)
252+
}
253+
)
219254

220255
if operation == "swap_first_two_attestations":
221256
body = signed_block.block.body

packages/testing/src/consensus_testing/test_types/aggregated_attestation_spec.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,16 @@ def build_invalid_proof(
202202
invalid_proof = SingleMessageAggregate(participants=aggregation_bits, proof=placeholder)
203203

204204
# Append invalid attestation to the block body.
205-
block.body.attestations = AggregatedAttestations(
206-
data=[*block.body.attestations.data, invalid_aggregated]
205+
block = block.model_copy(
206+
update={
207+
"body": block.body.model_copy(
208+
update={
209+
"attestations": AggregatedAttestations(
210+
data=[*block.body.attestations.data, invalid_aggregated]
211+
)
212+
}
213+
)
214+
}
207215
)
208216

209217
return block, invalid_proof

packages/testing/src/consensus_testing/test_types/block_spec.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -548,22 +548,30 @@ def build_signed_block_with_store(
548548
attestation_spec.validator_indices, attestation_data
549549
)
550550
block_proofs.append(proof)
551-
final_block.body.attestations = AggregatedAttestations(
552-
data=[
553-
*final_block.body.attestations.data,
554-
AggregatedAttestation(
555-
aggregation_bits=AggregationBits.from_indices(
556-
attestation_spec.validator_indices
557-
),
558-
data=attestation_data,
559-
),
560-
]
551+
final_block = final_block.model_copy(
552+
update={
553+
"body": final_block.body.model_copy(
554+
update={
555+
"attestations": AggregatedAttestations(
556+
data=[
557+
*final_block.body.attestations.data,
558+
AggregatedAttestation(
559+
aggregation_bits=AggregationBits.from_indices(
560+
attestation_spec.validator_indices
561+
),
562+
data=attestation_data,
563+
),
564+
]
565+
)
566+
}
567+
)
568+
}
561569
)
562570

563571
# Recompute state root with the modified body.
564572
post_state = spec.process_slots(parent_state, self.slot)
565573
post_state = spec.process_block(post_state, final_block)
566-
final_block.state_root = hash_tree_root(post_state)
574+
final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)})
567575

568576
signed_block = self._sign_block(
569577
final_block, block_proofs, proposer_index, key_manager, parent_state

src/lean_spec/spec/forks/lstar/block_production.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,6 @@ def build_block(
282282
# Merging proofs keeps the same voters, so the post-state is unchanged.
283283
# Only the body's shape differs, so just the root is needed.
284284
post_state = self.process_block(self.process_slots(state, slot), final_block)
285-
final_block.state_root = hash_tree_root(post_state)
285+
final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)})
286286

287287
return final_block, post_state, aggregated_attestations, aggregated_signatures

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
class BlockBody(Container):
1111
"""Payload of a block containing attestations."""
1212

13+
model_config = Container.model_config | {"frozen": True}
14+
1315
attestations: AggregatedAttestations
1416
"""Attestations in the block. Signatures are folded into the block-level proof."""
1517

@@ -22,6 +24,8 @@ class BlockHeader(Container):
2224
Smaller than full blocks.
2325
"""
2426

27+
model_config = Container.model_config | {"frozen": True}
28+
2529
slot: Slot
2630
"""The slot in which the block was proposed."""
2731

@@ -41,6 +45,8 @@ class BlockHeader(Container):
4145
class Block(Container):
4246
"""A complete block including header and body."""
4347

48+
model_config = Container.model_config | {"frozen": True}
49+
4450
slot: Slot
4551
"""The slot in which the block was proposed."""
4652

@@ -65,6 +71,8 @@ class SignedBlock(Container):
6571
over the block root.
6672
"""
6773

74+
model_config = Container.model_config | {"frozen": True}
75+
6876
block: Block
6977
"""The block being signed."""
7078

src/lean_spec/spec/forks/lstar/state_transition.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ def process_slots(self, state: State, target_slot: Slot) -> State:
144144
)
145145

146146
if needs_state_root:
147-
state.latest_block_header.state_root = cached_state_root
147+
state.latest_block_header = state.latest_block_header.model_copy(
148+
update={"state_root": cached_state_root}
149+
)
148150
state.slot = Slot(state.slot + Slot(1))
149151

150152
# Reached the target slot. Return the advanced state.

tests/consensus/lstar/fc/test_checkpoint_sync.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,7 @@ def test_store_from_anchor_rejects_mismatched_state_root(
361361
# mismatch below the only difference between this test and the happy path.
362362
assert anchor_block.state_root == hash_tree_root(anchor_state)
363363

364-
anchor_block.state_root = Bytes32(b"\xff" * 32)
365-
bad_anchor_block = anchor_block
364+
bad_anchor_block = anchor_block.model_copy(update={"state_root": Bytes32(b"\xff" * 32)})
366365

367366
fork_choice_test(
368367
anchor_state=anchor_state,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for the Block container family."""
2+
3+
import pytest
4+
from pydantic import ValidationError
5+
6+
from lean_spec.spec.forks import Slot, ValidatorIndex
7+
from lean_spec.spec.forks.lstar.containers import (
8+
AggregatedAttestations,
9+
Block,
10+
BlockBody,
11+
BlockHeader,
12+
MultiMessageAggregate,
13+
SignedBlock,
14+
)
15+
from lean_spec.spec.ssz import ByteList512KiB, Bytes32
16+
17+
18+
def _empty_body() -> BlockBody:
19+
"""A block body carrying no attestations."""
20+
return BlockBody(attestations=AggregatedAttestations(data=[]))
21+
22+
23+
def _block() -> Block:
24+
"""A minimal block with a zeroed state root and an empty body."""
25+
return Block(
26+
slot=Slot(1),
27+
proposer_index=ValidatorIndex(0),
28+
parent_root=Bytes32.zero(),
29+
state_root=Bytes32.zero(),
30+
body=_empty_body(),
31+
)
32+
33+
34+
class TestBlockBodyImmutability:
35+
"""Frozen-model semantics forbid post-construction mutation."""
36+
37+
def test_assigning_attestations_raises(self) -> None:
38+
"""Assigning new attestations on a constructed body raises."""
39+
body = _empty_body()
40+
with pytest.raises(ValidationError, match="frozen"):
41+
body.attestations = AggregatedAttestations(data=[])
42+
43+
44+
class TestBlockHeaderImmutability:
45+
"""Frozen-model semantics forbid post-construction mutation."""
46+
47+
def test_assigning_state_root_raises(self) -> None:
48+
"""Assigning a new state root on a constructed header raises."""
49+
header = BlockHeader(
50+
slot=Slot(1),
51+
proposer_index=ValidatorIndex(0),
52+
parent_root=Bytes32.zero(),
53+
state_root=Bytes32.zero(),
54+
body_root=Bytes32.zero(),
55+
)
56+
with pytest.raises(ValidationError, match="frozen"):
57+
header.state_root = Bytes32(b"\xff" * 32)
58+
59+
60+
class TestBlockImmutability:
61+
"""Frozen-model semantics forbid post-construction mutation."""
62+
63+
def test_assigning_state_root_raises(self) -> None:
64+
"""Assigning a new state root on a constructed block raises."""
65+
block = _block()
66+
with pytest.raises(ValidationError, match="frozen"):
67+
block.state_root = Bytes32(b"\xff" * 32)
68+
69+
def test_assigning_body_raises(self) -> None:
70+
"""Assigning a new body on a constructed block raises."""
71+
block = _block()
72+
with pytest.raises(ValidationError, match="frozen"):
73+
block.body = _empty_body()
74+
75+
76+
class TestSignedBlockImmutability:
77+
"""Frozen-model semantics forbid post-construction mutation."""
78+
79+
def test_assigning_proof_raises(self) -> None:
80+
"""Assigning a new proof on a constructed signed block raises."""
81+
proof = MultiMessageAggregate(proof=ByteList512KiB(data=b""))
82+
signed_block = SignedBlock(block=_block(), proof=proof)
83+
with pytest.raises(ValidationError, match="frozen"):
84+
signed_block.proof = proof

0 commit comments

Comments
 (0)