refactor(ssz): depend on the released SSZ specification - #1205
Merged
Conversation
tcoratger
force-pushed
the
use-eth-ssz-specs-release
branch
from
September 2, 2026 17:42
e2235f6 to
7fbf59f
Compare
The SSZ specification was extracted into its own repository and released as eth-ssz-specs 0.1.0. leanSpec now imports it instead of carrying a copy. Deleted: the nine-module vendored package, the merkleization module, their mirrored unit tests, and the two consensus vector modules whose cases the upstream release ships verbatim (merkleization boundaries and the decode-failure smoke test). The generic cases in the basic-types vectors go the same way; the field-element and attestation-subnet cases stay, since upstream cannot express them. Kept, in a new module for the shapes the SSZ package leaves to the protocol: - The struct base, frozen and camelCase. Fork choice keeps one state per block root and hands it to every branch below, so a writable state would let one branch rewrite history for its siblings. The SSZ shapes are mutable, so each leanSpec base restates the freeze. The key casing is what cross-client test vectors carry. - The byte widths, declared against the SSZ byte vector, as the consensus specs declare theirs. The 32-byte one is the root type itself: a separate vector of that width would be its sibling, and SSZ refuses equality between siblings, so every check of a stored root against a computed one would raise. Two behaviors needed porting rather than renaming: - The KoalaBear field element now rides on the 32-bit unsigned integer. That is what packs eight elements to a leaf; a type outside the unsigned integers takes a leaf each, and every XMSS key, signature and registry root would move. A test pins the packing. - The sequence decoder builds its result past validation, so the validator registry stopped meeting its position rule on the wire. A registry read off the wire is re-checked, which keeps two clients from disagreeing on whether a malformed registry is valid. Every consensus byte is unchanged: the serialized payloads and Merkle roots in all surviving vectors are identical to what the vendored code produced. The one JSON difference is that a bare field element now renders as a decimal string, the way every other integer already did, instead of a Python repr. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tcoratger
force-pushed
the
use-eth-ssz-specs-release
branch
from
September 2, 2026 17:44
7fbf59f to
d792826
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The SSZ specification now lives in ethereum/ssz-specs and ships as
eth-ssz-specs0.1.0. This deletes leanSpec's copy and imports the release instead: 9,059 lines removed, 605 added.What goes
src/lean_spec/spec/ssz/— the nine-module vendored packagesrc/lean_spec/spec/crypto/merkleization.py— merkleization and thehash_tree_rootdispatchtests/spec/ssz/andtests/spec/crypto/test_merkleization.py— the mirrors of those modulestests/consensus/lstar/ssz/test_merkleization_boundaries.pyandtest_decode_failure_smoke.py— the upstream release ships these cases verbatim, same function names, same inputs, same rootstest_basic_types.py— 51 of 57Kept: the consensus, networking and XMSS container vectors, the decode-rejection vectors, and the six basic-type cases upstream cannot express (three field-element, three attestation-subnet). Those pin leanSpec's own encodings and are what cross-client implementers run.
What stays, and why
src/lean_spec/spec/ssz_types.pyholds the three things the SSZ package leaves to the protocol using it.Immutability. SSZ values are mutable, with a version counter that invalidates the remembered root. leanSpec's were frozen. Fork choice keeps one state per block root and hands the same value to every branch descending from it, so a branch that could write through its state would rewrite history for its siblings. Each leanSpec base restates the freeze rather than accepting the loosening silently.
JSON casing. Upstream emits snake_case. Consensus test vectors carry camelCase (
aggregationBits), so the container base keeps the alias generator every spec type already shares.Byte widths.
Bytes4/16/20/33/52/64andByteList512KiB, declared against the SSZ byte vector the wayconsensus-specsdeclares its own.Bytes32is the root type itself, not a separate vector of that width: a separate one would be its sibling, and SSZ refuses equality between siblings, so every check of a stored root against a computed one would raise rather than compare.Two behaviors needed porting, not renaming
The KoalaBear field element.
Fpnow subclassesUint32. That inheritance is what packs eight four-byte elements into one leaf. A type outside the unsigned integers takes a 32-byte leaf each, and every XMSS public key, signature and validator-registry root would move.Fpkeeps its own constructor (reduce mod P), its own strict field arithmetic, and a canonical-residue check on both decode paths, since four bytes can spell a value the field does not hold. A new test pins the vector packing, which nothing covered before.The validator registry position rule. Upstream's sequence decoder builds its result through
model_construct, past validation, soValidatorsstopped meetingindex == positionon the wire — a 224-byte payload leanSpec rejected decoded cleanly to[0, 5].Stateis decoded on checkpoint sync and from storage, and a registry one client accepts and another rejects is a split. The registry now re-checks after deserializing. Containers, bitlists and byte lists still run their validators on decode, soSignature's length check was unaffected.One smaller fix in the same area:
Signaturedeclared its fixed width by overridingis_fixed_size/get_byte_length, which the SSZ base now derives fromfixed_size(). It declaresfixed_size()instead, which restores inline embedding in the enclosing container.Evidence
Fixtures were generated on
main, archived, and regenerated after the migration. Every serialized payload and every Merkle root is byte-identical across all surviving vectors — state transition, fork choice, consensus containers, networking, XMSS, API, sync. The single JSON difference is that a bare field element now renders as"2130706432"rather than the Python repr"Fp(value=2130706432)", matching how every other integer type already rendered. That also fixes a dead branch in the fixture emitter.Also in the emitter:
_generate_decode_failurecaught bareException, so a bug in a filler would be minted into a vector asserting that every client must reject a well-formed input. It now catches refusals only.Notes for review
tests/consensus/lstar/ssz/imported as top-level packagesszand shadowed the dependency duringfill. Added the two missing__init__.pyfiles so the consensus tree is a rooted package.SSZValueErrorsubclassesValueError, so pydantic wraps leanSpec's own container invariants intoValidationErrorand their messages would reach cross-client vectors carrying pydantic's noise. Those three invariants raiseContainerInvariantErrorinstead, keeping the messages the vectors already record.Port(9000) == Uint16(9000)is nowTrue; upstream admits comparison between a type and the one it narrows. Siblings still refuse. The affected test asserts the new contract.bytes.__hash__. Equality still refuses between siblings, which is the stronger guarantee; the test was rewritten around it.proposer_index: ValidatorIndex needs 8 bytes, the input holds 5), which is more informative than what they replace.One caveat worth CI's confirmation: a parallel
fillrun produced 493 fixture entries where 494 were expected, with thethree_of_sixcase oftest_safe_target_does_not_advance_below_supermajoritymissing. Running that module alone emits both cases correctly, so this looks like a worker write race in the filler rather than a semantic regression, but I have not chased it down.🤖 Generated with Claude Code