#35563·go-ethereum

core/ssz: add SSZ encoding, decoding, merkleization and proof generation

Author: vivek-0509Created Aug 21, 2026Updated Aug 24, 2026
Labelstype:feature

Rationale

There are two reasons for this, and I want to keep them separate because one is immediate and one is forward looking.

1. Two TODOs in beacon/types, open since 2023

Both were added in #27292, May 2023:

// beacon/types/header.go:79
// TODO(zsfelfoldi): Remove this when an SSZ encoder lands.
func (h *Header) Hash() common.Hash {

// beacon/types/committee.go:85
// TODO(zsfelfoldi): Get rid of this when SSZ encoding lands.
func (s *SerializedSyncCommittee) Root() common.Hash {

Both compute hash_tree_root, the SSZ Merkle root of a value, by open-coding the tree. Header.Hash allocates a 16-slot array, writes each field into a hardcoded leaf position, and folds pairwise from index 7 down to 1. SerializedSyncCommittee.Root pads and hashes 512 pubkeys, then folds by halves. Both work, and both are correct, but the merkleization is fused to one type's exact shape.

That costs two things.

First, it does not generalise. The tree depth and every leaf position are worked out by hand for one specific type, so the next beacon type that needs a root needs its own copy of the same logic. Two already exist.

Second, both functions return only the root and discard everything else. To prove a single field, for example that a given StateRoot belongs to a known header root, a verifier needs the sibling hash at each level on the path down to that leaf. Header.Hash computes all of them and drops them when it returns; SerializedSyncCommittee.Root overwrites them as it folds. So there is no way to produce a proof against either tree today.

These are classic SSZ types and they will stay classic. EIP-7688 lists both BeaconBlockHeader and SyncCommittee in its "Immutable types" table, which means they are deliberately not converted to ProgressiveContainer and their merkleization is fixed for good. So classic encoding and merkleization is all these two will ever need, and that alone is enough to delete both functions.

2. The progressive types are arriving and Geth cannot compute their roots

A classic SSZ container derives its tree shape from the field count, so the shape is not stable across forks. EIP-7495 states the cases: "When the number of fields reaches a new power of two, or a field is removed or replaced with one of a different type, the shape of the underlying Merkle tree changes, breaking verifiers of Merkle proofs for these containers." Every field's position moves, including fields nobody touched.

The cost falls on whoever has to redeploy. As EIP-7495 puts it, "deploying a new verifier may involve security councils to upgrade smart contract logic, or require firmware updates for embedded devices. This effort is needed even when no semantic changes apply to the fields that the verifier is interested in."

EIP-7495 ProgressiveContainer and EIP-7916 ProgressiveList fix this by assigning each field a stable position that never moves, allowing gaps where a field is absent, and recording which positions are filled in an active_fields bitvector.

EIP-7688 is Scheduled for Inclusion in Glamsterdam. It redefines ExecutionPayload, ExecutionPayloadHeader, BeaconBlockBody, BeaconState, Attestation, IndexedAttestation and ExecutionRequests as progressive containers, with many of their list fields becoming ProgressiveList, and Transaction becoming a ProgressiveByteList. EIP-7807 (SSZ execution blocks, still Draft and not scheduled for Glamsterdam) builds on the same primitives (EIP-7495, EIP-7916) and defines ExecutionPayload as ProgressiveContainer(active_fields=[1] * 18).

Progressive merkleization is not a variation on the classic scheme. It builds a chain of subtrees of 1, 4, 16, 64 leaves instead of one balanced tree, so nothing that already exists can compute these roots. Geth has no way to produce a root for any of the types above.

What exists today

Three disconnected mechanisms, none of which covers this:

  • ferranbt/fastssz, used in exactly one file, internal/era/accumulator.go
  • protolambda/zrnt and ztyp, behind the light client
  • hand-rolled sha256 merkleization in beacon/types, the code above

beacon/merkle.VerifyProof can verify a single branch, but nothing in the tree generates an SSZ proof to hand it.

Why not an existing library

  • fastssz has no progressive types. Its tracking issue (ferranbt/fastssz#227) has not moved since February 2026.
  • pk910/dynamic-ssz does have them, and as far as I can tell is the only Go implementation that does. It resolves types through reflection at runtime, which is a poor fit for consensus-critical hashing, where the encoding should be auditable by reading it rather than by tracing runtime behaviour.
  • karalabe/ssz handles classic SSZ well, fixed and variable size both, and suits the engine API transport types in #35171. It has no progressive types, and its monolithic OnFork types vary the encoding across forks rather than keeping the tree shape stable.

Implementation

Yes, I am willing to implement this. It is my project for the Ethereum Protocol Fellowship cohort seven, so it has mentor review and a schedule behind it rather than being an open-ended intention.

A new core/ssz package providing SSZ serialization, strict deserialization, hash_tree_root for both classic and progressive types, and Merkle proof generation against those trees.

Types declare their own layout through hand-written methods rather than struct-tag code generation or runtime reflection. The engine owns the algorithms and each type states its field order in Go, which keeps the ordering somewhere a reviewer can check against the spec line by line, and matches how RLP and the trie are already done here. Reflection would put consensus-critical encoding behind runtime machinery that cannot be audited by reading it.

Two layers, matching the two reasons above.

Layer 1, classic SSZ. What the beacon/types TODOs need. This stands on its own regardless of what happens with the progressive EIPs.

  • PR 1, merkleization core: named sentinel errors, batched sha256 hashing, the zero-hash table for empty subtrees, chunking, classic tree folding and mix_in_length
  • PR 2, codec: SSZ serialization and strict deserialization
  • follow-up: replace Header.Hash and SerializedSyncCommittee.Root with calls into the package, and delete both TODOs

Layer 2, progressive types. Built on layer 1, for EIP-7688 and EIP-7807.

  • PR 3, EIP-7916 progressive merkleization
  • PR 4, EIP-7495 progressive containers
  • PR 5, generalized indices and proof generation

Layer 2 calls into layer 1 rather than sitting beside it, so layer 1 can be finished and verified against the official ssz_generic conformance vectors before any progressive code is written. Each PR is independently reviewable.

If any of these turns out larger than is comfortable to review, I will split it further. The five above are the intended shape rather than a fixed count, and I would rather open more small PRs than fewer large ones.