Skip to content

feat(beacon): gloas validator duties on the beacon node and the validator client - #662

Open
MegaRedHand wants to merge 30 commits into
feat/beacon-gloas-livefrom
feat/beacon-gloas-validator-duties
Open

MegaRedHand wants to merge 30 commits into
feat/beacon-gloas-livefrom
feat/beacon-gloas-validator-duties

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

#639 makes the node follow gloas, but every validator duty is refused from the gloas epoch on (refuse_validator_duties_from_gloas, require_fulu_slot), so neither our beacon node nor ethlambda validator can validate past the fork. This PR implements the gloas validator duties on both sides, per consensus-specs v1.7.0-beta.2 (gloas validator.md, builder.md) and the current beacon-APIs (the produceBlockV4 shape lighthouse v8.3.0-rc.0 implements).

Beacon node

Endpoint What
POST /eth/v4/validator/blocks/{slot} produceBlockV4: self-built gloas block (bid with BUILDER_INDEX_SELF_BUILD), include_payload returns gloas BlockContents (block, envelope, cell proofs, blobs) or the bare block; JSON or SSZ
POST /eth/v2/beacon/blocks also takes a bare SSZ gloas SignedBeaconBlock
GET /eth/v1/validator/execution_payload_envelopes/{slot}/{root} the cached envelope of a block this node produced
POST /eth/v1/beacon/execution_payload_envelopes signed envelope, with blobs (Eth-Blob-Data-Included: true) or the cached ones; checks bid match, signature and column KZG proofs, builds every gloas column sidecar, gossips envelope + columns and imports them; waits up to 4 s for the block to be imported
POST /eth/v1/validator/duties/ptc/{epoch} PTC duties (get_ptc_assignment), crossing upgrade_to_gloas from a fulu head
GET /eth/v1/validator/payload_attestation_data?slot= payload_present from the envelope's arrival time vs PAYLOAD_DUE_BPS, blob_data_available from the custody columns; 204 when the slot has no block
GET/POST /eth/v1/beacon/pool/payload_attestations validated with gossip's checks, pooled, gossiped; the GET lists aggregates
duties, attestation_data, pool attestations, aggregates served at gloas epochs; data.index carries the head's payload status (0 same-slot, else 1 iff FULL)

Under the hood:

  • engine: PayloadAttributesV4 (slotNumber, targetGasLimit), engine_forkchoiceUpdatedV4 with attributes, engine_getPayloadV6.
  • gloas_block_production.rs: payload inputs per prepare_execution_payload (should_build_on_full, apply_parent_execution_payload), the 5-type request list, gloas attestation packing, payload attestation packing (one bit per PTC seat), block + envelope assembly with a self-check of the envelope against the post-state.
  • One SharedPayloadAttestationPool filled by gossip and the API, read by block production.
  • The envelope's arrival time is recorded in Store for payload_present.
  • DefaultBodyLimit raised to 64 MiB on the two publish routes: a blob-heavy block or envelope (21 blobs) was a 413 at axum's 2 MiB default (found on the devnet).

Validator client

  • Deadlines pick the fork's basis points (*_DUE_BPS_GLOAS); loop at gloas: propose (block, then envelope), attest at 25%, aggregate at 50%, PTC at 75%.
  • Gloas proposal through produceBlockV4 (SSZ), then signs the self-built envelope under DOMAIN_BEACON_BUILDER with the proposer key and publishes it with its blobs.
  • Gloas attestations (committee_index omitted, index passed through) and gloas aggregates.
  • PTC duties, payload_attestation_data, DOMAIN_PTC_ATTESTER signing and submission; votes even with no attester duty in the slot.

Decisions and deviations (documented in docs/spec_deviations.md)

  • Self-build only: no builder bids (p2p or builder API), no SignedProposerPreferences. The BuilderConfig body is decoded and ignored.
  • targetGasLimit is the parent bid's gas limit.
  • skip_randao_verification is not honored; an invalid reveal is a 400 up front.
  • A pre-gloas head counts as FULL for attestation_data.index (the decided boundary rule).
  • payload_present reads the envelope's arrival time, which a restart forgets.
  • Still out of scope: sync-committee duties in the validator client (it has none at any fork), remote signing, a slashing-protection DB.

Validation

  • Unit and router tests per piece, plus validator_client_tests.rs driving the real validator client against the real router over a socket at a gloas slot: attest + aggregate, PTC duties/data/submission, and a full proposal (with and without a blob) against a fake execution client answering forkchoiceUpdatedV4/getPayloadV6.
  • cargo test --profile release-fast --lib: rpc 170, validator 269, engine 26, storage 152, state-transition gloas 87 / block_production 19; clippy -D warnings and fmt clean. Spec fixture suites not run locally.
  • Kurtosis devnet on ethlambda-5, fulu genesis, gloas_fork_epoch: 5, 12 nodes x 128 keys: ethlambda CL + VC on 6 nodes (50% of the stake; geth, ethrex 29.0.1, nethermind, besu, reth, erigon), the other half lighthouse v8.3.0-rc.0, teku 26.9.1, nimbus v26.10.0, prysm v7.2.0, lodestar v1.49.0, grandine 3.0.0-rc.0 (Platåberget releases). Image built from this branch merged into tmp/bci-626-63-64-633-636-638-gloas-live.
    • Every node on one head through the fork; finality kept advancing (justified 7, finalized 6 at slot 286, gloas from slot 160).
    • Epochs 4 (last fulu) and 5 (first gloas): all 1536 validators earned source, target and head rewards (/eth/v1/beacon/rewards/attestations).
    • Gloas slots 160 to 286: no missed slot; ethlambda proposed 54 blocks and teku holds the envelope for all 54 (74/74 for the other clients); ethlambda VCs published every envelope and a PTC vote in every slot, none rejected, no VC errors.

