Skip to content

Serve, validate and build Gloas blocks end to end (collapses #514–#629) - #635

Draft
0w3n-d wants to merge 33 commits into
developfrom
od/gloas-stack-collapsed
Draft

0w3n-d wants to merge 33 commits into
developfrom
od/gloas-stack-collapsed

Conversation

@0w3n-d

@0w3n-d 0w3n-d commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

This PR puts the 12 Gloas stack PRs that had no reviews into one PR, rebased onto develop. It replaces #514, #515, #562, #564, #570, #571, #575, #578, #580, #584, #587 and #629. It is stacked on #509.

With these changes, helix can run a full Gloas (ePBS) auction end to end on a devnet:

  • The relay accepts Gloas submissions and holds each builder's payload. It serves real execution payload bids and fills the proposer's committed bid with a signed envelope that it broadcasts.
  • Each submission carries the builder's EIP-7928 block access list. The list goes to the SSZ simulator, which checks it against the list that execution produces and fails closed if they differ.
  • The helix-builder simulation role validates Gloas/Amsterdam blocks, and the building role builds and submits them.
  • Payload attributes and bids are keyed by parent hash and beacon root, not by parent hash alone. Two forks that share an execution parent no longer overwrite each other.

Differences from the original PRs

The rebase is not purely mechanical, because develop changed under the stack:

What this deliberately does not do

🤖 Generated with Claude Code

helix's ExecutionPayload/SignedBidSubmission is its own bounded-list
builder<->relay wire shape, shared unchanged across Bellatrix-Fulu; it
is not a mirror of the real per-fork consensus SSZ types. Route the
Gloas fork through the same decode path Fulu already uses instead of
erroring, and add conversion functions producing the real,
progressive-list Gloas consensus types (ExecutionPayloadGloas,
ExecutionRequestsGloas) for use at the outbound bid/envelope boundary.

block_access_list, builder_deposits, and builder_exits are left empty
(TODO(gloas): EIP-7928/EIP-8282, no producer path yet).
…bmissions

getExecutionPayloadBid now reads the winning submission out of the same
bid-sorter/payloads map get_header already reads, converts it via the
prior step's Gloas conversion functions, and signs a real
SignedExecutionPayloadBid under helix's own configured builder identity.

submitSignedBeaconBlock's GloasPayloadStore placeholder is replaced by a
real auctioneer round trip (Event::TakeHeldGloasPayload) that looks up
the same payload by block hash and converts it for envelope
construction. GloasBuilderIdentity is now constructed once in main.rs
and shared between the proposer API and the auctioneer.

execution_payment is set equal to value (no payment-split product need
yet, per #489).
The SSZ dispatch short-circuited above the fork gate #517 added, so a
Gloas submission reaching an SSZ simulator was validated as Fulu.

Gate both dispatch kinds, and have the simulation role refuse a fork it
does not understand instead of discarding `decoder_params.fork_name`.

Answer 501, not 400. The relay maps a 400 body to a demotable error, and
this is helix's own limitation.
`to_lighthouse_gloas_payload` left the EIP-7928 list empty, so the
envelope the relay broadcasts could never be valid. Only an execution
client can produce the list, so it travels on the submission.

A separate `SignedBidSubmissionGloas` rather than a fork-gated field:
`Encode` is derived on `SignedBidSubmission`, so an extra field would
change the bytes for every fork.

Refuse Gloas with bid adjustments. Combining every extension with Gloas
multiplies the wire shapes, and a testnet does not need adjustments.
Set the header's EIP-7928 block access list hash and EIP-7843 slot number
from the submission, and compare the list against the one execution
recomputes. Route Gloas to SSZ simulators again.
Set the header's slot number from the proposal slot and carry the block
access list ethrex records, then submit it in the Gloas shape. Size the
payout reserve for EIP-8037's account-creation state gas, without which
a fee recipient's first payment runs out of gas.
Records the genesis, relay and builder configuration a Gloas testnet
needs, including the requirements that are not discoverable from the
code: the EIP-8282 predeploys, ssz_url on every simulator, and the
Amsterdam gas cost of a first payment.
…am-devnet-8

Develop already pins an ethrex past v26.0.0 (#590), so the bump from #580
is dropped. The two predeploys moved between devnet-7 and devnet-8, so the
test fixture now reads them from ethrex instead of hardcoding them.
`rayon` is a default feature of ethrex-blockchain, ethrex-common and
ethrex-vm. The pins here set `default-features = false`, and only the
`ethrex` cmd crate (deliberately not a dependency) adds it back, so the
embedded node has never had it.

It is not optional. ethrex gates the parallel BAL execution path on it
(`crates/vm/backends/levm/mod.rs:485`) but not the caller that decides
whether to create the merkleizer channel
(`crates/blockchain/blockchain.rs:1026`). With a BAL supplied and both
`bal_parallel_*` options at their defaults, the caller skips the channel
and LEVM then drops the BAL and takes the sequential path, which needs
that Sender:

  Error executing block: sequential execution path called without a
  merkleizer Sender

So every `newPayload` that carries a BAL fails and the node cannot
follow the head. P2P sync is unaffected because it passes no BAL.

Also restores the mempool prewarm and the block warmer, both of which
log or no-op when the feature is off.
Gloas drops `parent_block_number` from the event, so every event failed
to parse and the relay never got a slot's attributes. The field is now
optional, and stays quoted on the wire for the forks that still send it.
Nothing reads it yet; a future consumer must take the number from the
execution layer, because Gloas will never supply it.
An empty duty list built `VALUES  ON CONFLICT`, which Postgres refused,
so a relay with no registered proposer logged a failure every slot. The
aborted transaction changed nothing, so returning early keeps the same
result without the error.
The building role's HTTP client panicked on the main thread with
"Could not automatically determine the process-level CryptoProvider",
which killed the process and took the simulation role and the embedded
node with it. The relay and the data API already install the provider at
startup; the builder now does the same.
The bid's `value` and `execution_payment` are gwei, checked against the
builder's on-chain balance, but the relay put a wei figure in both. A
0.001 ETH bid claimed a million ETH, and anything above 18.4 ETH pinned
to u64::MAX. The proposer is paid in-block, so `value` is now 0 and only
`execution_payment` carries the amount, converted to gwei.
Lodestar types `getExecutionPayloadBid` with `VersionMeta` and parses the
JSON body as `{version, data}`, so a bare `SignedExecutionPayloadBid`
fails with "expected key version is undefined". `getHeader` already
returns `ForkVersionedResponse`; the Gloas endpoint now does the same.

The SSZ path is unchanged: it already sets `Eth-Consensus-Version`.
Helix revealed the payload the moment the proposer handed back its block,
which is at the top of the slot. An equivocating proposer can build a
competing block on a payload revealed that early, before any attestation
weighs behind the honest block. Gloas puts the attestation deadline at
25% of the slot (ATTESTATION_DUE_BPS_GLOAS) and the payload deadline at
50%, so hold the reveal until the attestation deadline and let the
proposer have its 202 straight away.

Also publish the proposer's block to our own beacon nodes. Post-Gloas
publishBlockV2 takes a bare SignedBeaconBlock, not SignedBlockContents.
This is what makes the block root known by the time we reveal, instead of
waiting for the block to come back to us over gossip.
Two ways a proposer could redeem two blocks against one bid, neither of
which helix noticed.

First, it could send helix two different signed blocks. `get_for_redemption`
is a lookup, not a take, so the second one built and revealed a second
envelope. Remember the block root committed to per slot and refuse a
different one. Because the reveal is now held until the attestation
deadline, an equivocation seen before then aborts the pending reveal, so
the payload is withheld rather than merely logged. Registration happens
only after the block is known to redeem our bid: this endpoint does not
verify the proposer's signature, so an unvalidated block must not be able
to withhold a legitimate reveal.

Second, it could gossip a competing block helix never sees. Publish the
envelope with broadcast_validation=consensus_and_equivocation so the
beacon node refuses to broadcast a payload for a block it has seen a
competing version of. The default is `gossip`, which skips that check.
submitSignedBeaconBlock authenticated nothing: no signature check, and no
auth middleware on the route. The bid's block hash becomes public as soon
as the proposer gossips its block, so anyone could redeem a bid on the
proposer's behalf.

That was survivable while a forged block only produced an envelope the
beacon node would reject. With the equivocation guard it is not: a forged
block with a different root reads as an equivocation and aborts the real
reveal, so any observer could make helix withhold a legitimate payload.

Carry the proposer helix bid to alongside the held payload and check the
block's signature under DOMAIN_BEACON_PROPOSER before anything else acts
on it. A gossiped payload carries no bid trace and so no proposer, and is
refused rather than redeemed unverified.
`BuilderRequestAuth.data` is opaque and nothing reads it, so we do not
know what clients put there. Log its length and a bounded prefix, on both
getExecutionPayloadBid and submitBuilderPreferences, along with the slot
the auth is signed for.

The field runs to 4096 bytes, so the rendering is truncated and says so
while still reporting the real length.
@0w3n-d
0w3n-d force-pushed the od/gloas-stack-collapsed branch from c8d89f5 to 0e47b77 Compare September 30, 2026 11:12
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.

1 participant