Skip to content

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

Open
MegaRedHand wants to merge 31 commits into
fix/beacon-foreign-vc-endpointsfrom
feat/beacon-skip-randao-verification
Open

MegaRedHand wants to merge 31 commits into
fix/beacon-foreign-vc-endpointsfrom
feat/beacon-skip-randao-verification

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Why

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

What

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

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

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

Tests

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

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

Open question

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

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

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

Security & Correctness

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

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

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

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

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

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

Verification: Infinity point validation in RPC layer

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

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

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

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

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

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

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

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

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

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

API Consistency Issue

assemble_gloas_block_on_bid lacks infinity reveal validation

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

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

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

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

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

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

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

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

Code Quality & Maintainability

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

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

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

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

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

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

Duplicate skip_randao() test helper

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

randao_verification function naming

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

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

Error Handling

Inconsistent error messages for infinity reveal without flag

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

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

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

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

And in proposal.rs the test at line 665:

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

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

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

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

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

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

Performance

No performance concerns. The changes add:

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

Testing

Good coverage

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

Missing test: assemble_gloas_block_on_bid with infinity reveal

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

Minor Issues

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

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

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

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

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

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

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

Summary

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

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


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

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

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

Findings

  • No blocking issues found.

Notes

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

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


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 678, honor skip_randao_verification on block production

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

What it does

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

Correctness and security

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

Minor notes (non-blocking)

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

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

The change looks good to merge.


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

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

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

Semantic resolutions:
- #665's `active_balances` and #678's `options` both kept on every
  `process_block`, `options` last; `assemble_*` keep tmp's inputs
  (operations, sync aggregate) plus `options`.
- fork_choice `CountingView` test impl takes #658 as it landed on bci
  (forwards `contains`).
- #678's v3 tests use tmp's fixtures: `SharedSyncCommitteePool`,
  `OwnVersion`, `fake_execution_client(0)`.