Known, not addressed here

  • At each epoch boundary the validator client's next-epoch attester-duty lookahead gets a 400 ("epoch is not within one epoch of the head state's"): the node bounds the epoch by its head state rather than the wall clock, and the VC asks at slot 0, before the new epoch's first block. Pre-existing (fulu too); current-epoch duties still arrive in time.
  • The fulu POST /eth/v2/beacon/blocks with blobs (feat(beacon): blob-carrying blocks and the operation pool #646) needs the same body-limit raise; the tmp merge carries it.

The gloas validator duties touch the network actor, the Beacon API and
block production at once. Fixing the seams first lets each part be built
against them independently: two RpcToP2P methods for the gossip a validator
client's envelope and payload votes need, a payload attestation pool that
gossip and the API fill and block production reads, empty route modules
for the new endpoints, JSON decoding for the gloas containers the API
accepts, and the electra/gloas attestation conversions a pool holding
electra's shape needs to serve gloas.
…tPayloadV6

A self-built gloas block needs the execution client to start a build with
PayloadAttributesV4 (slot number and target gas limit) and to hand back an
ExecutionPayloadV4 with its block access list and BlobsBundleV2. Both
methods are advertised in the capabilities handshake.
Gloas moves the attester and aggregator offsets earlier and adds a payload
timeliness committee deadline. The clock now keeps the whole Config and picks
the basis points by the fork of the slot's epoch, so a client running across
the boundary is on the right offsets on both sides of it. The spec response
parser reads the four new keys and keeps the specification defaults when a node
omits them.
…dator

The placeholder vector scanned every held message on each query. Keying by
slot first makes pruning a split and the per-slot reads touch only their own
slot; dedup per (validator, data) is a map lookup.
From gloas the block commits to a bid and the payload travels in an envelope,
so a proposal is now two publications. The client asks produceBlockV4 with
include_payload so the envelope arrives with the block, decodes the body by
Eth-Execution-Payload-Included, signs and publishes the bare block, then signs
the envelope under the builder domain with the proposer's own key and publishes
it inside the same budget. When the node returned no payload the envelope is
fetched by slot and block root and published bare.

The envelope is checked to be for this block and this self-build before it is
signed, since the signature covers it as it stands. A bid naming a builder is
left to that builder. A failed envelope does not fail the proposal, whose block
is already out, and has its own failure counter because it is the failure that
withholds the payload. The proposal guard is untouched: envelopes are not
slashable.

The same change widens BeaconNodeApi (and its http, mock and fallback
implementations) with the gloas aggregate, payload timeliness committee and
envelope calls, so that the services built on top of them in the following
commits do not each reopen the trait.
Gloas repurposes AttestationData.index as the payload signal, so the client
must neither ask about committee 0 nor reset the answer to zero: the query now
omits committee_index from the first gloas slot on (the fork is passed from the
client's own schedule), and the index the node answers is submitted and signed
unchanged. A test pins that an index of 1 reaches both the submission and the
signature, and that a zeroed copy would be a different message.

Aggregates are fork-tagged: the node's answer is parsed into gloas's own
Attestation, the wrapper is the gloas AggregateAndProof signed over its own
root (the two attestation layouts hash differently), and the batch goes out as
gloas JSON under the gloas header. The doc claims that the index is always zero
are corrected.
From gloas a small committee is drawn each slot to attest whether the payload
arrived. DutiesService now keeps that schedule for the current and next epoch
on the attester schedule's terms (replaced when the dependent root changes,
narrowed to this client's indices), and refresh_epoch fetches it for gloas
epochs only, gating each epoch on its own fork since the lookahead can be the
first gloas epoch.

PayloadAttestationService asks the node for the slot's data, signs it under the
PTC domain at the data's epoch and submits it. A 204 means no block is known and
is not a vote against the payload. One vote per validator and slot is recorded
at signing time, so a duty dropped at its deadline is lost rather than re-signed.

The duty loop no longer skips a slot that has no attester duty: committee seats
are drawn independently, so the old early continue would have dropped exactly
those votes. The per-slot work moves into serve_slot_duties, which sleeps to
the payload deadline (three quarters in) and is bounded by the end of the slot
like the other duties.
…lopes

Gloas moves the payload out of the block: the body commits to a bid and
the payload is revealed in an envelope. Block production therefore needs
its own assembly: the payload inputs that depend on whether the parent's
payload is built on, the five-kind request list, gloas attestation
packing (payload-availability index rules), payload attestation
aggregation by PTC seat, the self-build bid, the state root, and the
unsigned envelope. The envelope is checked against the block's post-state
before it is handed out, so a malformed build fails at the producer.
Column sidecars for the blobs are built from the cells and the execution
client's cell proofs.
…eacon API

Gossip-accepted payload_attestation_message votes now land in the pool block
production packs from, and the Beacon API router gets the same pool as an
Extension for its pool endpoints.
Gossipsub never delivers a node its own message, so the publish path also
hands the vote to the chain actor and marks the seen cache, which keeps a
peer echo from being validated and forwarded a second time.
A validator client needs proposer and attester duties and attestation data
from the first gloas epoch on, and all three were refused by name.

Proposer duties read the lookahead of a fulu or a gloas state: gloas keeps
fulu's field and `upgrade_to_gloas` carries it over, so a fulu head can
already answer the first gloas epoch. Attestation data at a gloas slot sets
`index` as the payload-present flag: 0 when the head block is from the slot
itself, else 1 iff fork choice holds the head's payload as FULL (a pre-gloas
head counts as FULL, the boundary rule fork choice applies). `committee_index`
is optional in the query, as gloas's beacon-APIs have it.
…ol endpoints

The submit endpoints refused the gloas header and the aggregate query refused
gloas slots. A gloas slot now needs the gloas header (and an older one an
electra or fulu header), a vote's `data.index` is 0 or 1 and passes the gossip
payload-status rule (reused, not reimplemented), and aggregates are served and
accepted in gloas's container. Submitted and gossip gloas aggregates are
pooled by converting them to the pool's electra shape, so block production
keeps one pool. The refusal helper has no caller left and is removed.
The committee votes payload_present when an envelope was seen before
get_payload_due_ms() into the slot, and the store only knew whether a payload
was verified, not when it arrived. Record the arrival time (not the
verification time, since envelopes can be held for columns or the engine) in
an in-memory map pruned with the el-hash cache, and add get_payload_due_ms.
A validator client needs three things from the node to propose at gloas:
a self-built block (produceBlockV4), a way to publish it, and a way to
publish the payload envelope that reveals it together with the data
columns of its blobs, which a self-building proposer owes the network.

produceBlockV4 builds on the head's recorded payload branch, asks the
execution client through forkchoiceUpdatedV4 and getPayloadV6, and caches
the envelope, blobs and cell proofs for the current and previous slot so
include_payload=false and the stateful publish form work. publishBlockV2
takes a bare gloas block. The envelope endpoint waits briefly for a block
the chain actor has not imported yet, since a validator client publishes
the envelope as soon as the block is handed to the network.

The node's custody set is handed to the RPC server for the build request.
A gloas validator client needs three things from its beacon node to sit on
the payload timeliness committee: its seat (POST duties/ptc/{epoch}), the
data to sign (GET payload_attestation_data) and somewhere to submit the vote
(POST pool/payload_attestations). Submitted votes go through the same
stateful checks gossip applies to a peer's, then into the pool block
production packs from, and out on gossip.

The first gloas epoch is answered from a copy of a fulu head state advanced
across the upgrade on a blocking thread, since get_ptc only reads the cached
window of a gloas state.
The Beacon API hands the node a signed envelope and the columns of its
blobs, and gossipsub never delivers a node its own messages, so the
publish path also has to give them to the chain actor. The envelope goes
to the actor as it is, which is safe before its block is imported: the
actor holds envelopes for blocks it has not imported or has stored but
not imported. The sidecars go through the chain checks, which park one
whose block is not imported yet.
…elope publication

Both this branch and the PTC branch gave the Beacon API's handles the node's
custody set; the merge keeps the PTC branch's `CustodyColumns` handle, which
block production now reads under an alias that keeps it apart from the
engine's bitfield of the same name.
…nt API

The client grew a fork argument on attestation_data, AggregateKind, a fork on
BlockRequest and ProducedBlock accessors. The router also layers the payload
attestation pool and custody columns as the real server does, so the harness
keeps matching what start_beacon_rpc_server builds.
The beacon node and validator client now serve and perform gloas duties, but
the docs still said the node refuses a gloas epoch and never builds a payload.
Document the new endpoints (produceBlockV4, the envelope endpoints, PTC duties,
payload attestation data and pool, gloas on the existing pool and block
endpoints), the departures from the specification that came with them
(self-build only, target gas limit, ignored skip_randao_verification, the
pre-gloas FULL rule for attestation data, arrival-time payload_present), the
validator client's slashing-protection scope for envelopes and PTC votes, the
four new validator metrics, and the engine calls used to build a gloas payload.
pack_payload_attestations grouped and verified votes inline, tied to a state
exactly one slot past the parent. The pool listing endpoint needs the same
grouping for any slot the head state can read a committee for, so the
grouping moves into aggregate_payload_attestations and packing calls it.
Packing behavior is unchanged.
beacon-APIs getPoolPayloadAttestations returns Gloas.PayloadAttestation
(a bitvector over the slot's committee plus one aggregated signature), not
raw messages, so a consumer built to the spec could not parse our answer.
The handler now aggregates the pooled votes per slot and data with the logic
block production uses, against the head state. The optional slot filter is
kept.
Adds gloas counterparts to the validator-client tests, each running the real
client (HttpBeaconNode, ProposalService, PayloadAttestationService) against
the real router over a socket, with a signature check against the state's own
domains:

- attest and aggregate: no committee index in the request, the payload
  signal in the answer (0, then 1 once fork choice holds the head's payload as
  full), the gloas header on submit, and the gloas aggregate container.
- payload timeliness committee: duties equal the state's committees, 204 for a
  slot with no block, and votes signed by the client are gossiped and pooled.
- proposal: produceBlockV4 with the payload, a signed block, a signed
  builder-domain envelope matching the bid, with and without a real blob (all
  data columns go out with the envelope). The fake execution client now also
  answers forkchoiceUpdatedV4 and getPayloadV6.

The test state needs its fork set to the schedule's gloas version, which the
builders leave zeroed, for the client's signatures to verify against it.
The listing now follows the specification's PayloadAttestation shape; the doc still described the raw messages it returned before.
Axum caps request bodies at 2 MiB by default, so POST /eth/v2/beacon/blocks
with a fulu SignedBlockContents of 18-21 blobs (131072 bytes each, plus 128
cell proofs of 48 bytes per blob) answered 413. The gloas envelope POST with
Eth-Blob-Data-Included carries the whole execution payload plus blobs and
would hit the same limit.

Raise the limit to MAX_PUBLISH_BODY_BYTES (64 MiB) with a route-level
DefaultBodyLimit on exactly those two POST routes, not router-wide. The
preset's MAX_BLOB_COMMITMENTS_PER_BLOCK is no practical bound (~550 MB), so
the cap is sized from a generous blob count plus a maximal payload and margin.
MegaRedHand added a commit that referenced this pull request Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 662

Scope: The checkout I can see has one uncommitted change, to CLAUDE.md (+3/−32). I reviewed that. If the PR includes other files, I haven't seen them.

Blocking: the edit makes CLAUDE.md wrong.

  • The diff deletes the paragraphs that describe gloas validator duties. These cover self-build only, gloas_block_production.rs, rpc/src/beacon/gloas_proposal.rs and ptc.rs, SharedPayloadAttestationPool, PayloadAttributesV4 and get_payload_v6, and the validator client's gloas slot schedule.
  • In their place it adds: "The node stays a follower: the validator-client endpoints still refuse a gloas epoch (refuse_validator_duties_from_gloas)."
  • That sentence is false for this tree.
    • grep finds no refuse_validator_duties_from_gloas in any .rs file.
    • state_transition/src/beacon/gloas_block_production.rs and payload_attestation_pool.rs exist.
    • crates/validator/src/payload_attestation.rs exists.
    • HEAD is the merge into feat/beacon-gloas-validator-duties.
  • The change looks like it came from a stale base, or a merge or rebase that kept the older CLAUDE.md. Either way, it would send future readers and agents toward a refusal guard that no longer exists and hide the modules that do exist.

Suggested fix:

  • Drop this CLAUDE.md change, or rebase onto the branch tip and restore the removed paragraphs.
  • If the aim is to shorten the doc, keep the facts that aren't obvious from the code:
    • the self-build-only limit;
    • the 4 s envelope-POST wait for its block;
    • the shared PTC pool;
    • the missing slashing-protection record for PTC votes and envelopes.
  • Before keeping the old "no slashing-protection record" line, check it against attestation_guard.rs and proposal_guard.rs in crates/validator/src. The old text may already be partly out of date.

No code, consensus-logic or security changes are in what I could review.


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

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

⚠️ Warning: Diff was truncated from 12042 to 10000 lines. Some changes were not reviewed.

Found one material correctness bug; otherwise the PR looks thoughtfully implemented and well-tested for a large consensus/API addition.

  • crates/net/rpc/src/beacon/ptc.rs:328 — payload_present uses seen_ms < due_ms. The spec text in your own comments says “before get_payload_due_ms() into the slot”, which usually means at or before the deadline. As written, an envelope seen exactly at the cutoff votes false. Given this feeds PTC voting and therefore fork-choice weight, this is a consensus-relevant off-by-one. Suggest seen_ms <= due_ms unless the spec explicitly requires strict inequality.
  • crates/validator/src/lib.rs:340 — serve_slot_duties runs attestation/aggregation first and only then sleeps until the PTC deadline. If attestation or aggregation overruns past PAYLOAD_ATTESTATION_DUE_BPS, the PTC vote is sent late instead of being independently scheduled. That can cause missed or stale PTC votes exactly in the stressed conditions this PR is trying to handle. This is correctness/performance relevant because PTC membership is independent of attester duties. Consider spawning the PTC path concurrently once the slot starts, or computing its deadline relative to slot start rather than “after attestation work finishes”.
  • crates/net/rpc/src/beacon/gloas_proposal.rs:313 and crates/net/rpc/src/beacon/gloas_proposal.rs:828 — the cache is keyed only by (slot, block_root) and later trusted for blob/proof recovery when Eth-Blob-Data-Included: false. That is fine if block_root uniquely commits to the bid/blob commitments, but it would be safer to assert the cached envelope matches the posted signed envelope’s bid-relevant fields before reusing cached blobs. Right now cache.get ignores the cached envelope entirely, so a future refactor could accidentally serve mismatched cached side data without tripping this code path.
  • crates/net/rpc/src/beacon/gloas_proposal.rs:809-823 — the bid/envelope consistency check only compares builder_index and payload.block_hash. If the protocol requires more of the envelope to be consistent with the block’s signed bid than just those fields, this check is too weak. I suspect later signature/STF paths catch most misuse, but this endpoint is security-sensitive enough that I’d prefer explicit validation of every bid-bound field here, or a comment stating why these two fields are sufficient.
  • crates/validator/src/payload_attestation.rs:192 — held + HISTORY_SLOTS > slot can theoretically overflow on very large held. It is harmless in practice for chain slot ranges, but held.saturating_add(HISTORY_SLOTS) > slot is the idiomatic safe form.

Overall, the design choices around earliest-envelope tracking, bounded duty execution, and keeping PTC duties independent from attester duties are solid. If you want, I can turn the two stronger points above into ready-to-post GitHub review comments.


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

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

⚠️ Warning: Diff was truncated from 12042 to 10000 lines. Some changes were not reviewed.

I'll review this PR focusing on the critical areas for a consensus client. This is a large change introducing Gloas (EIP-7732) support with payload envelopes, payload timeliness committees (PTC), and self-built block production.

Critical Issues

1. Missing slashing protection for PTC votes and envelopes (Security)

crates/net/rpc/src/beacon/ptc.rs:284-285

// It still keeps no slashing-protection record; PTC votes have an in-memory
// `(validator, slot)` dedup only, and envelopes are unguarded.

This is documented in CLAUDE.md but represents a critical security gap. The PayloadAttestationPool only deduplicates by (validator, slot) in memory (line 75 of payload_attestation_pool.rs), but:

  • No persistent slashing protection for PTC votes
  • Envelopes have no deduplication at all — a validator can sign multiple envelopes for the same slot, creating slashable offenses

Recommendation: Add persistent slashing protection before mainnet deployment, or at minimum document this as a known limitation with risk assessment.

2. Race condition in envelope seen timestamp (beacon_envelope.rs:565-568)

let seen_ms = held.event_ms.saturating_sub(config.genesis_time_ms());
self.store.insert_beacon_envelope_seen(root, slot, seen_ms);

The comment says "When it arrived, not when it finished verifying" — but event_ms is set when the envelope first arrives at P2P, before validation. A malicious peer could flood late envelopes with forged early timestamps. The event_ms should be from trusted network receipt, not arbitrary P2P message time.

Recommendation: Use a trusted clock source or validate event_ms against local time bounds.

3. gloas_data_column_sidecars recomputes cells instead of verifying proofs (gloas_block_production.rs:296-317)

let per_blob_cells = blobs
    .iter()
    .map(|blob| super::kzg::compute_cells(blob))
    .collect::<Result<Vec<_>>>()?;

The function receives cell_proofs from the execution client but recomputes cells from blobs rather than verifying the provided proofs against commitments. This wastes CPU and ignores the proofs entirely for cell construction. More critically, if the execution client provides invalid proofs, they're passed through unchecked to the sidecars.

Recommendation: Verify the provided cell proofs against the commitments and computed cells, or document why this is safe (the KZG proof verification in gloas_verify_data_column_sidecar_kzg_proofs happens later).

4. PayloadCache uses Mutex without poisoning recovery (gloas_proposal.rs:97-116)

fn insert(&self, slot: Slot, block_root: H256, payload: CachedPayload) {
    let mut entries = self.0.lock().expect("payload cache lock poisoned");
    // ...
}

Multiple .expect("... lock poisoned") calls will panic on poisoned locks. In a consensus client, this is a denial-of-service vector.

Recommendation: Use lock().unwrap_or_else(|e| e.into_inner()) for graceful recovery, or parking_lot::Mutex which doesn't poison.

5. assemble_gloas_block clones entire state unnecessarily (gloas_block_production.rs:462-470)

let mut post = state.clone();
let BeaconState::Gloas(_) = &post else {
    unreachable!("checked above")
};
stf::gloas::process_block(&mut post, &block, config, &CommitteeCache::default())?;

The state is cloned to compute the state root, but process_block mutates post. For large states, this is expensive. The CommitteeCache::default() is also passed — if this triggers committee recomputation on every block, it's a significant performance hit.

Recommendation: Verify that CommitteeCache::default() doesn't recompute committees, or pass a cached one.

Correctness Issues

6. pack_gloas_attestations filter logic may drop valid attestations (gloas_block_production.rs:180-196)

let includable = |attestation: &electra::Attestation| {
    let data = &attestation.data;
    data.index < 2
        && is_attestation_same_slot(state, data)
            .is_ok_and(|same_slot| !same_slot || data.index == 0)
};

The condition !same_slot || data.index == 0 means: if same-slot, index must be 0. But is_attestation_same_slot checks if the attestation is for the block's own slot. For a gloas slot, attestations for the current block should indeed have index == 0 (no payload yet). However, this filter runs before pack_attestations, which may have its own filtering. Ensure no double-filtering drops valid attestations.

7. get_ptc_assignments returns first slot only, not all slots (helpers/gloas.rs:718-740)

assignments.entry(validator_index).or_insert(slot);

Per the comment: "A validator with several seats gets its first slot." But the specification's get_ptc_assignment returns one slot per validator. If a validator has multiple seats in the same slot, this is correct. If they have seats in different slots, this loses information.

Verify: Does the PTC allow one validator in multiple slots per epoch? If yes, this is a bug.

8. post_envelope waits for block with busy-polling (gloas_proposal.rs:1034-1049)

async fn wait_for_block(
    store: &Store,
    root: H256,
    timeout: Duration,
) -> Option<(containers::SignedBeaconBlock, Arc<BeaconState>)> {
    let deadline = Instant::now() + timeout;
    loop {
        if let (Ok(Some(block)), Ok(Some(state))) =
            (store.get_signed_block(&root), store.get_state(&root))
        {
            return Some((block, state));
        }
        if Instant::now() >= deadline {
            return None;
        }
        tokio::time::sleep(BLOCK_POLL_INTERVAL).await;
    }
}

This polls every 50ms for up to 4 seconds. Under load, this wastes CPU. The store should support a notification mechanism (watch/channel) for block arrival.

Recommendation: Add tokio::sync::Notify or similar to the store for block arrival events.

Performance Issues

9. aggregate_payload_attestations does O(n×m) signature aggregation (gloas_block_production.rs:245-280)

for (position, validator) in ptc.iter().enumerate() {
    if let Some(signature) = votes.get(validator) {
        aggregation_bits.set(position, true).ok()?;
        signatures.push(*signature);
    }
}
let seats = signatures.len();
let attestation = PayloadAttestation {
    aggregation_bits,
    data,
    signature: bls::aggregate(&signatures).ok()?,
};

For each distinct PayloadAttestationData, this iterates the entire PTC and looks up in a BTreeMap. With PTC_SIZE potentially large, and many distinct data values, this is O(k × n × log n) where k = distinct data count.

Optimization: Pre-group by validator index, or use a Vec instead of BTreeMap for the small PTC size.

10. SSZ deserialization without size limits (gloas_proposal.rs:622-640, post_envelope)

let (signed, supplied) = if blob_data_included {
    match GloasSignedEnvelopeContents::from_ssz_bytes(body) {

body can be up to MAX_PUBLISH_BODY_BYTES (64 MiB), but from_ssz_bytes may allocate proportionally. No streaming or incremental validation.

Recommendation: Add depth limits and pre-validate sizes before full deserialization.

Rust Best Practices

11. Multiple expect on mutex locks throughout

Pattern seen in:

  • gloas_proposal.rs:101, 104, 411, 417, 425, 431, 1042
  • pool.rs:pool.lock().expect("...")
  • ptc.rs:pool.lock().expect("...")

Use parking_lot::Mutex or handle poisoning gracefully.

12. gloas_block_production.rs test support exposes internals

The test_support module is pub with #[cfg(any(test, feature = "test-utils"))]. This exposes randao_reveal, sign_block, sign_envelope — signing functions that could be misused.

Recommendation: Make these pub(crate) or restrict further.

Minor Issues

13. decode_builder_config SSZ parsing is fragile (gloas_proposal.rs:147-162)

let offset = body
    .get(16..20)
    .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")));
return match offset {
    Some(20) => Ok(usize::from(body.len() > 20)),
    _ => Err(ApiError::BadRequest("the body is not a BuilderConfig")),
};

This assumes little-endian 32-bit offset at bytes 16-20, and that offset==20 means empty list. This is correct for SSZ but hardcoded without schema validation.

14. payload_attestation_data uses SystemTime::now() directly (ptc.rs:431-434)

let now_ms = std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH)
    .map(|elapsed| elapsed.as_millis() as u64)
    .unwrap_or(0);

This should use the store's configured genesis time and slot clock for consistency, not wall clock.

Positive Observations

  1. Good test coverage: Extensive tests for gloas block production, PTC duties, envelope publication, and validator client integration.
  2. Clear fork versioning: The ForkName checks are explicit and reject unknown forks.
  3. Self-build only: Correctly refuses builder bids (BUILDER_INDEX_SELF_BUILD), simplifying security surface.
  4. Envelope arrival tracking: The envelope_seen_ms cache correctly tracks earliest arrival for PTC timeliness.
  5. State transition validation: assemble_gloas_block runs process_block before signing, catching errors early.

Summary

Priority Issue Location
Critical No slashing protection for PTC/envelopes CLAUDE.md, ptc.rs
Critical Race condition in envelope timestamp beacon_envelope.rs:565
High Mutex poisoning panics Multiple files
High Cell proof verification skipped gloas_block_production.rs:296
Medium Busy-polling for block arrival gloas_proposal.rs:1034
Medium SystemTime::now() instead of slot clock ptc.rs:431
Low O(n×m) aggregation gloas_block_production.rs:245

The PR is generally well-structured for a major fork addition, but the missing slashing protection and mutex poisoning issues should be addressed before production use.


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

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

This reverts merge 1fef238, keeping its first parent's side.

#653 serves v2 proposer duties too, through its own handler, so tmp takes
that one instead of carrying both. #662 had since taught proposer_duties to
read a gloas head's lookahead; that stays, only the dependent-slot
parameter, the v2 route and its test go.
MegaRedHand added a commit that referenced this pull request Oct 5, 2026
…64-633-636-638-gloas-live

#653 sits on the squashed #643, whose liveness change tmp already holds
from the pre-squash branches, adapted to gloas and the shared attestation
pool. Every conflict in the files only #643 touches (verdict.rs, pool.rs,
store.rs) therefore keeps tmp's side.

validator.rs keeps tmp's imports and takes #653's v2 proposer duties
handler; proposer_duties still reads a gloas head's lookahead (#662). The
new validators/{validator_id} handler reads the registry through tmp's
iter_validators and balance accessors (validators() and balances() are gone
here), and reports execution_optimistic through block_is_optimistic, as its
siblings in states.rs do on tmp. docs/rpc.md keeps tmp's endpoint rows and
both #653's v2 dependent root and #662's gloas sentence.
@MegaRedHand MegaRedHand added the beacon Ethereum Beacon Chain client label Oct 6, 2026

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.

1 participant