Skip to content

feat(beacon): sync committee support in the node and the validator client - #668

Open
MegaRedHand wants to merge 139 commits into
mainfrom
feat/beacon-sync-committee
Open

MegaRedHand wants to merge 139 commits into
mainfrom
feat/beacon-sync-committee

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

Neither ethlambda beacon nor ethlambda validator took part in sync committees. Other clients' validator clients on our node got a 404 for every sync duty (seen on the fulu->gloas devnet behind #667), our validator client had no sync duties at all, and every block we produced carried an empty sync aggregate, so every sync committee member lost its reward in our slots.

Stacked on #662 and merges feat/beacon-api-missing-endpoints (#642) for POST /eth/v1/validator/duties/sync/{epoch}; the #642 commits disappear from this diff once it lands.

Beacon node

Piece What
Gossip sync_committee_{subnet_id} (joined on demand from sync_committee_subscriptions, until until_epoch, carried across fork digests, advertised in MetaData syncnets) and sync_committee_contribution_and_proof (already subscribed, now validated instead of ignored). Every altair rule, with seen caches and their own validation permit pool so the 1/3-slot burst does not starve attestations.
Pool One SharedSyncCommitteePool filled by gossip and the API: messages by (slot, root, subcommittee) position, best contribution per key.
Blocks Block N packs, for (N-1, parent_root), the best contribution per subcommittee extended with pooled messages at uncovered positions, verified exactly as process_sync_aggregate checks it (else the empty aggregate). Fulu and gloas; every retry-without-operations path drops it too.
Endpoints POST /eth/v1/beacon/pool/sync_committees, GET /eth/v1/validator/sync_committee_contribution, POST /eth/v1/validator/contribution_and_proofs, POST /eth/v1/validator/sync_committee_subscriptions (JSON, beacon-APIs shapes, IndexedErrorMessage on partial failure).

Validator client

Sync duties per period (current and next), subnet subscriptions re-sent every epoch, a message per validator at SYNC_MESSAGE_DUE_BPS (_GLOAS from gloas) over the head root (skipped when the head is optimistic), selection proofs and contributions at CONTRIBUTION_DUE_BPS, run concurrently with attestations. New ethlambda_validator_sync_* metrics.

Deviations (in docs/spec_deviations.md)

  • Gossip picks the committee by the message's slot (slot + 1's period) against the cached head state, and the domain from the fork schedule, so an honest message is not refused while the head lags a period or fork boundary.
  • Subnets are joined on request only, immediately, with no ENR syncnets (ethrex's discovery server cannot replace the served record at runtime).
  • Blocks pack only (slot - 1, parent_root), contributions extended with direct messages.
  • The validator client signs at the deadline only, not early on a block's arrival.

Validation

  • Unit tests per piece (helpers, rules, pool, packing, topics, verdicts, subnets, endpoints, VC service, signing, clock, duties).
  • Spec gossip vectors gossip_sync_committee_message and gossip_sync_committee_contribution_and_proof, mainnet: 46 passed (fulu and gloas, including valid_at_period_boundary). Minimal not run locally.
  • End to end (validator_client_tests.rs, fulu and gloas): the real validator client against the real router covers all 512 seats on all 4 subnets, contributions with every bit, and the next block carries a full sync aggregate that process_sync_aggregate accepts.
  • --lib: p2p 318, rpc 195, state-transition 501, validator 299; clippy and fmt clean.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

I didn’t spot a consensus-critical bug in the sync-committee flow overall; the design is careful about re-validating pooled aggregates before block packing. I do see one correctness/perf issue worth fixing.

  • crates/net/rpc/src/beacon/sync_committee.rs:332 computes the subscription clamp from wall_slot(&store) instead of the chain head/state period. That means a machine with a skewed local clock can accept or truncate until_epoch differently from the actual head period the validator duties were derived from. In the worst case, a slightly fast clock can clamp away part of the next-period subscription and cause missed sync gossip right at the boundary. This endpoint should derive the clamp from the same head state / head period used for sync duties, not wall time.

  • crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs:138 rebuilds aggregate signatures by cloning all message signatures into a Vec on every contribution() / sync_aggregate() read. That is probably acceptable at current subnet sizes, but it is on the proposer critical path and scales with pooled messages. If this becomes hot, consider caching the best merged contribution per (slot, root, subnet) when inserts happen rather than recomputing during block production.

  • crates/net/rpc/src/beacon/validator.rs:116 keeps duplicate validator indices from the request and will emit duplicate duty entries. That is not unsafe, but it is a small API-quality issue and unnecessary work; deduping the parsed indices would make the endpoint more robust.

  • crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs:365 allocates a Vec of participant pubkeys before eth_fast_aggregate_verify. That is standard and fine, but this path is attacker-facing gossip validation. If profiling shows pressure here, a no-allocation/stack-backed path would be worth considering.

Everything else I checked looks sensible:

  • sync committee selection is keyed off message slot rather than stale head period, which avoids rejecting honest messages across period/fork boundaries.
  • block production verifies the final SyncAggregate again before inclusion, which is the right safety backstop.
  • subnet joins are bounded and aggregated by subnet, which is a good guard against RPC abuse.

Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: sync committee support (PR 668)

I read the core of the PR: the gossip rules, the pool, the p2p subnet management, the Beacon API handlers and verified_sync_aggregate. I skimmed or skipped the validator-client service, the DTOs and the docs. I did not build or run the tests.

The design is sound and the rules follow the altair p2p spec closely. The departures are deliberate and documented. Findings are minor.

Correctness

  • Committee selection (helpers/sync_committee.rs:42). sync_committee_for_slot keys on the period of slot + 1, which matches the spec's "signs at S for inclusion at S+1". A message from the last slot of a period maps to next.

    • slot + 1 can overflow on u64::MAX. Every current caller checks is_current_slot first, so it is unreachable today. check_message and check_contribution are pub, though. A saturating_add would remove the reliance on call order.
  • Block packing (block_production.rs, verified_sync_aggregate). Re-verifying the pooled aggregate against the block's own pre-state and falling back to the empty aggregate is the right safety net. It makes a bad pooled signature cost rewards instead of the block.

    • candidate.sync_committee_bits.get(position).unwrap_or(false) is fine here. Both sides use the committee-sized bitvector.
    • This step costs one eth_fast_aggregate_verify over up to 512 pubkeys on the proposal path. Please confirm it runs off the async executor.
  • Pool combining (sync_committee_pool.rs:118-164). A held contribution is merged with individual messages only at uncovered positions, so no signer is counted twice. This is correct. A validator holding several seats gets one signature per bit, as the spec requires.

    • The Err(_) => return held.cloned() fallback returns None when nothing was held, even though messages exist. That is acceptable, because the comment says everything pooled was verified.
  • Seen-cache race (gossip/sync_committee.rs:125). SeenSyncContributions::record re-runs the verdict so only the first of two concurrent validations records. The message-side cache has no such guard, but insert_message keeps the first signature per position, so the outcome is the same.

Performance and robustness

  • Mutex held across BLS work. SyncCommitteePool::contribution and sync_aggregate run bls::aggregate while the std::sync::Mutex is held:

    • get_sync_committee_contribution in rpc/src/beacon/sync_committee.rs:205 does this on an async task.
    • Block production calls sync_aggregate the same way, with up to four aggregations.
    • Concurrent gossip verdicts then block on insert_* for the whole aggregation, and the async task blocks an executor thread.
    • Suggestion: clone the minimal inputs (signatures and bits) under the lock, drop it, then aggregate. Alternatively, wrap the GET handler's pool call in spawn_blocking.
  • Unbounded batch size. post_pool_sync_committees and post_contribution_and_proofs verify one to three BLS signatures per item, with no cap on the batch length. They run on spawn_blocking, so the executor is safe. A large body can still tie up a blocking thread. A max batch size, or a body-size limit if the router doesn't already set one, would be cheap insurance. This is the same posture as the attestation endpoint, so it is low priority.

  • Pool memory. Pool entries are keyed by beacon_block_root, which is not checked against known blocks. Only validated messages get in, and the pool keeps 4 slots of 512 members, so growth is bounded. No action needed.

Idioms and nits

  • join() in p2p/src/beacon/sync_committee.rs:126 pushes to joined from inside an or_insert_with closure. It works, but a match on Entry would read more plainly.
  • describe() and batch_response are clean.
  • Tests are thorough. They cover the seat, period and subnet edge cases, metadata_seq_number moving only on change, and the clamp on until_epoch. I found no gaps worth flagging.
  • CLAUDE.md and the docs are updated to match. The warn! about advertising cgc without sync subnets became an info!, which fits the new behavior.

Verdict: looks good. I'd address the lock-held-during-BLS point before merge, and the rest is optional.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR implementing sync committee support in ethlambda. This is a substantial feature addition spanning gossip validation, pooling, block production, validator client duties, and Beacon API endpoints.

High-Level Assessment

The PR implements Altair sync committee functionality (messages, contributions, aggregation) with proper integration into gossip validation, block production, and validator client duties. The architecture follows existing patterns in the codebase (attestation pool, gossip split into cheap/stateful checks, permit pools).


Critical Issues

1. Race condition in SyncCommitteePool::insert_message — incorrect bounds check logic

File: crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs
Lines: 78-82

if subcommittee >= SYNC_COMMITTEE_SUBNET_COUNT as u64
    || position >= SYNC_SUBCOMMITTEE_SIZE
{
    continue;
}

Issue: The position >= SYNC_SUBCOMMITTEE_SIZE check is wrong. SYNC_SUBCOMMITTEE_SIZE is the size of one subcommittee, but position is the index within the entire sync committee (0..511 for mainnet). The correct check should be position >= preset::SYNC_COMMITTEE_SIZE.

However, looking more carefully at how seats are constructed in sync_committee_seats (line 119-126 of helpers/sync_committee.rs), position is index % SYNC_SUBCOMMITTEE_SIZE — the position within the subcommittee. So the check is actually correct for the intended semantics.

Wait — re-reading: In sync_committee_seats, the returned positions are:

  • subcommittee = position / SYNC_SUBCOMMITTEE_SIZE
  • position_within = position % SYNC_SUBCOMMITTEE_SIZE

So position in insert_message is indeed the within-subcommittee position. The check is correct. ✅

2. Potential panic in verified_sync_aggregate — saturating_sub with slot 0

File: crates/blockchain/state_transition/src/beacon/block_production.rs
Lines: 131-132

let previous_slot = state.slot().saturating_sub(1);
let Ok(block_root) = get_block_root_at_slot(state, previous_slot) else {

Issue: For genesis slot (slot 0), previous_slot is 0, and get_block_root_at_slot(state, 0) should return the genesis block root. However, the spec's process_sync_aggregate is only called from Altair onward, and slot 0 in Altair would be unusual. More critically, if state.slot() is 0, we're asking for the block root at slot 0, which is correct (genesis block root is stored at slot 0 in block_roots).

Actually, re-checking: get_block_root_at_slot returns the block root at the given slot from the state's block_roots array. For slot 0, this is valid. The saturating_sub is defensive but the case shouldn't occur in practice since sync committees start at Altair.

3. Missing sync_committee_bits SSZ serialization fix

File: crates/common/types/src/beacon/containers/altair.rs
Lines: 90-91

The SyncAggregate struct changes from:

#[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")]

to:

#[serde(with = "crate::beacon::serde_helpers::ssz_hex")]

This is a critical fix — the old code could only serialize but not deserialize sync_committee_bits. The new with attribute enables both directions. This is necessary for the Beacon API to accept SyncAggregate in JSON. ✅ Good fix.


Security Issues

4. BLS signature verification bypass in verified_sync_aggregate

File: crates/blockchain/state_transition/src/beacon/block_production.rs
Lines: 109-152

pub fn verified_sync_aggregate(state: &BeaconState, candidate: SyncAggregate) -> SyncAggregate {
    // ...
    if bls::eth_fast_aggregate_verify(
        &participants,
        signing_root,
        &candidate.sync_committee_signature,
    ) {
        candidate
    } else {
        empty_sync_aggregate()
    }
}

Issue: This re-verifies the aggregate signature, but it does not verify that candidate.sync_committee_bits matches the actual participants. A malicious or buggy pool could set bits for non-participants and provide a valid signature for a different set of participants.

However, looking at the call sites: the pool's sync_aggregate method only sets bits for which it has verified signatures. And verified_sync_aggregate is called on the result. The risk is if someone calls verified_sync_aggregate directly with a crafted SyncAggregate.

Mitigation: The function should verify that the number of set bits equals participants.len(), or more precisely, that no extra bits are set beyond what the signature covers. Currently, if bits are set for positions not in participants, the signature won't verify (since participants is derived from the bits), so this is actually safe — the participants vector is built from the bits, so extra bits would require extra pubkeys in participants, making the signature invalid.

Actually wait — re-reading: participants is built from candidate.sync_committee_bits. If bit i is set, pubkey i is included. The signature is verified against exactly those pubkeys. This is correct: you can't set a bit without including the corresponding pubkey in the verification. ✅

5. No verification that sync_committee_bits count matches signature participants in pool

File: crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs
Lines: 178-205

In contribution, when extending a held contribution with messages:

for (position, signature) in messages.into_iter().flatten().enumerate() {
    let Some(signature) = signature else { continue };
    if contribution.aggregation_bits.get(position).unwrap_or(true) {
        continue;
    }
    // ...
}

Issue: The unwrap_or(true) on line 185 is dangerous. If position is out of range for aggregation_bits, it defaults to true (skip), which silently drops valid signatures. This should be unwrap_or(false) or an explicit bounds check with error.

Actually, position comes from enumerating messages which is Vec<Option<BlsSignature>> of length SYNC_SUBCOMMITTEE_SIZE (set in insert_message line 84). So position is always in range. The unwrap_or(true) is defensive but unreachable in correct operation.

However, if messages is from a different source or corrupted, this could drop signatures. Consider expect or debug_assert! instead.


Correctness Issues

6. Wrong epoch computation in sync_committee_for_slot

File: crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs
Lines: 50-64

pub fn sync_committee_for_slot(state: &BeaconState, slot: Slot) -> Result<&SyncCommittee> {
    let (current, next) = state.sync_committees()?;
    let signing_period = compute_sync_committee_period(compute_epoch_at_slot(slot + 1));
    let state_period = compute_sync_committee_period(get_current_epoch(state));
    // ...
}

Issue: The spec's compute_subnets_for_sync_committee uses state.slot + 1 to determine the committee, but here it's slot + 1 where slot is the message's slot. The comment says this is intentional (a deviation documented in spec_deviations.md), but let's verify:

For a message at slot S to be included in a block at slot S+1, the signing committee is determined by S+1. The spec's get_sync_subcommittee_pubkeys takes state and subcommittee_index, and uses state.slot + 1 implicitly through get_sync_committee_for_slot(state) which uses state.slot.

The deviation: using the message's slot rather than state.slot + 1. This is correct for the stated reason (head lagging across boundary), but the function name and docs should be very clear this is a deviation.

✅ Properly documented in spec_deviations.md and code comments.

7. compute_sync_committee_period uses wrong preset constant

File: crates/blockchain/state_transition/src/beacon/helpers/altair.rs
Lines: 184-189

pub fn compute_sync_committee_period(epoch: Epoch) -> u64 {
    epoch / preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD
}

Wait — this is correct per spec. But let me check if EPOCHS_PER_SYNC_COMMITTEE_PERIOD is defined in the right preset. Looking at ethlambda_types::beacon::preset, this should be EPOCHS_PER_SYNC_COMMITTEE_PERIOD which is 256 for mainnet. ✅

8. Missing check for SyncCommitteeContribution slot matching in pool

File: crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs
Lines: 98-115

In insert_contribution:

pub fn insert_contribution(&mut self, contribution: SyncCommitteeContribution) -> bool {
    self.prune_before(contribution.slot.saturating_sub(RETAINED_SLOTS));
    if contribution.subcommittee_index >= SYNC_COMMITTEE_SUBNET_COUNT as u64 {
        return false;
    }
    // ...
}

No verification that the contribution's signatures actually match the claimed aggregation_bits. This is by design — the pool only accepts pre-validated contributions. But the function is pub, so external callers could insert invalid contributions.

Recommendation: Add debug_assert! or document the invariant more prominently. The current doc comment says "Only objects that already passed validation go in", which is sufficient if adhered to.


Performance Issues

9. Inefficient pubkey lookup in test helper

File: crates/blockchain/state_transition/src/beacon/block_production.rs
Lines: 718-724 (in pooled_aggregate)

let index = (0..64)
    .find(|&index| state.validator(index).unwrap().pubkey == pubkey)
    .expect("the committee is drawn from the registry");

This is test-only code, but it's O(validators) per committee member. For tests with small validator sets this is fine. Not a production issue.

10. SyncCommitteePool uses Mutex not RwLock

File: crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs
Line: 39

pub type SharedSyncCommitteePool = Arc<Mutex<SyncCommitteePool>>;

The pool is read by block production and the contribution endpoint, and written by gossip/Beacon API. A RwLock would allow concurrent reads. However, the operations are fast (BTreeMap lookups), and the attestation pool uses the same pattern (Arc<Mutex<_>>). Consistency with existing code is reasonable.


Rust Best Practices

11. Unnecessary Clone bound in test

File: crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs
Lines: 535-543

let mut bits = <SyncCommitteeContribution as Clone>::clone(&SyncCommitteeContribution {
    slot: SLOT,
    // ...
});

This is unnecessarily verbose. SyncCommitteeContribution::clone(&default_value) or just constructing directly would be cleaner. Actually, this is constructing a base then modifying it. Could use ..Default::default() syntax if SyncCommitteeContribution derives Default.

Looking at the type, it does derive Default. So this could be:

let mut bits = SyncCommitteeContribution {
    slot: SLOT,
    beacon_block_root: root(),
    subcommittee_index: subcommittee,
    ..Default::default()
};

Minor style issue, test-only.

12. Inconsistent error handling for poisoned locks

File: crates/net/p2p/src/beacon/sync_committee.rs
Lines: 376-379

let mut pool = server.sync_committee_pool.lock().expect("sync committee pool lock poisoned");

This panics on poison. Some places use .unwrap_or_else(|e| e.into_inner()) to recover. The attestation pool uses expect consistently, so this is consistent. ✅

13. Flexible deserializer accepts negative numbers

File: crates/net/rpc/src/beacon/sync_committee.rs
Lines: 312-340

impl<'de> Deserialize<'de> for Flexible {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        // ...
        fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Flexible, E> {
            Ok(Flexible(value))
        }
        fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Flexible, E> {
            value.trim().parse().map(Flexible).map_err(E::custom)
        }
    }
}

visit_i64 is not implemented, so negative numbers would fail. But visit_str with "-1" would parse to u64 and fail with a parse error. Actually, str::parse::<u64>() on "-1" returns Err. So this is safe. ✅


Consensus-Critical Issues

14. Fork choice rule for sync committee — no issue, correctly absent

Sync committee messages have no fork-choice weight. The PR correctly does not send them to the chain actor (see verdict.rs lines 250-275, and test at line 1407-1428 confirming received.try_recv().is_err()). ✅

15. Empty aggregate uses point at infinity — spec compliance

File: crates/blockchain/state_transition/src/beacon/block_production.rs
Lines: 100-107

pub fn empty_sync_aggregate() -> SyncAggregate {
    SyncAggregate {
        sync_committee_bits: SyncCommitteeBits::default(),
        sync_committee_signature: BlsSignature(bls::G2_POINT_AT_INFINITY),
    }
}

The spec requires the empty aggregate to have the point at infinity as signature. ✅ Verified by test at line 795-801.

16. Period boundary handling in sync_committee_for_slot

File: crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs
Lines: 50-64

At the last slot of a period, slot + 1 is in the next period, so next_sync_committee is used. This is correct per spec. The test at lines 268-283 verifies this. ✅


Documentation and Maintainability

17. Typo in CLAUDE.md

File: CLAUDE.md
Line: 337 (approximate)

"Gloas digests add two more topics," — should this be "Global digests" or is "Gloas" intentional? Looking at context, "Gloas" is the fork name, so this is correct. ✅

18. Inconsistent comment about syncnets in ENR

File: docs/beacon_wire.md
Lines: 639-642

The doc says "the ENR has no syncnets entry" but also mentions cgc is advertised. The PR correctly does not add syncnets to ENR (runtime modification not supported). ✅


Summary

Category Count Notes
Critical 0 No critical security bugs found
Security 0 All signature verification paths are correct
Correctness 0 Spec deviations are documented and intentional
Performance 1 Could use RwLock for pool, but follows existing pattern
Style/Rust 2 Minor test code issues
Documentation 0 Clear and comprehensive

Recommendations (Non-Blocking)

  1. Add debug_assert! for position bounds in SyncCommitteePool::contribution (line 185) to catch any invariant violations in testing.

  2. Consider RwLock instead of Mutex for SharedSyncCommitteePool if read contention becomes measurable.

  3. The unwrap_or(true) in contribution at line 185 could be expect since it should be unreachable — or document why true (skip) is the safe fallback.

  4. Test verified_sync_aggregate with mismatched bits/signature — add a test where bits claim more participants than the signature covers, to ensure it returns empty aggregate.

Overall Assessment

Approve with minor suggestions. The PR is well-structured, thoroughly tested, correctly implements the spec with documented deviations, and follows the codebase's architectural patterns. The sync committee integration is complete from gossip through block production to validator client duties.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

MegaRedHand added a commit that referenced this pull request Oct 6, 2026
…633-636-638-gloas-live

Both the sync committee (#668) and the builder market sides are carried
through every shared surface: P2P::spawn, P2PServer, BeaconApiHandles,
RecordingNetwork, the Validated enum and its matches, the gossip reason
enums, the spec gossip runner (no bid, preferences or sync handler is
ignored) and the docs. Sync and builder gossip each validate on their own
permit pool.

Non-obvious resolutions:
- GloasBidBlockInputs gains sync_aggregate and assemble_gloas_block_on_bid
  uses it. assemble_on_bid verifies the pooled aggregate for
  (slot - 1, parent_root) against the block's pre-state and falls back to
  the empty aggregate on every retry path, as the self-build does.
- produce() runs the local build and the execution client version lookup
  concurrently, each skipped when no engine is configured, and the bid
  selection reads the pooled sync candidate.
- Test fixtures follow tmp: no attestation pool extension, the sync pool and
  OwnVersion layers in the builder market app, BuiltGloasPayload and Prepared
  initializers with the builder market fields, and the ActiveBalanceCache
  argument of gloas process_block.
- New test: a block built on a bid carries the pooled sync aggregate.

Known failure: gossip::execution_payload_bid::tests::
a_bid_across_an_epoch_uses_and_caches_the_checkpoint_state fails. Its scene
has finalized_checkpoint.epoch 1 at slot 32, and tmp's get_finality_delay now
errors when the finalized epoch is past the previous epoch, so advancing the
scene to epoch 2 returns StateUnavailable. The fixture state is unreachable
on a real chain; left unchanged pending a decision.
@MegaRedHand MegaRedHand added the beacon Ethereum Beacon Chain client label Oct 6, 2026
MegaRedHand added a commit that referenced this pull request Oct 19, 2026
A catch-all arm compiles unchanged when a fork is added and quietly
applies an older fork's rule; with every variant named, the next fork
fails to build at each site that has to decide.
Gloas's progressive state lists are written element by element, the same
way the Beacon Chain validator registry already is through `SszList`.
lambdaclass/libssz#37 gives `ProgressiveList` the `DerefMut`/`Index`/
`IndexMut` that lambdaclass/libssz#33 gave `SszList`, so both containers
now support the same in-place access.

Moves the workspace's libssz git pin from 36802dd (0.3.0 plus
lambdaclass/libssz#33) to 5cb1437, the head of lambdaclass/libssz#37's PR
branch. That rev also carries lambdaclass/libssz#35 (`as_chunks` in the
merkleize fold loop) and lambdaclass/libssz#36 (CI only) from libssz's
main. Every beacon spec fixture case still passes on both presets with the
same pass/ignore counts as before the bump, so lambdaclass/libssz#35
changed no hash_tree_root.

The pin points at an unmerged PR branch head for now. It must move to
lambdaclass/libssz#37's merge commit before this branch merges, since a PR
branch ref stops being fetchable once the branch is deleted.
Gloas (EIP-7688) turns the validator registry into an EIP-7916
progressive SSZ list: no fixed limit, merkleized as a chain of
balanced subtrees holding 1, 4, 16, ... chunks. The registry needs the
same persistent-tree properties List already gives Validators and
Balances: buffered writes, per-subtree hash caching, and cheap rebase
onto a resident state.

ProgressiveList gets there by keeping one existing Interface per
subtree, at heights 0, 2, 4, ..., rather than inventing new tree
machinery. Every List property (buffered writes via UpdateMap,
OnceLock hash caching, Arc-shared rebase) holds per subtree unchanged;
only the O(log n) chain above them, one hash_nodes per subtree, is
new. This is a pure data-structure addition: nothing constructs a
Gloas registry yet.

Verified against libssz_types::ProgressiveList (the Vec-backed
reference) by a proptest model over random push/set/apply sequences
for packed (u64), composite (Item), and variable-size (Blob)
elements; boundary cases at every subtree edge, including one element
past each newly-opened subtree; rebase proptests mirroring List's
(a base with fewer subtrees, one with more, and equal-shaped lists
sharing a common prefix); a deterministic case for a base whose
pending push has just opened a subtree with nothing committed yet
(rebase must not adopt it, and must reclaim it once applied); iter_from
at a subtree boundary, mid-subtree, and past the end; and decode-error
parity with the reference, including one that pins a fixed-size decode
loop to reporting the first bad element rather than a later one.

Mainnet-scale bench (2.4M validators, ProgressiveList<Validator,
BTreeMap<usize, Validator>> vs. a validators-only List<Validator, ...>
baseline built in the same test for a like-for-like comparison,
release-fast, one sequential run):
  build                                224.4 ms  (List: 177.3 ms)
  memory for one resident state        696 MiB   (List: 696 MiB)
  cold hash (first root)              1233.3 ms  (List: 1226.2 ms)
  1000 scattered writes + apply + root    3.4 ms
  independent copy of the same data    110.6 ms
  rebase that copy onto the written state 169.9 ms
(cargo test -p ethlambda-types --test tree_bench --profile release-fast
-- --ignored --nocapture --test-threads=1; the two #[ignore]d tests in
that file share a process-wide allocation counter, so --test-threads=1
is required for either one's memory figure to mean anything.)
Move the Beacon Chain spec-test fixtures from consensus-specs v1.6.1 to
v1.7.0-beta.2, and take the BLS/KZG vectors from ethereum/cryptography-specs
v0.1.0 instead of consensus-spec-tests' `general` config: consensus-specs
stopped shipping them there as of v1.7.0-alpha.13 (ethereum/consensus-specs#5398),
moving what was left of `general` (BLS) and KZG's cell/proof vectors into
their own release. The v0.1.0 vectors are byte-identical to v1.6.1's, so
every BLS and KZG case still passes unchanged.

At this commit the suite is knowingly red on four groups the version bump
surfaced, all fixed by the commits that follow:

- `networking/gossip_*`: a new gossip-validation fixture format the release
  now ships for every fork phase0-fulu (893 minimal / 904 mainnet failures).
  Not modeled anywhere in this crate; fixed by merging
  beacon-chain-integration's own gossip validation and running these vectors
  against it.
- Five new `ssz_static` containers this crate has no Rust type for yet:
  `NewPayloadRequest` (bellatrix through fulu) and `PartialDataColumnSidecar`,
  `PartialDataColumnPartsMetadata`, `PartialDataColumnHeader`,
  `PartialDataColumnGroupID` (fulu only) (1107 minimal / 45 mainnet
  failures).
- A real phase0 fork-choice rule change (diffed against v1.6.1): a block's
  proposer-score boost now also requires its shuffling-dependent root to
  match the pre-block head's, and `is_head_weak`/`get_proposer_head` gained
  an equivocation-aware branch (19 minimal / 1 mainnet failures).
- `transition/fulu/core/pyspec_tests/transition_with_deposit_request_right_after_fork`:
  a state-root mismatch, not yet root-caused (1 failure on both presets).

Counts at this commit (passed/failed/ignored):
- test-beacon-minimal --lib: 246/0/1 (unchanged from v1.6.1)
- test-beacon-minimal beacon_spec_tests: 38688/2020/3692
- test-beacon-mainnet --lib: 245/0/1 (unchanged from v1.6.1)
- test-beacon-mainnet beacon_spec_tests: 5823/951/152
From gloas on, the registry (validators, balances) moves to a tree-backed
ssz_tree::ProgressiveList in place of ssz_tree::List, so no single accessor
type can return "the list" for every fork. Element-level reads and writes
(validator, balance, iter_validators, iter_balances, push_validator, ...)
answer identically whichever list type backs a given fork's state, so this
removes validators()/validators_mut()/balances()/balances_mut() from
BeaconState in favor of them, ahead of gloas's own containers landing.

bci's own callers (gossip validation, the validator client, p2p, rpc) already
reached the registry through the single-element accessors this keeps, so
merging bci needed no further migration beyond what this cherry-pick already
carries.
A catch-all arm compiles unchanged when a fork is added and quietly
applies an older fork's rule; with every variant named, the next fork
fails to build at each site that has to decide.

A sweep of every Rust file bci changed (gossip validation, precheck,
the validator client, p2p, rpc) turned up exactly one such gate:
HttpBeaconNode::aggregate_attestation accepted any fork not older than
electra, so a fork added after fulu would silently be parsed as an
electra-shaped aggregate. Named explicitly, matching the reject-pattern
ProducedBlock::from_ssz already uses in the same crate. Every other
catch-all in bci's code turned out not to be this kind of gate: the
`_ =>`/`other =>` arms found either reject on a type other than
ForkName/BeaconState/SignedBeaconBlock, are single- or dual-fork
projections that already reject every other variant by construction,
or dispatch on Lean-vs-every-beacon-fork where the non-Lean behavior
does not vary by fork. Those are left as they are.
…ners

consensus-specs v1.7.0-beta.2's ssz_static suite covers five containers this
crate did not model: NewPayloadRequest (bellatrix through fulu, one type per
fork whose own ExecutionPayload shape differs, since the field's type is that
fork's payload) and fulu's PartialDataColumnSidecar,
PartialDataColumnPartsMetadata, PartialDataColumnHeader, and
PartialDataColumnGroupID (gossipsub's Partial Message Extension wire types,
from fulu/partial-columns/p2p-interface.md). Types only: nothing in the node
uses them yet, and the state transition does not call the execution engine or
speak partial columns.

ssz_static failures for these handlers go from 1107 (minimal) / 45 (mainnet)
to zero on both presets.
v1.7.0-beta.2's main consensus-spec-tests release now ships the
networking/gossip_* vectors that used to live only in a v1.7.0-beta.1
pre-release, so the gossip runner that beacon-chain-integration gained with
gossip validation no longer needs a tree of its own: drop the separate
Makefile target, CI step, and .gitignore entry, and collect gossip_beacon_block/gossip_data_column_sidecar cases from the
same tree every other runner reads.

Every gossip_* handler and fork the tree ships that beacon::gossip does
not validate yet (everything pre-fulu, plus attestations, slashings,
sync committee messages, blob/partial-column sidecars, voluntary exits,
BLS-to-execution changes) is now named and ignored rather than left for
the tree walk to silently never mention, with a discovery test that
fails if a future release adds a gossip_* handler this file does not
list.
Gloas bundles EIP-7732 (ePBS: the execution payload moves out of the block
into a builder-signed envelope, revealed and attested to a slot after the
block that bid for it), EIP-7688 (progressive SSZ: most lists and every
"big" container become unbounded and EIP-7495 progressive containers
instead of a fixed shape), EIP-8045/8061/8282 (a gas limit schedule and
builder deposit/exit requests). This lands the preset constants, spec
constants, and every gloas SSZ container the types crate needs; nothing
reads them yet (ForkName::Gloas wiring is a follow-up commit).

The validator registry and balances stay tree-backed, on a new
ethlambda_ssz_tree::ProgressiveList (mirroring List's element-level
accessors) rather than switching to libssz_types' plain, Vec-backed one:
a derived state still needs to share unchanged registry subtrees with its
parent, and a block still needs to rehash only the records it touched.
Every other progressive list is libssz_types::ProgressiveList /
ProgressiveBitlist, now that the pinned libssz rev carries DerefMut /
IndexMut on both.

Every container was cross-checked against consensus-spec-tests'
ssz_static/*/ssz_random/case_0 fixtures (decode, hash_tree_root, and
re-encode-matches-input) on both mainnet and minimal, through a throwaway
test removed before this commit: all 30 gloas-defined containers, plus
all 38 containers gloas reuses unmodified from earlier forks, for the 68
non-LightClient handlers the fixture tree lists. Field order and SSZ
attributes were also checked against libssz's own alpha.13 reference
model (~/libssz/spec-tests/src/types/gloas.rs), with field sets taken
from the beta.2 spec text where the two disagree.

Two gaps found against that alpha.13 reference, both confirmed from the
beta.2 spec text and covered by the fixture cross-check: NewPayloadRequest
has no gloas variant there (added here, since ExecutionPayload's shape
does change), and PartialDataColumnSidecar / PartialDataColumnPartsMetadata
/ PartialDataColumnGroupID are reused from fulu there, but gloas's own
partial-columns/p2p-interface.md modifies all three (CellsBitList becomes
progressive, PartialDataColumnSidecar drops its header field entirely,
PartialDataColumnGroupID gains slot), so this commit gives gloas its own
versions instead.
Gloas is a new ForkName, between fulu and lean, so every existing
fork-keyed match (BeaconState, SignedBeaconBlock, ForkName itself) gains
a Gloas arm. Per the spec-port convention, no match grows a `_` catch-all
for it: naming every variant explicitly is what makes the next fork after
gloas break the build instead of silently defaulting somewhere. Most of
these arms are `Error::UnsupportedForFork` (or the local not-a-Result
idiom: a panic, a `None`, or a decode refusal), since the gloas state
transition, fork choice, p2p decode, and validator client integration are
all follow-up work; a few (execution_payload_timestamp, genesis_anchor_block,
the container-level registry accessors) already have a real gloas answer
because the container itself is enough to give one.

The validator registry needed its own abstraction rather than a Gloas arm
on the existing `dispatch_state!` macro: gloas's registry is EIP-7688's
progressive, unbounded list (ProgressiveValidators/ProgressiveBalances)
rather than every earlier fork's bounded, tree-backed List. Accessors that
hand back an *element* (validator_count, validator, balance, push_validator,
validators_root, apply_pending_mutations) still typecheck against both list
kinds unchanged, since ProgressiveList mirrors List's element-level API. The
few that hand back the *list itself* cannot: `registry`/`registry_mut` now
return a `Registry`/`RegistryMut` enum over the two kinds, and
`iter_validators`/`iter_balances` return a private `RegistryIter` wrapping
either list's own iterator type. `rebase_on` and `validators_ptr_eq` match
same-kind pairs and no-op (or return false) across a fork boundary, since
there is no shared tree to reuse between a bounded and a progressive
registry anyway.

Config gains the gloas fork version/epoch, its timing deadlines
(attestation/aggregate/sync-message/contribution *_GLOAS, payload_due_bps,
payload_attestation_due_bps), its validator churn quotients (EIP-8061;
these are the validator registry's own churn, not builders' -- see
`min_builder_withdrawability_delay` for the one field that is), and
min_builder_withdrawability_delay. DB_VERSION becomes 5 because Config is
SSZ-encoded under Metadata["config"], so widening it changes what a
directory's stored bytes decode into: an existing data directory has to be
re-synced, the same as any prior DB_VERSION bump. HEZE_* and EIP8321_*
(each its own later fork schedule), GAS_LIMIT_SCHEDULE, and heze's own data
keys (INCLUSION_LIST_DUE_BPS and its three siblings, plus
CONFIRMATION_BYZANTINE_THRESHOLD, the Fast Confirmation Rule) are still
unclaimed and still reported as ignored keys.

This also hardens every place a gloas message or anchor can reach this
build from the network, beyond the bare wiring above:
- `fork_choice::get_forkchoice_store` refuses a successfully decoded gloas
  anchor state outright: checkpoint sync decodes one structurally today
  (the container exists, so nothing about the bytes themselves fails), and a
  loaded network scheduling GLOAS_FORK_EPOCH: 0 reaches the same decoded
  anchor through `genesis_anchor_block`; either way, without this check the
  node would boot a store it can never advance past. The two startup call
  sites keep that reason distinguishable from a genuinely mismatched anchor
  pair (`CheckpointSyncError::UnsupportedFork` vs `AnchorPairingMismatch`)
  rather than collapsing both into one message.
- Gossip validation now `Ignore`s a gloas-shaped message instead of
  `Reject`ing it: a `Reject` down-scores the sending peer in gossipsub, and
  every honest peer sends exactly this once this node's own clock reaches
  gloas, so scoring it would punish the whole mesh for a gap in this build.
  Covers beacon blocks (`gossip::block::cheap_checks`), aggregates and
  attester slashings (`p2p::beacon::decode`'s new `DecodeError::UnsupportedFork`).
  Data column sidecars decode first, unlike the other two: gossip topic
  subscriptions are frozen at startup, so a node running across the boundary
  stays on its fulu-digest topic where a late but legitimate fulu sidecar can
  still arrive, and a clock check ahead of the decode would drop that one
  too. Only on a decode failure does `triage_data_column` consult the wall
  clock (`decode::current_fork`, an approximation of the topic's own fork,
  which is not threaded down to the handler today) to decide `Ignore` vs
  `Reject`.
- `process_or_pend_block` refuses a gloas block once, before persisting it:
  without this, a gloas block whose parent was missing would be tracked as
  pending, and every later block naming it as an ancestor would walk back,
  fetch it out of storage, and requeue it -- paying the clone-parent-state
  round trip again on every single delivery, forever, since nothing ever
  marks it imported. This also makes `data_availability_for`'s and
  `fork_choice::block_operations`'s own gloas arms unreachable from network
  input; both are left as explicit, harmless refusals rather than
  `unreachable!()`, in case a future change reintroduces a path to them.
  Range sync itself does not loop on the same slot range: its own
  `beacon_fetched_through` bookkeeping advances on delivery, not on import.
  It will, however, keep fetching (and discarding) every new gloas-era block
  a peer produces for as long as this build cannot import any of them --
  that is a real, unbounded-over-time cost this change does not address,
  since deciding whether range sync should stop advancing past a fork this
  build cannot process at all is a product decision, not a wiring one.
- Startup now warns explicitly when `gloas_fork_epoch` is scheduled
  (`network::warn_if_gloas_scheduled`, shared by `BuiltInNetwork::resolve`
  and a loaded `NetworkDir`), since the ignored-keys warning no longer
  implies it now that GLOAS_* keys are claimed. Sepolia's own boundary log
  line (`beacon::wire_params`) is now a `warn!` and no longer suggests a
  restart crosses it. In practice this build's Sepolia follower stops making
  progress at the first gloas-era block: gossip already refuses it via
  `Ignore`, and `process_or_pend_block` refuses it again before persisting,
  so `upgrade_state`'s own epoch-boundary refusal is the fallback for an
  empty-slot epoch crossing, not the usual path.

Notes on how the change fits the code it lands on:
- There are more fork-keyed matches than a first survey finds (gossip
  validation, the validator crate, p2p column checks, committee-cache
  changes, block production); every one the compiler or a search found got
  a named Gloas arm, including the two wildcard arms in block production.
- `max_request_payloads` was already a Config field before this change, and
  was never reported as ignored, so it is not added here.
- `registry()` already returned `(&Validators, &Balances)` uniformly; this
  commit replaces it with the `Registry` enum, plus a `registry_mut`/
  `RegistryMut` pair. The pair is needed because `rebase_on` matches self's
  registry kind against a distinct `base` value's kind and needs both
  mutably and immutably at once.
- `bin/main.rs`'s `a_genesis_anchor_block_hashes_to_the_states_own_header_at_every_fork`
  test chains `upgrade_state` across every `ForkName::ALL` member; since
  `upgrade_to_gloas` does not exist yet, the loop now stops before Gloas
  (asserting `upgrade_state` still refuses it, so the assertion itself
  starts failing the day that changes), leaving `genesis_anchor_block`'s own
  gloas arm uncovered there until then.
- `gossip/every_handler_is_accounted_for`'s IGNORED_HANDLERS list gained
  gloas's four new gossip topics (execution_payload_bid,
  execution_payload_envelope, payload_attestation_message,
  proposer_preferences), which only became visible once gloas's fixture
  directory started parsing; the list is alphabetized throughout.
- `UNMODELED_FORKS` drops "gloas" (now parses); "heze" stays.

Nothing on the node's own paths panics on a gloas boundary: fork digest,
next-fork-boundary, and ENR computation are Ord/list-based rather than
exhaustive matches and handle Gloas today; checkpoint sync decodes a gloas
state through the new `BeaconState::from_ssz` arm.
… in fulu

transition/fulu/core/pyspec_tests/transition_with_deposit_request_right_after_fork
failed because process_deposit_request set deposit_requests_start_index for a
fulu block, differing from the reference implementation's post-state root.

v1.7.0-beta.2 gave fulu's beacon-chain.md three "Modified" sections our port
never picked up (v1.6.1's fulu spec did not have them, per the tag diff):
process_operations, process_deposit_request, and process_pending_deposits all
drop the former eth1-bridge deposit mechanism's last bookkeeping.
process_deposit_request no longer sets deposit_requests_start_index the first
time a request is seen; process_pending_deposits no longer gates a deposit
request behind "every eth1-bridge deposit ahead of it has drained"
(deposit_requests_start_index compared against eth1_deposit_index).

Our fulu block and epoch processing called electra's shared functions for
both, so process_deposit_request kept writing the field. Fixing only that
write without also dropping process_pending_deposits' gate would have
regressed every later fulu deposit request: with the field permanently
UNSET_DEPOSIT_REQUESTS_START_INDEX, electra's gate
(eth1_deposit_index < deposit_requests_start_index) is true forever, wedging
the pending-deposits queue. Both functions, plus process_operations for
spec-text fidelity (it reaches the same verdict as electra's shared version
on any state that finished the electra transition, but the spec explicitly
redefines it), now have their own fulu copies.

Two spec-test harness handlers (epoch_processing.rs's "pending_deposits" and
operations.rs's "deposit_request") still called electra's function for every
fork, including fulu; they passed only because every fixture case that
exercises either handler starts with deposit_requests_start_index already
set, so electra's gate and index-setting were no-ops for them. Routed both to
the fulu functions on ForkName::Fulu, with an explicit arm per fork. Added
unit tests exercising the one case the fixtures do not: an UNSET start index
plus a deposit-request-sourced pending deposit, processed by fulu's function
and stalled forever by electra's.

Rebased onto the gloas fork variant (1a6829e4): the two spec-test harness
matches this cherry-pick added on ForkName (epoch_processing.rs's
"pending_deposits", operations.rs's "deposit_request") now each name a
Gloas arm returning Error::UnsupportedForFork, matching every other
handler's own gloas refusal in these files.
The fixture bump to consensus-specs v1.7.0-beta.2 left 19 minimal and
1 mainnet `fork_choice` cases failing: `fork-choice.md` changed between
v1.6.1 and beta.2 for phase0 (inherited by every later fork through fulu)
and again for fulu itself, while this module still implemented the v1.6.1
algorithm.

Ported, diffing v1.6.1 against beta.2 (gloas/heze/fast-confirmation.md out
of scope):

- `update_proposer_boost_root` (phase0 `fork-choice.md`): the boost is now
  gated on the new block sharing the pre-import head's proposer shuffling,
  via `get_shuffling_dependent_root`/`compute_shuffling_dependent_slot`/
  `compute_shuffling_lookahead_start_slot` (`MIN_SEED_LOOKAHEAD`). `on_block`
  computes that pre-import head (`compute_head`, split out of `get_head`)
  only when the block is timely and unopposed, since those are cheap and
  already decide most blocks (a synced block is never timely), before the
  computation `compute_weights`' own documentation calls out as expensive at
  scale.
- `get_attestation_score` (phase0): split out of `get_weight`, the
  vote-only half of the weight with no proposer boost, which `is_head_weak`/
  `is_parent_strong` now call directly instead of going through `get_weight`.
- `is_head_weak` (phase0): now also adds the effective balance of every
  equivocating validator in the head slot's committees (bci's
  `CommitteeCache`, not a fresh shuffle), so the weight it reads is
  monotonic under later equivocation evidence.
- `is_parent_strong` (phase0): takes the head root rather than the parent
  root and derives the parent internally, matching the spec's own
  `store.blocks[root].parent_root`.
- `is_proposer_equivocation` (phase0, new): whether more than one block is
  known for a root's own (slot, proposer); scans the in-memory block index
  for same-slot candidates before paying for `get_signed_block`, rather than
  the spec's whole-history scan.
- `get_proposer_head` (phase0, modified again in fulu/EIP-7917): gains the
  `head_weak and current_time_ok and proposer_equivocation` re-org branch.
  Fulu drops the `shuffling_stable` requirement; kept as one function whose
  `shuffling_stable` folds to `true` from fulu on (read off the head block's
  own fork, not `Config::fork_at_epoch`, since the minimal-preset spec-test
  config never schedules a fork away from `FAR_FUTURE_EPOCH`) rather than
  two near-identical copies.

`should_override_forkchoice_update` (bellatrix) is deleted outright, along
with its harness check: it is not merely relocated at `v1.7.0-beta.2`, it is
gone from the specification entirely (checked the whole tree, including
`fast-confirmation.md`), and it never had a caller of its own.

Also, from review:

- `get_ancestor`-based walks used to fail past a non-genesis (checkpoint-
  synced) anchor once the current epoch was close enough to the anchor's own
  that a shuffling-dependent slot fell below it: the anchor's own
  `parent_root` is real history this store never held, so the walk stepped
  into a gap instead of stopping. `get_shuffling_dependent_root` now stops at
  the lowest indexed ancestor instead (regression test with a non-genesis
  anchor), which is what the import benchmark's `replay.rs` hit on its very
  first block, since it sets the clock to that block's own slot before
  importing it.
- Every fallible step of the new proposer-boost logic (only `compute_head`
  itself) now runs before `on_block` mutates the store; `record_block_timeliness`
  and `update_proposer_boost_root` are infallible once the anchor-walk fix
  above lands, so nothing after the block joins the store can turn into an
  `Err`.
- `on_block` now returns early on an already-known root
  (`Store::has_block`), the specification's own guard; the chain actor's own
  `has_state`-based dedup at the call site is unchanged and still the one
  that matters on that path.
- `CLAUDE.md` and `docs/beacon_stf.md` both still credited the deleted
  `should_override_forkchoice_update` among what the mainnet `fork_choice`
  fixtures cover; updated both sentences to name the proposer-boost
  dependent-root gate and `get_proposer_head`'s equivocation branch instead.

`get_proposer_head`/`is_head_weak`/`is_parent_strong` have no caller outside
this module and its test harness today, but `on_block`'s proposer-boost
gating and `get_head`'s head selection run on every live beacon import, so
this changes head selection and proposer boost on the live node as well as
in the spec suite.

fork_choice suite: 19 minimal + 1 mainnet failures -> 0 (328/328 minimal,
152/152 mainnet). Lib unit tests (32, six new: `is_proposer_equivocation`
true/false, the new `is_head_weak`/`get_proposer_head` equivocation
branches, the anchor-walk regression), `make fmt`, clippy on both presets,
and `cargo build -p ethlambda-blockchain`/`-p ethlambda` all pass.

Rebased onto the gloas fork variant (1a6829e4) and F6 (the fulu deposit-request
fix, ee7e9283/15002399): the cherry-pick applied without conflicts, since
1a6829e4's gloas refusals in this module (get_forkchoice_store's anchor
refusal, block_operations' and on_block's own Gloas arms) sit in code this
change never touches, and it adds no new exhaustive ForkName/BeaconState
match of its own (only one non-exhaustive `>= ForkName::Fulu` comparison in
get_proposer_head's shuffling-stable fold).

Fixed during integration, found by a livelocked workspace test run
(ethlambda-blockchain's `a_childs_fan_out_does_not_livelock_a_held_parent`
spun at 100% CPU): on_block's new early-return guard checked
`Store::has_block`, matching the specification's `block_root in
store.blocks` literally, but this store's `blocks` table does not mean what
the specification's does. The specification's `store.blocks` only gains a
root at the very end of `on_block`, once a post-state exists; this store
writes a held block's row (one row for a beacon block, through
`Store::insert_pending_block`) before import, while it is parked waiting for
missing data columns, without the `LiveChain` entry that marks a block
admitted. Guarding on `has_block` therefore answered `Ok(())` for a
held-but-never-imported root without importing it, so every child naming it
as an ancestor found it "already known" and gave up without importing
anything either, forever. The guard now checks `Store::has_state`, which is
what `store.blocks` membership actually maps to here; `lib.rs`'s own comment
on its parallel `has_state` dedup check is updated to match. Added
`on_block_imports_a_block_already_persisted_with_no_post_state`
(`fork_choice.rs`), which persists a block the way `hold_block_for_columns`
does (`insert_pending_block`: readable by root, no `LiveChain` entry, no
post-state), builds it a real BLS-signed, correctly-rooted successor to a
genuine anchor pair, delivers it timely so `on_block`'s pre-import
`compute_head` call also runs, and asserts `on_block` still imports it.
Gloas's state transition is not implemented yet, so HIGHEST_IMPLEMENTED_FORK
stays at Fulu and every Gloas fixture case is still marked ignored by
default. But the fork's containers already exist (containers/gloas.rs), and
ssz_static only exercises encoding, hash_tree_root, and decoding, none of
which needs a state transition. Turning its Gloas cases on now is free
coverage that catches a container mistake (field order, a wrong list kind,
a missing progressive_container attribute) long before the state transition
that depends on those shapes is written.

GLOAS_RUNNERS names the runners allowed to run their Gloas cases ahead of
HIGHEST_IMPLEMENTED_FORK reaching Gloas; case_trial consults it alongside
Case::in_scope. Only ssz_static is in it for now, so every other runner's
Gloas cases stay ignored exactly as before.

ssz_static's own handler match gains a Gloas arm for every container whose
shape changed (attestations and their aggregates, the block family, the
state, the execution payload, its new-payload request, and the builder's
execution requests), placed ahead of the >= Electra/>= Deneb range arms
they would otherwise fall into, plus unconditional arms for containers
gloas introduces outright (the builder registry and its payment queues,
payload attestations, the execution payload bid and its envelope, proposer
preferences). MatrixEntry and DataColumnsByRootIdentifier are unchanged
from fulu, so their existing arms now also match gloas rather than gaining
a duplicate. No container needed a fix: every Gloas ssz_static case passes
on both presets on the first run.

That is 8364 cases on minimal and 340 on mainnet; LightClient* stays
ignored, as for every earlier fork.
Config::minimal() had copied mainnet's values for four keys instead of
reading configs/minimal.yaml. CONSOLIDATION_CHURN_LIMIT_QUOTIENT is 32 on
minimal, not mainnet's 65_536. Nothing read it before gloas (EIP-8061
introduces it), but gloas's consolidation churn divides by it: at 65_536,
a minimal state's churn limit rounds down to zero and every consolidation
request is dropped. The deposit chain and network ids are minimal's 5
(Goerli's), not mainnet's 1, and the deposit contract address is the
file's own placeholder rather than mainnet's contract.

Every other field of Config::minimal() was checked against
configs/minimal.yaml, and every field of Config::mainnet() against
configs/mainnet.yaml; no other value differs.
Gloas's block and epoch processing need a layer of helpers before any of
it can be ported. ePBS (EIP-7732) separates proposers from builders and
adds a payload timeliness committee. EIP-8045 limits the proposer draw to
unslashed validators. EIP-8061 splits electra's combined activation/exit
churn into independent activation, exit and consolidation limits. This
commit adds those helpers as plain functions in helpers/gloas.rs; no block
or epoch driver calls them yet.

Where gloas modifies an inherited function by a single call, the inherited
function is shared rather than copied, the way pyspec resolves it. Gloas
changes one line of compute_exit_epoch_and_update_churn (its churn limit)
and none of initiate_validator_exit, process_withdrawal_request,
process_consolidation_request or process_registry_updates. So electra's
exit and consolidation cursors pick the churn limit by fork and read
gloas's on a gloas state, through a projection that exposes the four
cursor fields for electra, fulu and gloas. Electra's operations are then
already right on a gloas state as far as churn goes. The proposer
lookahead has the same type in fulu and gloas, so both read it through
one accessor and one get_beacon_proposer_index.

Three shared dispatchers that refused gloas now route it:
get_beacon_proposer_index, initiate_validator_exit_for_fork and
get_next_sync_committee. The activation churn gate in epoch/registry.rs
still refuses gloas, like electra and fulu: it is phase0..deneb's
validator-count cap, and gloas's get_activation_churn_limit is read only
by gloas's own process_pending_deposits.

compute_ptc takes the committee cache: process_ptc_window calls it once
per slot of every epoch, and rebuilding each committee from the registry
costs a full registry scan per committee.

The unit tests check the new draws against the functions the spec says
they generalize. compute_balance_weighted_selection with one draw and
shuffling picks electra's proposer, and the gloas sync committee draw
equals electra's. A gloas exit consumes gloas's exit churn, not
electra's. Both of get_ptc's window branches are covered.
Ports upgrade_to_gloas, initialize_ptc_window and
onboard_builders_from_pending_deposits (gloas fork.md), the irregular
state change that turns a fulu state into gloas's shape at
GLOAS_FORK_EPOCH. EIP-7688 makes the registry-shaped fields progressive;
EIP-7732 replaces the execution payload header with a synthetic self-build
bid and a builder registry, and the payload timeliness committee window is
filled from the fork epoch onward.

onboard_builders_from_pending_deposits is the one path through the
validator deposit contract that creates builders. Its helpers,
add_builder_to_registry and get_index_for_new_builder, are pub in
helpers/gloas.rs because builder deposit requests (EIP-8282) reuse them.

Onboarding runs over the whole pending deposit queue, and every state that
crosses the fork pays for it: the chain actor's checkpoint states and the
column gossip checks, not only block import. The spec's membership tests
read as list scans (a validator pubkey per deposit, a builder pubkey list
recomputed per iteration, is_pending_validator re-verifying signatures),
which is quadratic on a mainnet-sized registry. Here they are a set built
from the queue's own pubkeys, a pubkey-to-index map kept in step with the
builder registry, and a per-entry memo of signature verdicts; each gives
the spec's answer, and the pass is linear.

process_slot gains gloas's one new step: it clears the next slot's
execution payload availability bit, after the state root is cached.

The fork runner now runs for gloas. transition stays off until gloas block
processing exists, since its cases cross into gloas blocks.

The binary's genesis-anchor-block test used to stop at gloas because
upgrade_to_gloas did not exist; it now chains through gloas like every
other fork.
Ports gloas's process_epoch (EIP-7732, EIP-8061) in the specification's
step order and turns on the gloas epoch_processing and rewards suites.

Most gloas epoch steps are textually unmodified, but EIP-7688 moves the
participation lists, the inactivity scores and the pending queues onto
progressive lists, a different Rust type from every earlier fork's bounded
SszList. Both list kinds deref to a plain slice with the same element
type, so the shared implementations serve gloas through narrow views
instead of gloas copies:

- altair_validator_lists returns slices and includes gloas, so altair's
  justification and finalization, rewards and penalties and their deltas
  run unchanged on a gloas state; inactivity_scores_mut gives the element
  writes process_inactivity_updates needs.
- PendingQueueFields gains a gloas arm and moves the consolidation queue
  in and out as a Vec, so electra's process_pending_consolidations and
  apply_pending_deposit serve gloas.
- fulu's process_proposer_lookahead shifts the window through an accessor
  both forks share and picks the proposer draw by fork, since gloas's
  get_beacon_proposer_indices excludes slashed validators (EIP-8045).

Gloas keeps its own process_pending_deposits, which the specification
modifies (the activation-only churn budget of EIP-8061), and its own
process_participation_flag_updates, which replaces the whole list and
needs the fork's concrete list type. process_builder_pending_payments and
process_ptc_window are new.

The justification and finalization dispatcher that fork choice's pulled-up
tip shares gains the gloas arm too. Gloas ships a second
pending_deposits_churn handler, which runs the same process_pending_deposits
against the new churn cap; both handlers dispatch to it.
Ports the withdrawal sweeps, the execution payload bid and the parent
execution payload processing EIP-7732 introduces. A gloas block carries
no payload: it commits to a builder's bid (process_execution_payload_bid),
and the previous slot's payload is applied only once the next block shows
whether it arrived (process_parent_execution_payload and
apply_parent_execution_payload). Withdrawals gain a builder payout sweep
and a builder registry sweep ahead of the validator ones, and
process_withdrawals takes no payload, since the sweep now follows from the
state alone.

apply_parent_execution_payload is where gloas applies the execution-layer
requests: deposits, withdrawals and consolidations, which gloas's
process_operations no longer calls, and the new builder deposit and exit
requests. Gloas leaves the first three unmodified, so electra's and fulu's
processors serve it. They differ only in the list type behind the pending
queues, which is progressive in gloas, and one PendingQueueFields
projection now covers electra, fulu and gloas: slice views and element
pushes for the block-level processors, take and set pairs for epoch
processing. Electra's process_voluntary_exit works on a gloas state the
same way. The pending deposit take and set refuse a gloas state, since
gloas modifies process_pending_deposits and drains that queue itself.

Electra's get_expected_withdrawals is split into
get_pending_partial_withdrawals and get_validators_sweep_withdrawals,
along the specification's own function boundaries, so gloas's modified
get_expected_withdrawals calls them unchanged. withdrawal_address has one
definition, in helpers::capella.

GLOAS_RUNNERS accepts "runner/handler" entries, so a runner can turn its
gloas handlers on one at a time. Enabled here: operations'
execution_payload_bid, withdrawals, parent_execution_payload,
deposit_request, withdrawal_request, consolidation_request,
builder_deposit_request and builder_exit_request.
ePBS (EIP-7732) gives every gloas block two branches that fork choice
weighs separately: empty (no payload revealed, or not attested to as
timely and available) and full (the builder's payload was revealed and
attested to on time). Gloas's fork-choice.md models this with a payload
dimension on ForkChoiceNode and LatestMessage, and with node derivation,
weight and head descent that read it. A pre-gloas node stays a bare root,
one per block.

Nodes are not stored: a (root, PayloadStatus) node is derived on each call
from the block tree, each block's bid and the set of verified payloads,
the way the specification's Store derives it from blocks and payloads.
The gloas get_weight walks every latest message per node, as the
specification writes it, where pre-gloas compute_weights folds bottom-up;
its doc lists what that costs and must change before live use.

is_head_weak and is_parent_strong are not copied: the specification's
gloas versions score a Pending node, which reduces to the pre-gloas,
root-based attestation score, so both call sites use the pre-gloas
functions. The store's block timeliness now holds one verdict per
deadline (attestation and PTC), and it gains the verified payloads and the
two PTC vote maps; pre-gloas code reads the attestation deadline's entry.

The handlers that fill this state (gloas on_block, the payload envelope
and payload attestation handlers, and the gloas anchor) need gloas block
processing, so this commit is checked by unit tests on hand-built stores,
each confirmed to fail on the defect it targets, and by every pre-gloas
fork-choice fixture passing unchanged.
Ports gloas's modified process_operations and the operations it changes.
An attestation's data.index is now the attester's vote on whether the
slot's payload was present, and process_attestation takes the parent
block's slot to tell a same-slot vote from a late one. A same-slot
attester that newly earns a flag adds its effective balance to the
builder's pending payment weight, which epoch processing checks against a
quorum before paying the builder. The new process_payload_attestation
checks the payload timeliness committee's vote on the previous slot's
payload. process_proposer_slashing also clears the slashed proposal's
pending builder payment.

process_operations no longer calls the deposit, withdrawal and
consolidation requests, which apply_parent_execution_payload applies, and
adds the payload attestation loop. EIP-7688 makes every operations list
progressive, so each one's length is checked against its preset maximum
explicitly rather than by an SSZ bound.

Operations the specification leaves unmodified are shared rather than
copied: voluntary exits, BLS-to-execution changes and the sync aggregate
run unchanged; the committee walk behind get_attesting_indices, the
attester slashing tail and the proposer slashing checks are extracted and
called from each fork, since only the list types behind them change.
BeaconState::epoch_participation_mut gives process_attestation element
writes to gloas's progressive participation lists.

Every gloas operations handler now runs. The operations harness reads a
case's own config.yaml when it ships one, and otherwise schedules every
fork up to the case's own at genesis, through a helper it now shares with
the gossip runner: get_ptc is the first operation to read a fork epoch
from the config.
The gloas fork choice in consensus-specs v1.7.0-beta.2 only describes a
store anchored in gloas. get_parent_payload_status reads the parent
block's execution payload bid, which a fulu block does not have, and the
node walks step onto such parents. The gap is known upstream: issue ethereum/consensus-specs#5096
was closed as not planned, and PR ethereum/consensus-specs#5125 (open) would answer the existing
PAYLOAD_STATUS_PENDING for a pre-gloas parent (it describes that as
Lighthouse's behavior; Lighthouse v8.2.2 answers EMPTY). Lodestar and
Prysm treat the pre-gloas block as full.

This follows Lodestar and Prysm. A pre-gloas block is a single fork-choice
node whose payload status is FULL, since its payload was executed inside
the block: it is the parent payload status of the first gloas block, its
only child node, the node every vote for it supports, and its payload
counts as verified, timely and available. The last point is what lets the
first gloas block and payload-present votes for the last fulu block pass
the gloas checks that require a verified parent payload.

get_head_node switches on the current slot's fork rather than the
justified state's: from GLOAS_FORK_EPOCH on, the gloas head runs over the
whole tree, pre-gloas blocks included, the way clients run one walk across
the boundary. Because that makes the gloas head run on live networks from
the fork epoch, it tolerates what the pre-gloas compute_weights already
tolerates: a vote whose ancestor walk leaves the pruned index (its own
block, or a parent below the finalized slot) weighs nothing, in the
root-based attestation score the proposer boost check shares as well, and
a block timeliness entry lost on restart reads as not timely. The first
cannot change the head, since a walk that leaves the index cannot descend
from a scored root; the second can move it for at most one slot right
after a restart. Both are marked as implementation choices beside the
specification text.
Ports gloas's process_block in the specification's order and wires it into
the block dispatch, so the full state transition runs on gloas blocks. A
gloas block carries no payload: it commits to a builder's bid, and the
previous slot's payload is applied by process_parent_execution_payload,
which runs before process_execution_payload_bid replaces the bid it reads.
parent_slot is read from the latest block header first, since
process_block_header overwrites it. process_block needs no execution
engine on this path.

A revealed payload is checked by verify_execution_payload_envelope
(fork-choice.md): the envelope's signature, its consistency with the block
it belongs to and the bid it fulfils, then the engine's verdict. It
mutates nothing. The block header it rebuilds takes the state's cached
state root when the state carries one, since this repository stores
post-block states with that root filled in and the specification's own
states leave it zero; hashing a stored state again would reject every
valid envelope. The signature check reads the proposer's key for a
self-build and the builder's key otherwise.

The sanity, finality, random and transition suites now run for gloas. The
sanity, operations and gossip runners share one case_config: a case's own
config.yaml, with seconds_per_slot derived from SLOT_DURATION_MS, or else
every fork up to the case's own scheduled at genesis. get_ptc reads the
gloas fork epoch from the config, so a runner using the compiled-in
default would reject a gloas case's attestations. Fork upgrades read only
the next fork's epoch, which the fallback never changes, so pre-gloas
cases run as before.

Fork choice still refuses gloas blocks: gloas on_block, the payload
envelope and the payload timeliness committee have no fork-choice handling
yet.
Fork choice had gloas's node model, head and proposer-head functions but no
handler that could put a gloas block, envelope or vote into the store, so
nothing could exercise them end to end. This adds the handlers.

- get_forkchoice_store accepts a gloas anchor and seeds block timeliness, the
  two payload-attestation vote vectors and an empty verified-payload set for
  it. The live follower still cannot follow gloas (nothing delivers envelopes
  or payload attestations), so the refusal moves to startup in main.rs, ahead
  of both store constructions and the import benchmark's, and keeps its
  distinct error.
- on_block handles a gloas block: a block on its parent's full branch needs
  that payload verified, availability is not checked at the block (it gates
  the envelope), the vote vectors are initialized, the block's payload
  attestations reach the store, and the two timeliness deadlines are recorded.
  A failing head computation fails the import, as it does before gloas.
- on_execution_payload_envelope checks data availability against the bid's
  commitments, verifies the envelope, and records the payload as verified. It
  takes the sampled gloas sidecars directly rather than a DataAvailability
  value, so block evidence and envelope evidence cannot be confused; the
  deneb and fulu availability checks now reject the other fork's evidence
  instead of reading it as available.
- on_payload_attestation_message and notify_ptc_messages write a member's vote
  into every committee seat it holds.
- validate_on_attestation and update_latest_messages apply gloas's index
  rules and slot ordering to a gloas attestation and leave earlier forks as
  they were. Attestation and AttesterSlashing gain gloas variants, and
  block_operations reads a gloas block's own attestations and slashings.
- Gloas column sidecars get their own structural and KZG checks, sharing the
  cell batch verification with fulu's.

Which rules apply is decided in one place, ForkRules::of, by an exhaustive
match over forks. The earlier "is this block pre-gloas" tests were negative
matches on the gloas variant, which would have sent any later fork down the
pre-gloas path; they now go through the same decision.

Comments and messages that said the state transition or on_block refuse
gloas blocks are corrected.
The gloas fork-choice handlers had never run against a fixture. The
fork_choice runner now drives them: each case is processed under its own
fork schedule (a shared config that schedules gloas at the far future
epoch would have run the pre-gloas head), and it understands gloas's
execution_payload and payload_attestation_message steps, the head's
payload status, and the payload timeliness and data availability vote
checks. Anchor blocks, attestations and attester slashings decode into
their gloas containers.

The should_apply_proposer_boost cases also name
viable_for_head_roots_and_weights. The runner walks the payload-aware
tree from the justified root the way the reference generator does and
compares each leaf's root, weight and payload status as an unordered set:
the store's children come from a hash map, the generator's from
insertion-ordered dicts. Without it, a wrong weight for the previous
slot's payload decision passed every case.

Every step and check struct now rejects unknown keys, so a check the
runner does not model fails its case instead of being dropped silently.

The harness does not expand a block's payload attestations itself:
on_block already does, and writing the same vote again changes nothing.
MegaRedHand added a commit that referenced this pull request Oct 21, 2026
…ient (#668)

Squash of feat/beacon-sync-committee at 07f0357 (merge-base 55adf1b).

Adapted onto #643-#667: gloas block production packs an empty sync
aggregate, #643's liveness recorder ignores sync committee messages, and
#638's per-validator settings reach the sync duties. The #642 commit this
branch carries is main's already.
MegaRedHand added a commit that referenced this pull request Oct 21, 2026
…nts, building on bids (#669)

Squash of feat/beacon-gloas-builder-market at 194deb0 (merge-base dcc325a).

Adapted onto #646-#668: block production on a bid packs the sync aggregate
and reads the active-balance cache; the market tests layer the sync
committee pool and OwnVersion, and the attestation pool lives in Store.
MegaRedHand added a commit that referenced this pull request Oct 21, 2026
Squash of feat/beacon-skip-randao-verification at 1c36c33 (merge-base 194deb0).

Adapted onto #668: the fork-choice block-production test passes the
processing options.
Main now prunes the beacon LiveChain below the finalized block (#627). A
block can hold the boost while sitting outside the justified subtree (the
boost rule only asks that it share the pre-import head's shuffling
dependent root), and a finalization advance later in the same slot can
prune its parent's index row. The gloas gate then reads that missing
parent and fails the whole head computation until the next slot clears
the boost.

Such a boost changes no comparison the descent makes, so
compute_node_weights now skips it under either fork's rules
(boost_reaches_the_descent) before the gate runs. The walk-bound docs
say why the bound still matters with a pruned index, and the test for a
boosted finalized block goes, since that boost is now skipped. Also drops
#628's second anchor_slot binding in get_forkchoice_store, which shadowed
the one this branch already takes from the anchor block.
ethpandaops re-signed every devnet-8 bootnode ENR on 2026-10-09
(glamsterdam-devnets f271042f, "bump erigon"). Two of the twenty hosts
(168.144.43.157, 64.23.215.76) also rotated their node keys, so the
embedded entries named peer ids those hosts no longer answer to, both
for the static dial and as discv5 seeds. The other eighteen keep their
key, address and ports and only carry a higher seq.

Every refreshed ENR now carries the gloas digest, so the comment on the
fulu digest pin no longer holds for the embedded file. The pin stays;
the comment now points at the upstream revision that published it.
Base automatically changed from feat/beacon-gloas-validator-duties to main October 26, 2026 16:46
Main gained the gloas stack this branch sat on (#634, #635, #639, #662)
plus #663. Merged against #662's squash as the base, since this branch
already carries feat/beacon-gloas-validator-duties, whose tree #662's
squash matches: what comes in is #663 only, without conflicts.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beacon Ethereum Beacon Chain client

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants