Skip to content

feat(rpc): honor skip_randao_verification on block production - #678

Merged
MegaRedHand merged 175 commits into
mainfrom
feat/beacon-skip-randao-verification
Oct 27, 2026
Merged

MegaRedHand merged 175 commits into
mainfrom
feat/beacon-skip-randao-verification

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Why

beacon-APIs (v5.0.0-beta.0) defines skip_randao_verification on produceBlockV3 and produceBlockV4 as a flag with no value: the caller sends the point-at-infinity reveal and asks the node not to check it. We accepted the flag on v4 and ignored it, and v3 did not read it, so a request carrying it was refused (v4: 400 invalid randao_reveal, v3: 500 the block failed to build). Lighthouse and Prysm build the block. The assertoor gloas-api-check playbook (patched to v5.0.0-beta.0) hit this: lighthouse and prysm answered 200, ethlambda 400.

What

Request Before After
no flag reveal verified unchanged
skip_randao_verification / skip_randao_verification= + infinity reveal v4 400, v3 500 block built; its state root mixes the unchecked reveal in
flag with a value (=true, ...) ignored 400 skip_randao_verification does not take a value
flag + a reveal other than infinity ignored 400 randao_reveal must be the point at infinity when skip_randao_verification is set

Parsing follows Lighthouse (get_randao_verification), which matches the spec's wording; Prysm instead accepts =true and silently replaces any reveal with infinity.

  • STF: BlockProcessingOptions { randao: RandaoVerification } (stf/block.rs), last parameter of the process_block dispatcher, every fork's process_block and process_randao. Skip only drops the reveal's BLS check; the mix is updated as before. state_transition always passes the default, so import never skips.
  • Production: assemble_block, assemble_gloas_block, assemble_gloas_block_on_bid take the options and forward them.
  • RPC: one shared randao_verification helper in beacon/proposal.rs, called right after the query parses (a 400 on any node, ahead of the engine check). v4 still gives its early invalid randao_reveal when not skipping.

Tests

  • STF: Skip mixes in the infinity reveal; Verify rejects it.
  • Production (pre-gloas and gloas): the infinity reveal builds only with Skip, and re-applying the block with Skip lands on its state_root.
  • RPC v3 and v4: 200 with both flag spellings and the infinity reveal in the returned block; 400 for a value on the flag and for a non-infinity reveal.

Ran: cargo test --profile release-fast --lib for ethlambda-state-transition (548 passed) and ethlambda-rpc (238 passed), clippy -D warnings on both, cargo fmt --check. The beacon spec-test suites were not run locally.

Open question

v3 without the flag still answers 500 for a reveal that does not verify (the STF rejects it), where v4 answers 400 early. Not changed here.

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which adds skip_randao_verification support for block production, matching Lighthouse's API. Let me analyze the changes systematically.

Security & Correctness

Critical: skip_randao_verification bypasses signature verification for block production only, not import

crates/blockchain/state_transition/src/beacon/stf/block.rs:207-246

The design correctly limits RandaoVerification::Skip to block production paths. The state_transition function at line 155 hardcodes BlockProcessingOptions::default(), ensuring all imported blocks verify RANDAO. This is the correct security boundary.

However, I notice a potential issue in the test at crates/blockchain/state_transition/src/beacon/block_production.rs:469-516:

fn skip_randao() -> BlockProcessingOptions {
    BlockProcessingOptions {
        randao: RandaoVerification::Skip,
    }
}

This helper creates options with only randao set, but BlockProcessingOptions may gain more fields in the future. The #[derive(Default)] on RandaoVerification means BlockProcessingOptions::default() is the safe choice for production. The explicit struct literal here is fine for tests but could be brittle if fields are added. Consider using BlockProcessingOptions { randao: RandaoVerification::Skip, ..Default::default() } or ensure BlockProcessingOptions stays a single-field struct.

Verification: Infinity point validation in RPC layer

crates/net/rpc/src/beacon/proposal.rs:284-302

pub(super) fn randao_verification(
    skip: Option<&str>,
    randao_reveal: &BlsSignature,
) -> Result<RandaoVerification, ApiError> {

This correctly validates that skip_randao_verification only works with G2_POINT_AT_INFINITY. The logic:

  • None → Verify
  • Some("") or Some("=") → Skip (but only if reveal is infinity)
  • Some(other) → error

Wait—looking more carefully at the test in crates/net/rpc/src/beacon/builder_market_tests.rs:1259-1276:

for flag in ["&skip_randao_verification=", "&skip_randao_verification"] {

And in crates/net/rpc/src/beacon/proposal.rs:288-295:

Some("") => {
    if randao_reveal.0 == bls::G2_POINT_AT_INFINITY {
        Ok(RandaoVerification::Skip)
    } else {
        Err(ApiError::BadRequest(...))
    }
}

The Some("") case handles ?skip_randao_verification= (empty string value). But what about ?skip_randao_verification with no =? Serde's Option<String> with #[serde(default)] will parse this as Some("") too? Let me verify...

Actually, looking at serde_urlencoded behavior: ?skip_randao_verification (no =) typically deserializes to Some("") for Option<String>. The test confirms this works. But this is subtle—add a comment explaining this serde behavior.

API Consistency Issue

assemble_gloas_block_on_bid lacks infinity reveal validation

crates/blockchain/state_transition/src/beacon/gloas_block_production.rs:367-399

pub fn assemble_gloas_block_on_bid(
    state: &BeaconState,
    inputs: GloasBidBlockInputs,
    config: &Config,
    options: stf::block::BlockProcessingOptions,
) -> Result<BeaconBlock> {

Unlike assemble_block and assemble_gloas_block, this function doesn't validate that when skip_randao() is used, the reveal is actually infinity. The validation happens in the RPC layer's randao_verification(), but assemble_gloas_block_on_bid is a public function that could be called directly.

Compare with prepare() in crates/net/rpc/src/beacon/gloas_proposal.rs:610-682 which does validate:

if randao == RandaoVerification::Verify {
    verify_randao_reveal(&state, proposer, randao_reveal)?;
}

But assemble_gloas_block_on_bid itself doesn't check. If someone calls it directly with skip_randao() and a real signature, they'd produce an invalid block. Consider adding a debug assertion or validation in the assembly functions themselves, or document that validation is the caller's responsibility.

Actually, looking more carefully—the assemble_* functions don't take randao_reveal directly; they take BlockInputs/GloasBlockInputs which contain the reveal. The options parameter is separate. So the caller could pass skip_randao() with a real reveal. This is the same pattern as assemble_block and assemble_gloas_block.

The test at line 1260 in gloas_block_production.rs tests assemble_gloas_block with infinity, but not assemble_gloas_block_on_bid. Consider adding a similar test for assemble_gloas_block_on_bid.

Code Quality & Maintainability

BlockProcessingOptions should use #[non_exhaustive] or be pub(crate)

crates/blockchain/state_transition/src/beacon/stf/block.rs:209-213

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BlockProcessingOptions {
    pub randao: RandaoVerification,
}

This is pub and not #[non_exhaustive]. If you add fields, this is a breaking change for external callers. Given this is a consensus client, external API stability matters. Consider:

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct BlockProcessingOptions {
    pub randao: RandaoVerification,
}

Or add a constructor: impl BlockProcessingOptions { pub const fn new() -> Self { ... } }

Duplicate skip_randao() test helper

Both block_production.rs:469 and gloas_block_production.rs:780 define similar helpers. Consider extracting to a shared test utility, though this is minor.

randao_verification function naming

crates/net/rpc/src/beacon/proposal.rs:284

The name randao_verification returns a RandaoVerification, but the function name is a noun phrase, not a verb. Consider parse_randao_verification or determine_randao_verification for clarity.

Error Handling

Inconsistent error messages for infinity reveal without flag

In crates/net/rpc/src/beacon/proposal.rs, when skip_randao_verification is not set and the reveal is infinity:

  • For fulu (V3): Returns StatusCode::INTERNAL_SERVER_ERROR (from assemble_block failing)
  • For gloas (V4): Returns StatusCode::BAD_REQUEST with "invalid randao_reveal"

Wait, let me re-check. In builder_market_tests.rs:1273-1276:

let reply = send(&app, produce_request_with(infinity, "")).await;
assert_eq!(reply.status, StatusCode::BAD_REQUEST);
assert_eq!(reply.json()["message"], "invalid randao_reveal");

And in proposal.rs the test at line 665:

let (status, _) = respond(app, Request::get(uri).body(Body::empty()).unwrap()).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);

This inconsistency is because the fulu path goes through assemble_block which fails with a generic error, while the gloas path has explicit validation in verify_randao_reveal. The gloas path gives a better error. Consider making the fulu path also return BAD_REQUEST with a clear message.

Actually, looking at the gloas code in gloas_proposal.rs:660-662:

if randao == RandaoVerification::Verify {
    verify_randao_reveal(&state, proposer, randao_reveal)?;
}

And verify_randao_reveal returns ApiError::BadRequest("invalid randao_reveal"). But in the fulu path (proposal.rs), the RANDAO verification happens inside assemble_block → process_block → process_randao, which returns a generic Result::Err that becomes INTERNAL_SERVER_ERROR.

This is a real inconsistency in user-facing error behavior. The fulu path should pre-validate like gloas does, or the STF error should be mapped to a better HTTP status.

Performance

No performance concerns. The changes add:

  • One BlockProcessingOptions parameter pass-through (zero-cost)
  • One enum comparison in process_randao (negligible)
  • Optional skip of BLS verification (saves time when used)

Testing

Good coverage

  • Unit tests for process_randao with both Skip and Verify
  • Integration tests for both fulu and gloas block production
  • RPC-level tests for flag parsing and error cases
  • Test that produced block imports correctly with same options

Missing test: assemble_gloas_block_on_bid with infinity reveal

As noted above, no test covers assemble_gloas_block_on_bid with skip_randao(). Add one in bid_assembly_tests.

Minor Issues

#[allow(dead_code)] removed but field still somewhat unused

crates/net/rpc/src/beacon/gloas_proposal.rs:205-208

#[serde(default)]
skip_randao_verification: Option<String>,

The field is "used" for deserialization but not directly accessed—it's passed to randao_verification(). The comment explains this well.

super::super::validator_client_tests::fake_execution_client()

crates/net/rpc/src/beacon/proposal.rs:636

This path is awkward. The pub(super) visibility added in the diff suggests this is a test utility leaking across module boundaries. Consider a cleaner test helper organization.

Summary

Priority Issue Location
Medium Inconsistent HTTP error status for invalid infinity reveal (fulu: 500, gloas: 400) proposal.rs vs gloas_proposal.rs
Low assemble_gloas_block_on_bid lacks infinity reveal test gloas_block_production.rs
Low BlockProcessingOptions should be #[non_exhaustive] stf/block.rs:209
Low randao_verification naming proposal.rs:284
Nit super::super:: test import path proposal.rs:636

The security-critical design is correct: state_transition always verifies, production paths can skip with proper validation. No critical bugs found.


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Looks good overall. The change is narrowly scoped, preserves the default verification path, and adds good tests around the new flag behavior.

  • No correctness bug stood out in fork choice, attestation flow, justification/finalization, or SSZ/XMSS handling from this diff.
  • The security-sensitive part is the new RANDAO bypass. It is constrained to explicit block-production options and the RPC layer only allows it for the point-at-infinity reveal, which is an important safety guard in crates/net/rpc/src/beacon/proposal.rs:285 and reused by gloas at crates/net/rpc/src/beacon/gloas_proposal.rs:243.
  • Performance impact is negligible; this mostly adds a branch and avoids one BLS verify only when explicitly requested.

Findings

  • No blocking issues found.

Notes

  • The split between request parsing and state transition is clean: RandaoVerification defaults to safe behavior in crates/blockchain/state_transition/src/beacon/stf/block.rs:213, and callers must opt into Skip.
  • assemble_block now computes state_root under the same processing options it was built with, which is necessary for consistency when skip_randao_verification is enabled in crates/blockchain/state_transition/src/beacon/block_production.rs:313.
  • The pre-validation in the RPC layer avoids returning opaque internal errors for bad caller input, especially in crates/net/rpc/src/beacon/proposal.rs:336 and crates/net/rpc/src/beacon/gloas_proposal.rs:241.
  • Readability is solid; comments clearly explain why the infinity reveal is the only allowed bypass case.

If you want, I can turn this into a ready-to-post GitHub review comment.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 678, honor skip_randao_verification on block production

I read the diff only and did not build it or run the tests. I found no blocking issues.

What it does

  • Adds BlockProcessingOptions { randao: RandaoVerification }, with Verify as the default, and threads it through every per-fork process_block, the assemble_* functions and the RPC producers.
  • state_transition and block import pass BlockProcessingOptions::default(), so consensus validation is unchanged. The new option is reachable only through block production.

Correctness and security

  • Skipping is guarded in randao_verification (proposal.rs). It is accepted only for the exact G2_POINT_AT_INFINITY reveal and only for the value-less flag form. A real reveal sent with the flag gets a 400, so a caller cannot get an unverified reveal mixed into a produced block by mistake.
  • The flag is parsed ahead of the engine check, so a bad flag is a 400 on any node, as the comments describe.
  • process_randao still mixes in hash(reveal) when skipping. The produced state_root therefore matches what an importer gets from the same reveal, and the tests check this by re-running process_block with the same option.
  • A block produced with the infinity reveal is not valid on the network. That is the intended Lighthouse-compatible behavior, because the caller is expected to replace the reveal.
  • In gloas_proposal.rs, prepare skips verify_randao_reveal only under Skip. The same options reach both assemble_gloas_block and assemble_gloas_block_on_bid, including their empty-attestation retry paths, so the two stay consistent.

Minor notes (non-blocking)

  1. proposal.rs: #[allow(clippy::too_many_arguments)] on produce works, but a small params struct would be tidier if this function grows again.
  2. process_randao computes the signing root and looks up the proposer even when skipping. This is harmless. Moving that work inside the Verify branch would save a little, but keeping it makes the proposer-index lookup still fail on a bad state, so I'd leave it.
  3. BlockProcessingOptions is a public API in state_transition, and its doc says import uses only the default. A debug assertion or a pub(crate) constructor for non-default values would make that harder to misuse later. This is optional.

Tests
Coverage is good. It includes the unit tests for process_randao, assembly across fulu and gloas, and RPC tests for both flag forms (= and bare), the rejection of a value or a real reveal, and the no-flag 400/500 behavior.

The change looks good to merge.


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

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

Also brings #669's sync with beacon-chain-integration (#645, #675).

Semantic resolutions:
- #665's `active_balances` and #678's `options` both kept on every
  `process_block`, `options` last; `assemble_*` keep tmp's inputs
  (operations, sync aggregate) plus `options`.
- fork_choice `CountingView` test impl takes #658 as it landed on bci
  (forwards `contains`).
- #678's v3 tests use tmp's fixtures: `SharedSyncCommitteePool`,
  `OwnVersion`, `fake_execution_client(0)`.
- p2p scoring test sets `SwarmConfig::agent_version` (#675).
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.
Every gloas runner that has a counterpart at earlier forks already ran
through a temporary allow-list, so the fork gate itself was the only thing
still holding the rest of the tree back. Declare gloas implemented by
raising HIGHEST_IMPLEMENTED_FORK, and remove the allow-list, its check in
case_trial, and the per-case handler field that only served it.

The custody-group networking suites now run for gloas too. Gloas gossip
vectors stay ignored on purpose: the node validates fulu's gossip rules
only, and the gossip runner's comment now says so.
Audit every match and comparison over the per-fork enums so a fork is never
handled by a rule written for an earlier one. The sites that mattered were on
live paths:

- A subnet attestation decoded with `fork >= Electra`, so on a gloas network an
  honest vote (whose `data.index` carries the payload flag) decoded as electra's
  and was rejected, penalizing the peer. It now answers `UnsupportedFork`, and
  the attestation topic maps that to `Ignore`, as the aggregate topic does. A
  handler test pins each topic's ignore arm, since deleting either would
  penalize honest gloas peers again with nothing failing.
- The pool endpoints accepted `Eth-Consensus-Version: gloas` and then applied
  electra's rules and container to it, and the aggregate query would have
  labelled a gloas slot's electra-shaped aggregate as gloas. Both refuse gloas
  by name.
- `range_needs_columns`, `fixed_proposer` and the test harness's gossip gate
  now name their forks, so the next fork has to be placed in each.
- `get_head_node` built a fresh committee cache per call on the gloas arm. It
  runs every slot from the fork epoch, so it now uses the store's shared cache.

Also drop references to how the work was scheduled from three doc comments,
fix the six intra-doc links that failed `rustdoc::broken_intra_doc_links`, and
correct the doc comments that undersold or misattributed what they describe.
…nts, building on bids (#669)

## Motivation

Gloas makes payload building a market: builders gossip signed bids on
`execution_payload_bid`, proposers gossip signed `proposer_preferences`
(fee recipient, gas target) so builders can bid for their slots, and a
proposer may build on the best bid instead of its own payload. Our node
subscribed to neither topic, served none of the endpoints (lighthouse
and prysm validator clients already call `POST
/eth/v1/validator/proposer_preferences` and got a 404), and always
self-built. This adds the node side of the market. Stacked on #667.

## Changes

| Piece | What |
|---|---|
| Gossip | `execution_payload_bid` and `proposer_preferences` under
gloas digests: every rule of gloas `p2p-interface.md`, seen caches,
their own validation permit pool, relayed on accept. Never queued, never
sent to the chain actor. |
| State | One `SharedBuilderMarket` shared by p2p and rpc: proposer
preferences per proposal slot, the best bids per slot and parent, and
the known execution payloads the bid rules check parents against (gossip
envelopes plus the node's own). Bounded and pruned. |
| Endpoints | `POST /eth/v1/beacon/execution_payload_bids` (JSON or SSZ,
gossip-validated, published), `POST
/eth/v1/validator/proposer_preferences` (verified, cached, published),
`POST /eth/v1/beacon/states/{state_id}/builders` (filterable builder
registry). |
| `produceBlockV4` | Honors `BuilderConfig`: the best gossiped bid
competes with the local build under `min_bid` and `builder_boost_factor`
(local wins ties, `shouldOverrideBuilder` respected). A winning bid
returns the block only (no envelope, `Eth-Execution-Payload-Included:
false`); the builder releases the envelope. A bid that fails to assemble
falls back to the local build. |
| Self-build | From gloas, the fee recipient and gas target come from
the proposer's signed preferences, then `prepare_beacon_proposer`, as
beacon-APIs requires. |
| Types | `Builder` integers are quoted in JSON (they were bare, also in
the gloas state JSON); preferences deserialize. |

Phase 2 (not here): the builder API (bids fetched from
`BuilderConfig.builders` URLs, `POST
/eth/v1/validator/builder_preferences`, forwarding the block to the
winning builder). That route is a 404 for now.

## Deviations (in `docs/spec_deviations.md`)

- Preference signatures are accepted under the spec's lookahead-state
domain and the schedule's fork at `P - MIN_SEED_LOOKAHEAD` and at `P`,
which coincide outside a fork's first epoch: lighthouse signs with the
fork at `P`, the spec with `P - 1`, and refusing either would drop
honest preferences at the gloas boundary.
- Stateful checks read cached states only (a miss is IGNORE), and the
head fast path serves preferences whose dependent block's state was
evicted.
- A pre-gloas parent's payload counts as known.

## Validation

- Spec gossip vectors v1.7.0-beta.2 mainnet:
`gossip_execution_payload_bid` 45/45, `gossip_proposer_preferences`
27/27 (previously ignored). Minimal not run locally.
- Unit tests for the market, every rule (including fulu-parent boundary
cases and the domains), assembly on a bid (`process_block` accepts it),
topics, decode, verdict settle, publish, bid selection, `BuilderConfig`,
and router tests driving every endpoint and `produceBlockV4` against a
fake execution client.
- `--lib`: state-transition 544, types 221, p2p 312, rpc 234, engine 27,
validator 269, blockchain 236, storage 152; clippy and fmt clean.
Base automatically changed from feat/beacon-gloas-builder-market to fix/beacon-foreign-vc-endpoints October 26, 2026 16:23
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.
…rification

Brings in main (the gloas stack) through the parent, which also now holds
#669 as a squash. Merged against that squash as the base, since this
branch already carries feat/beacon-gloas-builder-market, whose tree the
squash matches: no conflicts.
@MegaRedHand
MegaRedHand added this pull request to stack #702 October 26, 2026 19:03
# Conflicts:
#	crates/net/rpc/src/beacon/gloas_proposal.rs
#	crates/net/rpc/src/beacon/pool.rs
#	crates/net/rpc/src/beacon/proposal.rs
#	crates/net/rpc/src/beacon/ptc.rs
#	docs/rpc.md
# Conflicts:
#	crates/net/rpc/src/beacon/gloas_proposal.rs
# Conflicts:
#	CLAUDE.md
#	bin/ethlambda/src/main.rs
#	crates/blockchain/state_transition/src/beacon/gossip/mod.rs
#	crates/blockchain/state_transition/tests/beacon_spec/gossip.rs
#	crates/net/p2p/src/beacon/decode.rs
#	crates/net/p2p/src/beacon/verdict.rs
#	crates/net/p2p/src/gossipsub/handler.rs
#	crates/net/p2p/src/gossipsub/mod.rs
#	crates/net/p2p/src/lib.rs
#	crates/net/p2p/src/req_resp/handlers.rs
#	crates/net/rpc/src/beacon/gloas_proposal.rs
#	crates/net/rpc/src/beacon/mod.rs
#	crates/net/rpc/src/lib.rs
#	docs/metrics.md
#	docs/rpc.md
# Conflicts:
#	crates/net/rpc/src/beacon/gloas_proposal.rs
#	crates/net/rpc/src/beacon/proposal.rs
… version

Block production reads the sync committee pool (#668) and the node's own client version (#638) from request extensions; the builder market tests build their own router and had neither, so every produced block answered 500.
# Conflicts:
#	crates/net/rpc/src/beacon/builder_market_tests.rs
Base automatically changed from fix/beacon-foreign-vc-endpoints to main October 27, 2026 13:37
@MegaRedHand
MegaRedHand added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit fda4fc7 Oct 27, 2026
9 checks passed
@MegaRedHand
MegaRedHand deleted the feat/beacon-skip-randao-verification branch October 27, 2026 15:01
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.

2 participants