Skip to content

Commit 5cee882

Browse files
tcoratgerclaude
andauthored
refactor: enforce frozen natively in StrictBaseModel (leanEthereum#845)
Restore the frozen constraint that leanEthereum#789 removed, this time enforced once in the base model instead of per class. Every spec type is now immutable by default, with no opt-outs: the State accumulator and the fork-choice Store are frozen too, and every remaining in-place mutation site is converted back to the model_copy(update=...) functional style. Follow-up to leanEthereum#842/leanEthereum#843, as discussed in the leanEthereum#843 review thread. Changes: - base.py: add frozen to StrictBaseModel; restore the pre-leanEthereum#789 docstring. - Delete the 17 per-class model_config | {"frozen": True} overrides (block, checkpoint, attestation, aggregation, validator, xmss, eth2) now that the base enforces them. - state_transition.py: process_slots rebinds through model_copy (the deepcopy barrier is no longer needed), process_block_header applies its updates atomically in one final copy, process_attestations returns a new state. - fork_choice.py, timeline.py, validator_duties.py, aggregation.py: every store update flows through model_copy; dicts and inner sets are shallow-copied before growing so the caller's store is left untouched. - node/chain/service.py, node/sync/service.py: rebind the store instead of patching it in place. - xmss/interface.py: advance_preparation returns a rebuilt secret key. - enr.py: from_rlp rebuilds the record with the computed node id. - packages/testing + tests: all fixture-setup mutations converted to model_copy rebinding; helpers that mutated arguments now return the new instance. - Restore test_frozen_rejects_assignment and the immutability wording in the SSZ patterns rule; add mirrored immutability tests for State and Store. Validation: - just check passes (ruff lint + format, ty, codespell, mdformat, lock) - Unit suites: lstar spec 257 passed, node 1682 passed, crypto/ssz/enr/containers/base 1660 passed Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3738618 commit 5cee882

34 files changed

Lines changed: 505 additions & 322 deletions

.claude/rules/ssz-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ When creating SSZ types, follow these established patterns:
2929
- These use Pydantic models with a `data` field for contents
3030
- Example: `MyList(data=[1, 2, 3])` *has* a list of data with SSZ serialization
3131

32-
**Key principle**: If the type conceptually *holds* or *contains* other data, use SSZModel for consistent validation and SSZ encoding.
32+
**Key principle**: If the type conceptually *holds* or *contains* other data, use SSZModel for consistent validation and immutability.
3333

3434
## Modular Architecture
3535

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,9 @@ def make_fixture(self) -> Self:
246246

247247
# Updating validators changes the state root.
248248
# We must also update the anchor block to match.
249-
self.anchor_state.validators = Validators(data=updated_validators)
249+
self.anchor_state = self.anchor_state.model_copy(
250+
update={"validators": Validators(data=updated_validators)}
251+
)
250252
self.anchor_block = self.anchor_block.model_copy(
251253
update={"state_root": hash_tree_root(self.anchor_state)}
252254
)

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,7 @@ def build_signed_block_with_store(
540540
}
541541
for attestation_data, proofs in merged_store.latest_known_aggregated_payloads.items():
542542
merged_known.setdefault(attestation_data, set()).update(proofs)
543-
caller_store.latest_known_aggregated_payloads = merged_known
544-
store = caller_store
543+
store = caller_store.model_copy(update={"latest_known_aggregated_payloads": merged_known})
545544

546545
# Append forced attestations that bypass the builder's MAX cap.
547546
# Each entry is signed and aggregated so the block carries valid proofs.

src/lean_spec/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,17 @@ def to_json(self, **kwargs: Any) -> dict[str, Any]:
4848

4949
class StrictBaseModel(CamelModel):
5050
"""
51-
Strict base model for all spec types.
51+
Immutable, strict base model for all spec types.
5252
53-
Adds two constraints on top of CamelModel:
53+
Adds three constraints on top of CamelModel:
5454
55+
- Frozen: attribute assignment after construction raises
5556
- Extra forbidden: unknown fields rejected at construction
5657
- Strict: no implicit type coercion
5758
"""
5859

5960
model_config = CamelModel.model_config | {
6061
"extra": "forbid",
62+
"frozen": True,
6163
"strict": True,
6264
}

src/lean_spec/node/chain/service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ async def _tick_to(self, target_interval: Interval) -> list[SignedAggregatedAtte
110110
# Acceptance for the jumped slots waits for the final slot's tick.
111111
# That is safe: acceptance is a monotone pool merge, and the head recomputes from scratch.
112112
if target_interval - store.time > Interval(INTERVALS_PER_SLOT):
113-
store.time = target_interval - Interval(INTERVALS_PER_SLOT)
113+
store = store.model_copy(
114+
update={"time": target_interval - Interval(INTERVALS_PER_SLOT)}
115+
)
114116

115117
# Tick remaining intervals one at a time.
116118
while store.time < target_interval:

src/lean_spec/node/networking/enr/enr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ def from_rlp(cls, rlp_data: bytes) -> Self:
373373
# Compute and store node_id for routing/identification.
374374
node_id = enr.compute_node_id()
375375
if node_id is not None:
376-
enr.node_id = node_id
376+
enr = enr.model_copy(update={"node_id": node_id})
377377

378378
return enr
379379

src/lean_spec/node/networking/enr/eth2.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ class Eth2Data(StrictBaseModel):
3535
SSZ: fork_digest (4) + next_fork_version (4) + next_fork_epoch (8)
3636
"""
3737

38-
model_config = StrictBaseModel.model_config | {"frozen": True}
39-
4038
fork_digest: ForkDigest
4139
"""Current active fork identifier (4 bytes)."""
4240

src/lean_spec/node/sync/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ def _deconstruct_block_into_store(
656656
aggregates.append(SignedAggregatedAttestation(data=attestation_data, proof=combined))
657657

658658
if aggregates:
659-
store.latest_new_aggregated_payloads = new_payloads
659+
store = store.model_copy(update={"latest_new_aggregated_payloads": new_payloads})
660660

661661
return store, aggregates
662662

src/lean_spec/spec/crypto/xmss/containers.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ class PublicKey(HexSerializedContainer):
4242
class Signature(HexSerializedContainer):
4343
"""A single XMSS signature for one slot and message under one public key."""
4444

45-
model_config = Container.model_config | {"frozen": True}
46-
4745
path: HashTreeOpening
4846
"""Authentication path from the one-time key up to the Merkle root."""
4947

@@ -138,8 +136,6 @@ class SecretKey(HexSerializedContainer):
138136
class KeyPair(StrictBaseModel):
139137
"""A single XMSS public/secret pair returned by key generation."""
140138

141-
model_config = StrictBaseModel.model_config | {"frozen": True}
142-
143139
public_key: PublicKey
144140
"""Public key."""
145141

@@ -161,8 +157,6 @@ class ValidatorKeyPair(StrictBaseModel):
161157
Two independent pairs let each role sign from its own Winternitz chains.
162158
"""
163159

164-
model_config = StrictBaseModel.model_config | {"frozen": True}
165-
166160
attestation_keypair: KeyPair
167161
"""Key pair used to sign attestation data."""
168162

src/lean_spec/spec/crypto/xmss/interface.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,13 @@ def advance_preparation(self, secret_key: SecretKey) -> SecretKey:
398398
)
399399

400400
# Phase 3: rotate the right tree into the left slot, advance the index.
401-
secret_key.left_bottom_tree = secret_key.right_bottom_tree
402-
secret_key.right_bottom_tree = new_right_bottom_tree
403-
secret_key.left_bottom_tree_index = Uint64(left_index + 1)
404-
return secret_key
401+
return secret_key.model_copy(
402+
update={
403+
"left_bottom_tree": secret_key.right_bottom_tree,
404+
"right_bottom_tree": new_right_bottom_tree,
405+
"left_bottom_tree_index": Uint64(left_index + 1),
406+
}
407+
)
405408

406409

407410
PROD_SIGNATURE_SCHEME = GeneralizedXmssScheme(config=PROD_CONFIG, poseidon=POSEIDON)

0 commit comments

Comments
 (0)