- p2p scoring test sets `SwarmConfig::agent_version` (#675).
…ences, from JSON

`Builder` serialized `version`, `balance`, `deposit_epoch` and
`withdrawable_epoch` as bare numbers, which the Beacon API wants quoted
(this also affected the gloas state's JSON). Give it the same
`quoted_or_bare` adapters the other containers use, and `Deserialize`, along
with the proposer preferences containers a builder-market endpoint takes in a
request body.

Round-trip tests cover the bid, the preferences and the builder, and the
example bid and preferences events from beacon-APIs' event stream parse.
…and in parallel

Adds the shared `BuilderMarket`, the `execution_payload_bid` and
`proposer_preferences` gossip rule modules, the p2p triage, verdict and
publish plumbing, the `RpcToP2P` publish methods, and the Beacon API route
modules, all with the final signatures and placeholder bodies: every rule
answers `Ignore(NoConsumer)`, the market holds nothing, and no route is
registered yet.

Splitting the wiring from the logic lets the rules and market, the p2p
handling and the Beacon API be filled in on separate branches without
touching each other's files. New reason variants, `Validated` arms and
`RpcToP2P` methods sit beside the envelope ones, and everything else is a new
file, so a parallel sync committee branch merges cleanly.
Bids, proposer preferences and revealed execution payloads are judged and
consumed from three places (gossip rules on blocking threads, the Beacon API
and block production), so they live in one shared object rather than in the
chain actor.

The market keeps the spec's seen sets and best-bid bar apart from the pooled
bids: the pool is truncated to the top values per parent, but the bar a new
bid must strictly beat is not. Known payloads are a bounded LRU, and every
collection is bounded so spam cannot grow memory.

Also adds the test-utils fixtures (builder registry state, bid and
preference signing, envelopes) the gossip rules, p2p and rpc tests build on.
… blocks on a bid

Gives the builder market its rules: the spec's execution_payload_bid and
proposer_preferences gossip validation, split into cheap and stateful halves
like the other topics, plus block assembly on a builder's bid.

The rules read cached states only and never queue, so a verdict gossipsub
waits on is never stalled by a replay. Two shortcuts keep that honest: the
parent's own state stands in for the advanced one within its epoch (gloas's
process_slot touches none of the fields the later rules read), and a
proposer's lookahead comes from the head state when it shares the dependent
root, since a dependent block about an epoch old is usually out of the state
cache. Preference signatures verify under the fork versions on both sides of
the gloas boundary, because clients disagree there.

assemble_gloas_block_on_bid and bid_is_includable let produceBlockV4 commit a
block to another builder's bid; the pre-filter keeps a bid that would fail
process_block from being tried.

Both gossip vector handlers now run (44 and 26 mainnet cases pass). The
runner records a delivered envelope the way the spec generators do, without
re-verifying it, and applies the fixture's finalized-checkpoint override to
the stored states, which is how those generators activate builders.
docs/spec_deviations.md lists what departs from the spec.
…nces

Subscribe the execution_payload_bid and proposer_preferences topics under
gloas digests only, decode them, triage them (size cap, decode, cheap rules)
and hand the rest to the blocking pool on a permit pool of their own so a bid
burst cannot starve blocks, columns or attestations. Neither type reaches the
chain actor; accepted ones are recorded in the shared builder market.

Envelopes accepted from gossip, and envelopes this node publishes itself, are
recorded as known payloads, since bid validation reads them and gossip never
echoes a node's own message. Bids and preferences are published on the digest
of their own slot, so preferences for the first gloas epoch go out on the
gloas digest during the epoch before the fork.
An engine that wants its own payload used whatever a builder pays says so
with this flag; dropping it would let a bid comparison override that.
Add POST execution_payload_bids, POST proposer_preferences and POST
states/{id}/builders. Bids and preferences run through the gossip rules and
land in the shared builder market that p2p also fills.

produceBlockV4 now reads the real BuilderConfig, builds locally when it has an
engine and takes the best pooled bid when the config's min_bid and boost factor
say so (the local payload wins ties, and shouldOverrideBuilder keeps it). A
bid-won block comes back bare and caches nothing, since the builder reveals the
payload. A self-built payload takes its fee recipient and gas target from the
proposer's signed preferences when the market holds them.
…o end

Cover the three endpoints, the verdicts they turn into 400s, idempotent
resubmission, SSZ bodies, produceBlockV4 choosing between a stand-in engine's
payload and a pooled bid, the preferences-driven fee recipient and gas target,
and the envelope endpoints' behaviour for a block built on a bid.
CLAUDE.md still said gloas added two topics and that duties were self-build only.
The spec ignores a bid whose value does not exceed the best recorded for the
same (slot, parent hash, parent root), so a value-10 bid after a value-10 one
answered NotHighestBid, not Accept. The fixture was wrong; the rule is spec.
…zed epoch

The scene put a funded, active builder in an epoch-1 state: it is active only
once the finalized epoch passes its deposit epoch, but a chain never finalizes
past the previous epoch, so no real chain holds that state. Advancing it
through the end of epoch 1 underflows get_previous_epoch - finalized epoch,
which the spec treats as invalid and a stricter get_finality_delay now errors on.

scene_at builds the parent in a later epoch by advancing with nothing
finalized and finalizing epoch 1 afterwards. The epoch-crossing bid test uses
it with its slots and epochs shifted by one epoch; its assertions are unchanged.
The bid and preferences scenes hardcoded mainnet slots (parent 32, bid 34,
epochs at 64 and 96), but their base state sits at SLOTS_PER_EPOCH. On the
minimal preset that is slot 8, so every bid slot fell outside the parent's
two-epoch proposer lookahead and 43 tests panicked or hit the wrong rule.
Express each slot through SLOTS_PER_EPOCH and the scene's own PARENT_SLOT,
as the other gossip tests do, and place the stray and stateless dependent
blocks before the dependent slot so they reach the rule they test.
beacon-APIs defines `skip_randao_verification` on produceBlockV3 and
produceBlockV4 as a flag with no value that asks the node to build on the
point-at-infinity reveal without checking it. We accepted the flag on v4
and ignored it, and v3 did not read it, so a request carrying it with the
infinity reveal was refused (v4: 400 `invalid randao_reveal`, v3: 500),
where Lighthouse and Prysm build the block.

The beacon state transition now takes a `BlockProcessingOptions` whose
`RandaoVerification` lets block production mix in a reveal it did not
check. `state_transition` always passes the default, so import still
verifies every reveal, and every other check on a produced block stays.
Parsing follows Lighthouse: a value on the flag, or the flag with a reveal
other than the point at infinity, is a 400.
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-skip-randao-verification branch from 4100d21 to 1c36c33 Compare October 20, 2026 17:32
MegaRedHand added a commit that referenced this pull request Oct 21, 2026
Squash of feat/beacon-skip-randao-verification at 1c36c33 (merge-base 194deb0).

Adapted onto #668: the fork-choice block-production test passes the
processing options.
…verride

The builder market gossip vectors finalize epoch 1 through the runner's
finalized_checkpoint override, then walk the head from the justified
root. Since main's #627, moving finality prunes LiveChain below the
finalized block, which left the genesis justified checkpoint pointing at a
pruned row: every bid and preferences case failed with "computing the
head: SpecAssert(\"block_root in store.blocks\")". A store's justified
checkpoint is never behind its finalized one, so for those cases the
override now moves it up too.
Base automatically changed from feat/beacon-gloas-builder-market to fix/beacon-foreign-vc-endpoints October 26, 2026 16:23
…rification

Brings in main (the gloas stack) through the parent, which also now holds
#669 as a squash. Merged against that squash as the base, since this
branch already carries feat/beacon-gloas-builder-market, whose tree the
squash matches: no conflicts.
@MegaRedHand
MegaRedHand added this pull request to stack #702 October 26, 2026 19:03
# Conflicts:
#	crates/net/rpc/src/beacon/gloas_proposal.rs
# Conflicts:
#	crates/net/rpc/src/beacon/gloas_proposal.rs
#	crates/net/rpc/src/beacon/proposal.rs
# Conflicts:
#	crates/net/rpc/src/beacon/builder_market_tests.rs

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants