Skip to content

fix(rpc): serve the endpoints and body encodings other validator clients use - #667

Open
MegaRedHand wants to merge 141 commits into
mainfrom
fix/beacon-foreign-vc-endpoints
Open

MegaRedHand wants to merge 141 commits into
mainfrom
fix/beacon-foreign-vc-endpoints

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

Running other clients' validator clients against ethlambda beacon on a fulu->gloas Kurtosis devnet (lighthouse v8.3.0-rc.0, teku 26.9.1, nimbus v26.10.0, prysm v7.2.0, lodestar v1.49.0, each driving 77 keys on its own ethlambda node) showed four ways our node turned them away:

VC Symptom Cause
lighthouse every validator "inactive", proposals refused ("Proposer index does not match") no GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}, which it resolves indices with
nimbus node marked incompatible, no duty at all no GET /eth/v1/config/fork_schedule
prysm aggregates and payload votes 400 "invalid request body" it posts them as SSZ
teku gloas envelopes 415, so teku-proposed gloas blocks never got their payload it posts the envelope as JSON

Changes

  • GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}: one entry, by index or pubkey; 404 when unknown.
  • GET /eth/v1/config/fork_schedule: every scheduled fork as a linked {previous_version, current_version, epoch} list (nimbus checks the linking).
  • Request bodies chosen by Content-Type (none means JSON, anything else is a 415, which prysm answers by retrying with JSON):
    • JSON or SSZ lists on POST /eth/v2/beacon/pool/attestations, POST /eth/v2/validator/aggregate_and_proofs, POST /eth/v1/beacon/pool/payload_attestations;
    • JSON or SSZ on POST /eth/v2/beacon/blocks (fulu SignedBlockContents, gloas SignedBeaconBlock) and POST /eth/v1/beacon/execution_payload_envelopes (envelope or envelope contents per Eth-Blob-Data-Included).
  • Deserialize for every container a fulu/gloas block or envelope carries, with the inverse of each serde adapter (oversized lists are refused, not truncated).

Content types per VC, read from their tagged sources:

VC Blocks Envelope Envelope contents
lighthouse SSZ SSZ SSZ
prysm SSZ, JSON on 415 SSZ, JSON on 415 same
teku SSZ, then JSON JSON JSON
nimbus JSON JSON SSZ
lodestar JSON JSON JSON

Validation

  • Unit and router tests for each endpoint and body type (JSON and SSZ give identical results; 415 for other types); JSON round trips of the block, envelope and request containers.
  • Devnet on ethlambda-5, image built from this branch merged into tmp/bci-626-63-64-633-636-638-gloas-live (with fix(rpc): bound duties by the wall clock, advance the head for later epochs #663): fulu genesis, gloas at epoch 5, 1536 validators, ethlambda nodes holding 50% (half of that driven by the five foreign VCs above).
    • Epochs 2 to 12: every validator in every group earned source, target and head rewards.
    • Gloas slots 160 to 470: no missed slot; every block's envelope landed, including the 73 proposed by foreign VCs through our nodes.
  • Left: sync-committee endpoints (separate work), and proposer_preferences / register_validator (builder path), which lighthouse and prysm call and get a 404.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Overall this PR looks solid: the JSON deserialization coverage for Beacon API containers is useful, the new fork_schedule endpoint matches client expectations, and the request-body content negotiation changes are mostly clean.

  • crates/net/rpc/src/beacon/config.rs:77
    fork_schedule() does not enforce monotonic epochs. If a custom network config accidentally sets a later fork at an earlier epoch than a previous scheduled fork, the endpoint will emit an out-of-order linked list that validator clients may treat as invalid or misleading. Since this endpoint is used for compatibility checks, I’d either sort by epoch/version explicitly or reject non-monotonic schedules when building the response.

  • crates/net/rpc/src/beacon/states.rs:214
    ValidatorId::parse() only accepts lowercase 0x prefixes. Beacon API callers sometimes normalize hex more loosely; rejecting 0X... is probably not intended and is inconsistent with many other hex parsers. Low severity, but easy compatibility footgun.

  • crates/net/rpc/src/beacon/proposal.rs:163
    For fulu publishBlockV2, signature verification advances from the current head state instead of the block’s parent state, while the gloas path correctly uses block.parent_root(). That means a valid block extending a non-head parent can be rejected, and worse, proposer selection/signature checks become dependent on local head rather than the declared parent. For consensus code, I’d strongly prefer using the parent state here as well, like post_gloas_block() does.

  • crates/net/rpc/src/beacon/proposal.rs:153
    Related to the above: block.slot() <= head_state.slot() rejects any block not strictly after the local head. That may be intentional API policy, but it’s stricter than “parent is held and block is valid” and can reject valid side-chain/reorg blocks from a validator client. If the goal is gossip+import correctness rather than only local-head publication, this deserves another look.

  • crates/net/rpc/src/beacon/gloas_proposal.rs:808
    publishExecutionPayloadEnvelope waits for the block by root, then validates envelope contents against that imported block. That’s good, but it means the endpoint’s correctness depends on import winning a race within BLOCK_WAIT. If callers can legitimately publish immediately after publishBlockV2, short waits can create flaky 400 unknown block responses under load. Not a correctness bug in the validation itself, but worth checking operationally.

  • crates/common/types/src/beacon/containers/json_tests.rs:9
    The round-trip tests are a good start, but they only prove self-consistency of your serializers/deserializers. They won’t catch schema drift versus the Beacon API JSON shape. A few fixture-based tests with exact expected field names/encodings for the newly deserializable request types would increase confidence, especially around bitfields and quoted integers.

If you want, I can turn Point 3 into a concrete patch recommendation.


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

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which adds JSON deserialization support for beacon containers, SSZ body encoding negotiation, and related Beacon API endpoints. Let me analyze the changes systematically.

Overview

The PR makes three main changes:

  1. Adds serde::Deserialize to many beacon containers and fixes serde field attributes
  2. Implements content-type negotiation (JSON/SSZ) for validator submission endpoints
  3. Adds /eth/v1/config/fork_schedule and /eth/v1/beacon/states/{id}/validators/{validator_id} endpoints

Code Review

Item 1: U256 Deserialization - Missing 0x prefix handling

File: crates/common/types/src/beacon/primitives.rs, lines 345-347

let text = <String as serde::Deserialize>::deserialize(deserializer)?;
U256::from_dec_str(text.trim()).map_err(serde::de::Error::custom)

The U256 serialize implementation at line 325-335 writes a decimal string. However, the from_dec_str call doesn't handle the 0x prefix that many Ethereum tools use for uint256 values. More critically, this is inconsistent with how Uint256 might be received from other clients.

Question: Is U256::from_dec_str strict enough? If a client sends "0x1a2b3c..." (hex), this will fail. The spec doesn't mandate a format for uint256 in JSON, but execution layer APIs typically use hex for large integers.

Suggestion: Consider whether hex strings should be accepted, or document that only decimal is supported.

Item 2: ssz_hex Deserializer - Missing validation of 0x prefix handling

File: crates/common/types/src/beacon/serde_helpers.rs, lines 383-396 (in ssz_hex_seq::deserialize)

let digits = text.trim();
let bytes = hex::decode(digits.strip_prefix("0x").unwrap_or(digits))
    .map_err(serde::de::Error::custom)?;

The strip_prefix("0x") is correct, but note that hex::decode accepts both "0xab" and "ab" — this is fine. However, compare with impl_hex_deserialize! in primitives.rs which also strips 0x.

Potential inconsistency: The ssz_hex module's serialize function (line 255-265) adds 0x prefix. The deserializer strips it. This is correct and symmetric.

Item 3: seq::deserialize - Panic on conversion failure exposes debug format

File: crates/common/types/src/beacon/serde_helpers.rs, lines 427-432

C::try_from(values)
    .map_err(|err| serde::de::Error::custom(format!("invalid sequence: {err:?}")))

Using {:?} for error messages leaks internal debug representations to API clients. This occurs in multiple deserializers (seq, ssz_hex_seq, quoted_u64_seq).

Suggestion: Use Display if available, or a generic message:

.map_err(|_| serde::de::Error::custom("invalid sequence: length or conversion failed"))

Item 4: Missing serde::Deserialize on SyncAggregate in electra and later forks

File: crates/common/types/src/beacon/containers/altair.rs, lines 73-85

The SyncAggregate gets serde::Deserialize in altair, but check if electra's SyncAggregate (which reuses or redefines it) also needs this. Looking at the diff, electra.rs doesn't redefine SyncAggregate — it uses altair::SyncAggregate. Good.

However, bellatrix.rs and capella.rs have SyncAggregate fields that reference altair::SyncAggregate. The derive is only on the altair definition. This is correct since it's the same type.

Item 5: gloas.rs - BeaconBlock and SignedBeaconBlock get Default derive with serde::Deserialize

File: crates/common/types/src/beacon/containers/gloas.rs, lines 808-820

#[derive(
    Debug,
    Clone,
    Default,  // <-- here
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    ...
)]
pub struct BeaconBlock {

Adding Default to BeaconBlock is concerning. A default block with slot: 0, proposer_index: 0, zeroed roots, and an empty body is not a valid block. However, this may be needed for SSZ progressive containers or test fixtures.

Check: Does Default make sense semantically? The BeaconBlockBody also has Default in gloas. This seems intentional for the progressive container feature (#[ssz(progressive_container)]), but verify that a default block cannot accidentally be processed as valid.

Item 6: json_tests.rs - Test uses unwrap() extensively

File: crates/common/types/src/beacon/containers/json_tests.rs

The test file uses .unwrap() ~40 times. For a test file this is acceptable, but the round_trip function panics on failure without context:

fn round_trip<T>(value: &T)
where
    T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
    let json = serde_json::to_string(value).unwrap();
    let back: T = serde_json::from_str(&json).unwrap();
    assert_eq!(&back, value, "{json}");
}

The assert_eq! with "{json}" format is good for debugging. However, serde_json::from_str failure won't show the JSON that failed.

Suggestion: Consider capturing the JSON in a variable for error reporting, or use expect with the JSON included.

Item 7: fork_schedule - Epoch serialization as string vs number

File: crates/net/rpc/src/beacon/config.rs, lines 55-80

let fork = |previous: [u8; 4], current: [u8; 4], epoch: u64| {
    serde_json::json!({
        "previous_version": hex_string(previous),
        "current_version": hex_string(current),
        "epoch": epoch.to_string(),  // <-- quoted as string
    })
};

The epoch is serialized as a string (epoch.to_string()), but the standard Beacon API typically returns integers as numbers. However, looking at the existing get_spec implementation, it also uses .to_string() for integers (line 120: json!(value.to_string())).

This is intentional and consistent with the existing codebase's approach to avoid JavaScript number precision issues. The test at line 480 confirms: assert_eq!(data[0]["epoch"], "0");

Item 8: BodyEncoding::decode - Silent failure on decode errors

File: crates/net/rpc/src/beacon/mod.rs, lines 134-142

pub(crate) fn decode<T>(self, body: &[u8]) -> Option<T>
where
    T: serde::de::DeserializeOwned + libssz::SszDecode,
{
    match self {
        Self::Json => serde_json::from_slice(body).ok(),
        Self::Ssz => T::from_ssz_bytes(body).ok(),
    }
}

This silently swallows deserialization errors, returning None which becomes a generic 400 Bad Request. This loses diagnostic information.

Security/UX concern: A client sending a slightly malformed body gets the same error as completely wrong data. Consider logging the actual error for debugging:

Self::Json => serde_json::from_slice(body).map_err(|e| {
    tracing::debug!("JSON decode error: {e}");
    e
}).ok(),

Item 9: decode_list - SSZ list decoding doesn't use BodyEncoding::decode

File: crates/net/rpc/src/beacon/mod.rs, lines 148-162

pub(crate) fn decode_list<T>(
    headers: &axum::http::HeaderMap,
    body: &[u8],
) -> Result<Vec<T>, ApiError>
where
    T: serde::de::DeserializeOwned + libssz::SszDecode,
{
    let invalid = || ApiError::BadRequest("invalid request body");
    match BodyEncoding::from_headers(headers)? {
        BodyEncoding::Json => serde_json::from_slice(body).map_err(|_| invalid()),
        BodyEncoding::Ssz => {
            <Vec<T> as libssz::SszDecode>::from_ssz_bytes(body).map_err(|_| invalid())
        }
    }
}

Note that decode_list for SSZ uses <Vec<T> as SszDecode>::from_ssz_bytes, which is the SSZ List[T, N] encoding with offsets. But BodyEncoding::decode for single items uses T::from_ssz_bytes.

Question: Is <Vec<T> as SszDecode> the correct SSZ encoding for a list? In SSZ, a List[T, N] is variable-length with a 4-byte length prefix. The Vec<T> implementation in libssz should handle this. This appears correct but verify that Vec<T>'s SszDecode expects the list encoding, not a fixed-length vector.

Item 10: post_pool_attestations - Missing version header check for SSZ

File: crates/net/rpc/src/beacon/pool.rs, lines 89-105

The post_pool_attestations function uses decode_list which checks Content-Type, but doesn't check Eth-Consensus-Version for SSZ submissions. Looking more carefully, the function does:

let header_fork = match header_fork(&headers, &store) {
    Ok(fork) => fork,
    Err(err) => return err.into_response(),
};

This is called before decode_list. Good — the fork check happens first.

Item 11: gloas_proposal.rs - GloasSignedEnvelopeContents field attributes

File: crates/net/rpc/src/beacon/gloas_proposal.rs, lines 155-162

pub(crate) struct GloasSignedEnvelopeContents {
    pub(crate) signed_execution_payload_envelope: SignedExecutionPayloadEnvelope,
    #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")]
    pub(crate) kzg_proofs: CellKzgProofs,
    #[serde(with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq")]
    pub(crate) blobs: Blobs,
}

Wait — kzg_proofs uses seq (not ssz_hex_seq), but KzgProof is a 48-byte fixed array that should be hex-encoded. Let me check...

Looking at primitives.rs line 521: impl_hex_deserialize!(BlsPubkey, BlsSignature, H160, KzgCommitment, KzgProof); — so KzgProof has its own Serialize/Deserialize impls that use hex.

But seq calls T::deserialize for each element, which for KzgProof would use its Deserialize impl. Let me verify: CellKzgProofs is SszList<KzgProof, ...>. The seq serializer calls serialize on each element, which for KzgProof uses the hex impl from impl_hex_serialize!.

Wait — there's an asymmetry. Looking at the original FuluBlockContents:

#[serde(with = "ethlambda_types::beacon::serde_helpers::seq")]
pub(crate) kzg_proofs: CellKzgProofs,

This was seq before the PR too. And KzgProof has impl_hex_serialize! and impl_hex_deserialize!. So seq works because it delegates to each element's Serialize/Deserialize. This is correct.

But blobs uses ssz_hex_seq which hex-encodes the SSZ bytes of each element. For Blob (which is SszVector<u8, BYTES_PER_BLOB>), this is correct because Blob's own Serialize might not be hex.

Actually, let me re-check. Looking at the diff for FuluBlockContents:

-#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize)]
+#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize, serde::Deserialize)]
 pub(crate) struct FuluBlockContents {
     pub(crate) block: BeaconBlock,
-    #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::seq::serialize")]
+    #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")]
     pub(crate) kzg_proofs: CellKzgProofs,
-    #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq::serialize")]
+    #[serde(with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq")]
     pub(crate) blobs: Blobs,
 }

The blobs field was ssz_hex_seq::serialize and is now with = "ssz_hex_seq" (both serialize and deserialize). This is correct.

Item 12: Critical — ssz_hex_seq::deserialize doesn't validate length bounds properly

File: crates/common/types/src/beacon/serde_helpers.rs, lines 370-396

pub fn deserialize<'de, D, C, T>(deserializer: D) -> Result<C, D::Error>
where
    D: serde::Deserializer<'de>,
    C: TryFrom<Vec<T>>,
    C::Error: std::fmt::Debug,
    T: libssz::SszDecode,
{
    let texts = <Vec<String> as serde::Deserialize>::deserialize(deserializer)?;
    let values = texts
        .iter()
        .map(|text| {
            let digits = text.trim();
            let bytes = hex::decode(digits.strip_prefix("0x").unwrap_or(digits))
                .map_err(serde::de::Error::custom)?;
            T::from_ssz_bytes(&bytes).map_err(|err| {
                serde::de::Error::custom(format!("invalid SSZ encoding: {err:?}"))
            })
        })
        .collect::<Result<Vec<T>, _>>()?;
    C::try_from(values)
        .map_err(|err| serde::de::Error::custom(format!("invalid sequence: {err:?}")))
}

The T::from_ssz_bytes for an SszList or SszVector will validate its own bounds. But for Blob which is SszVector<u8, BYTES_PER_BLOB>, from_ssz_bytes checks the length. Good.

However, for Transaction (in ssz_hex_seq usage in ExecutionPayload), Transaction is SszList<u8, MAX_BYTES_PER_TRANSACTION>. The from_ssz_bytes will check this bound. Good.

Item 13: get_validator — Missing state_id validation edge case

File: crates/net/rpc/src/beacon/states.rs, lines 268-296

async fn get_validator(
    Path((state_id, validator_id)): Path<(String, String)>,
    State(store): State<Store>,
) -> Response {

The function uses load(&store, &state_id) which can fail with ApiError::BadRequest for invalid state IDs. Good.

But looking at the ValidatorId::Pubkey branch:

ValidatorId::Pubkey(pubkey) => state
    .iter_validators()
    .zip(state.iter_balances())
    .enumerate()
    .find(|(_, (validator, _))| validator.pubkey == pubkey)
    .map(|(index, found)| (index as ValidatorIndex, found)),

This does a linear scan O(n) over all validators. For a state with 1M validators, this is expensive. However, this matches the spec behavior and the batch post_validators also does linear scans. Acceptable for now, but consider indexing by pubkey in the future.

Item 14: an_oversized_list_is_refused_rather_than_truncated test — Good security test

File: crates/common/types/src/beacon/containers/json_tests.rs, lines 398-405

#[test]
fn an_oversized_list_is_refused_rather_than_truncated() {
    let mut json = serde_json::to_value(electra_block()).unwrap();
    let commitment = json["message"]["body"]["blob_kzg_commitments"][0].clone();
    json["message"]["body"]["blob_kzg_commitments"] = serde_json::Value::Array(vec![
        commitment;
        crate::beacon::preset::MAX_BLOB_COMMITMENTS_PER_BLOCK + 1
    ]);
    assert!(serde_json::from_value::<electra::SignedBeaconBlock>(json).is_err());
}

This is an excellent security test verifying that the TryFrom<Vec<T>> bound check in seq::deserialize rejects oversized lists rather than silently truncating.

Item 15: post_envelope_waiting — JSON envelope with blob_data_included: false uses wrong type

File: crates/net/rpc/src/beacon/gloas_proposal.rs, lines 793-802

} else {
    match encoding.decode::<SignedExecutionPayloadEnvelope>(body) {
        Some(signed) => (signed, None),
        None => {
            return ApiError::BadRequest(
                "the body is not a gloas SignedExecutionPayloadEnvelope",
            )
            .into_response();
        }
    }
}

When blob_data_included is false, the body should be a bare SignedExecutionPayloadEnvelope. With JSON encoding, this decodes correctly. With SSZ, it also decodes correctly. Good.

But wait — the GloasSignedEnvelopeContents struct has kzg_proofs and blobs fields that are not optional. When blob_data_included is true, the JSON must include these fields. The serde::Deserialize derive will require them. This is correct.

Item 16: PayloadCache — Not shown in diff, but insert and get need review

Not in diff, but the PayloadCache is used with GloasBlockContents which now implements Deserialize. Verify that the cache doesn't accidentally deserialize untrusted data.

Summary of Issues

Priority Item Location Issue
Medium 3 serde_helpers.rs `{:

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

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Say in a few words what I'm doing: I've read the whole diff and I'm writing up the review now.

Review of PR 667: JSON request bodies for the Beacon API, plus fork_schedule

Overall: The change is sound and consistent. The serialize_with to with swap is mechanical. BodyEncoding and decode_list route JSON and SSZ through the same typed decode, and the tests check that both encodings give the same outcome. I found nothing consensus-critical. I read the diff but did not build or run the tests. The points below are minor.

Findings

  1. Doc comments claim bare integers are accepted, but the code takes strings only. This is in serde_helpers.rs (the quoted_u64_seq::deserialize comment) and primitives.rs (the impl Deserialize for U256 comment).

    • Both deserialize through Vec<String> or String, so [1, 2] or 123 is rejected. quoted_or_bare does accept both.
    • Beacon API JSON always quotes integers, so this is harmless in practice.
    • Either fix the comments, or reuse the quoted_or_bare logic so bare integers work too.
  2. The JSON body can use much more memory than the request size. The new publish routes accept JSON up to MAX_PUBLISH_BODY_BYTES, which is 64 MiB and was sized in beacon/mod.rs for SSZ.

    • JSON hex doubles the size of blobs and payload transactions, so a maximal-but-legitimate JSON body could exceed the limit and get a 413. That is unlikely in practice, but the doc comment should say the limit is SSZ-sized.
    • The decode builds a Vec<String> and then a Vec<T> before C::try_from checks the length bound (ssz_hex_seq, seq, quoted_u64_seq). Peak memory per request is therefore several times the body size.
    • This is bounded and the endpoints are operator-facing, so it is not a blocker. A note, or a limit sized per encoding, would help.
  3. Missing Content-Type now means JSON on the block and envelope endpoints. Before, a missing header got a 415 there.

    • This matches the Beacon API and the behavior of the pool endpoints, so it is fine.
    • Matching is now exact on application/json and application/octet-stream, where blocks previously used starts_with.
    • from_headers compares case-sensitively, but MIME types are case-insensitive, so Application/JSON gets a 415. This is rare, but eq_ignore_ascii_case is cheap.
  4. fork_schedule is correct.

    • Phase0 is its own predecessor, unscheduled forks are skipped, and previous_version chains from the last scheduled fork.
    • Both the all-scheduled and the skipped-fork cases are tested.
    • It assumes epochs increase with fork order and does not enforce it. Config validation at startup presumably guarantees that. If it does not, a misordered config would return a non-monotonic schedule.
  5. Tests are good.

    • They cover SSZ and JSON parity for blocks and envelopes, forged signatures, tampered blobs, and a 415 versus 400 split.
    • The json_tests.rs round-trip file and the nimbus spec-key test lock in the contract.
    • One gap: no test sends a bare-integer or malformed quoted value to the new deserializers. Such a test would have caught Item 1.

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

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.
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.
…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-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.
@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:
#	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
@MegaRedHand
MegaRedHand enabled stacked auto-merge October 26, 2026 20:09
… 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.

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