Skip to content

refactor(ssz): depend on the released SSZ specification - #1205

Merged
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:use-eth-ssz-specs-release
Sep 2, 2026
Merged

refactor(ssz): depend on the released SSZ specification#1205
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:use-eth-ssz-specs-release

Conversation

@tcoratger

Copy link
Copy Markdown
Collaborator

The SSZ specification now lives in ethereum/ssz-specs and ships as eth-ssz-specs 0.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 package
  • src/lean_spec/spec/crypto/merkleization.py — merkleization and the hash_tree_root dispatch
  • tests/spec/ssz/ and tests/spec/crypto/test_merkleization.py — the mirrors of those modules
  • tests/consensus/lstar/ssz/test_merkleization_boundaries.py and test_decode_failure_smoke.py — the upstream release ships these cases verbatim, same function names, same inputs, same roots
  • the generic cases in test_basic_types.py — 51 of 57

Kept: 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.py holds 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/64 and ByteList512KiB, declared against the SSZ byte vector the way consensus-specs declares its own. Bytes32 is 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. Fp now subclasses Uint32. 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. Fp keeps 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, so Validators stopped meeting index == position on the wire — a 224-byte payload leanSpec rejected decoded cleanly to [0, 5]. State is 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, so Signature's length check was unaffected.

One smaller fix in the same area: Signature declared its fixed width by overriding is_fixed_size/get_byte_length, which the SSZ base now derives from fixed_size(). It declares fixed_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_failure caught bare Exception, 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 package ssz and shadowed the dependency during fill. Added the two missing __init__.py files so the consensus tree is a rooted package.
  • Upstream's SSZValueError subclasses ValueError, so pydantic wraps leanSpec's own container invariants into ValidationError and their messages would reach cross-client vectors carrying pydantic's noise. Those three invariants raise ContainerInvariantError instead, keeping the messages the vectors already record.
  • Port(9000) == Uint16(9000) is now True; upstream admits comparison between a type and the one it narrows. Siblings still refuse. The affected test asserts the new contract.
  • Two byte-vector newtypes of equal width and equal content now hash alike, since the SSZ byte vector inherits bytes.__hash__. Equality still refuses between siblings, which is the stronger guarantee; the test was rewritten around it.
  • Error-message assertions across the tree were updated to the upstream text. Upstream messages carry the field path (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 fill run produced 493 fixture entries where 494 were expected, with the three_of_six case of test_safe_target_does_not_advance_below_supermajority missing. 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

@tcoratger
tcoratger force-pushed the use-eth-ssz-specs-release branch from e2235f6 to 7fbf59f Compare September 2, 2026 17:42
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
tcoratger force-pushed the use-eth-ssz-specs-release branch from 7fbf59f to d792826 Compare September 2, 2026 17:44
@tcoratger
tcoratger merged commit 0b7d33e into leanEthereum:main Sep 2, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant