Skip to content

[runtime]: Glamsterdam (Gloas) support for the sync committee client - #1056

Draft
dharjeezy wants to merge 20 commits into
mainfrom
dami/glamsterdam-upgrade
Draft

[runtime]: Glamsterdam (Gloas) support for the sync committee client#1056
dharjeezy wants to merge 20 commits into
mainfrom
dami/glamsterdam-upgrade

Conversation

@dharjeezy

@dharjeezy dharjeezy commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Prepares the Ethereum sync committee consensus client for Glamsterdam (CL name gloas, EIP-7732 ePBS), and moves the SSZ layer from ssz-rs to Lighthouse's stack along the way.

Closes #1011.

The consensus problem

ePBS removes BeaconState.latest_execution_payload_header, the field the verifier proves today for state_root, block_number and timestamp. It is replaced by latest_block_hash plus a builder's bid, and the real ExecutionPayload moves to a separately gossiped envelope that the sync committee never signs. So after Gloas there is no SSZ path from the signed header to the execution state root.

The fix is to SSZ-prove latest_block_hash, have the relayer supply the EL block header, and check keccak256(rlp(header)) == block_hash before decoding the three fields out of the header. Same trust assumptions as today. The alternative, verifying the payload envelope through the PTC, was rejected: the PTC attests to payload timeliness, not to the state root, so it still needs the same block-hash-to-state-root binding while adding a second committee path.

Note the post-Glamsterdam EL header has 23 RLP fields, not 21. It appends block_access_list_hash (EIP-7928) and slot_number (ePBS). The 21 and 22 field encodings both produce the wrong hash.

Fork dispatch is at runtime, not compile time

ExecutionPayloadProof carries an ExecutionProof enum with Legacy and Gloas variants, both always compiled. The verifier picks the path with a runtime match on the epoch of the signed slot, so an update cannot select its own verifier, and a variant that disagrees with the fork is a hard InvalidUpdate.

Why the SSZ layer changed

After devnet-6 was retired, the Gloas spec adopted progressive SSZ merkleization (EIP-7688 and EIP-7916): BeaconState became a ProgressiveContainer and twelve of its lists became ProgressiveList. A progressive container hashes as a right leaning spine rather than a balanced tree, so every generalized index moved:

Field Before After Depth
finalized_checkpoint 84 367 8
next_sync_committee 87 2946 11
execution leaf 88 2947 11

Branch depths are no longer uniform, which the old fixed depth scheme assumed. The verifier needed no change for that: is_valid_merkle_branch walks the bits of the index and now takes the generalized index alone, deriving the depth from it, so the per field *_INDEX_LOG2 constants are gone.

ssz-rs has no progressive support, so rather than maintain our own we moved to the crates Sigma Prime maintains for Lighthouse. That stack had gaps, which are filled in five forks under polytope-labs, each with an open PR:

  • tree_hash has the progressive container merkleization already. Added: no_std, gindex addressed proof generation, multiproofs, and a ContainerFields/TreeHashFields split so a balanced container is never handed methods that assume the progressive tree.
  • ssz_types gains ProgressiveList<T>, which exists nowhere upstream, plus no_std and SCALE.
  • ethereum_ssz gains no_std and SCALE.
  • ethereum_hashing and ethereum_serde_utils gain no_std, since the chain has to build for the runtime.

SCALE is behind a non default scale feature and confined to one file per crate. These types cross a runtime boundary inside consensus proofs, and the encodings match ssz-rs exactly so previously encoded proofs still decode. Notably a bitfield encodes as Vec<bool>, one byte per bit, which is pinned by a test rather than left implicit.

Migration notes for reviewers

Most of the diff is one mechanical change with awkward consequences: ssz_types takes its bounds as type level integers where ssz-rs used const N: usize.

  • SSZ bound constants gained same named pub type aliases. Rust keeps types and values in separate namespaces, so VariableList<T, MAX_DEPOSITS> picks up the type and vec![0; MAX_DEPOSITS] picks up the constant, and no use site had to change.
  • Unused type parameters are an error where unused const parameters were not. StateList could no longer be a type alias and is now a newtype carrying PhantomData<N>, and the containers ePBS empties out carry a phantom field skipped by every derive. Neither reaches the wire.
  • Root was ssz-rs's Node, which carried SCALE and was Copy. Hash256 has neither and the orphan rule blocks adding SCALE, so Root is now Bytes32, following the Hash32 = Bytes32 line beside it. It is no longer Copy, hence some added clones.
  • hash_tree_root() returned a Result; tree_hash_root() is infallible.

Validation

All four gloas integration tests pass against live glamsterdam-devnet-8:

test gloas_test::beacon_state_hashes_to_the_signed_header ... ok
test gloas_test::execution_header_recovers_the_execution_state_root ... ok
test gloas_test::verifier_accepts_a_real_gloas_update ... ok
test gloas_test::verifier_rejects_tampered_gloas_updates ... ok

test result: ok. 4 passed; 0 failed

The first is the one that matters. It merkleizes the full Gloas BeaconState through the new stack and compares the root against what Nimbus signed, which is the only check here that validates our merkleization against an independent implementation rather than against itself. It also confirms the phantom fields and the StateList newtype leave the root untouched.

The forks pass their own suites (tree_hash 114, ethereum_ssz 128, ssz_types 78) and build for wasm32-unknown-unknown with no_std.

Not yet covered: the pre-Gloas prover suite in test.rs, which needs a local non-Gloas devnet, and the BSC client's tests. BSC is migrated and compiles; it only used Bitvector::deserialize.

Merge order

This depends on the five fork PRs above. The [patch.crates-io] section pins them by revision, and those revisions are branch commits that a squash merge will orphan, so the five need to land first and the pins bumped to the merged SHAs before this can go in.

Comment thread modules/consensus/sync-committee/verifier/src/lib.rs Outdated
@dharjeezy
dharjeezy requested a review from Wizdave97 July 24, 2026 08:43
@@ -459,4 +492,25 @@ pub struct BeaconState<
#[cfg(not(feature = "nofulu"))]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this feature flag no longer useful

Suggested change
#[cfg(not(feature = "nofulu"))]


if !is_merkle_branch_valid {
Err(Error::InvalidMerkleBranch("Execution payload branch".into()))?;
if header.state_root.0 != execution_payload.state_root.0 ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is not necessary here, if we verify the header hash successfully we don't need this check

@Wizdave97

Copy link
Copy Markdown
Member

@dharjeezy track activation on sepolia here: https://forkcast.org/upgrade/glamsterdam/

@dharjeezy
dharjeezy requested a review from Wizdave97 August 27, 2026 14:21
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.

[runtime] Ethereum Glamsterdam Support

2 participants