Skip to content

Stream Gloas execution payload bids over a websocket - #670

Closed
JasonVranek wants to merge 35 commits into
gattaca-com:od/gloas-stack-collapsedfrom
JasonVranek:jv/epbs-bid-stream
Closed

JasonVranek wants to merge 35 commits into
gattaca-com:od/gloas-stack-collapsedfrom
JasonVranek:jv/epbs-bid-stream

Conversation

@JasonVranek

@JasonVranek JasonVranek commented Oct 13, 2026 •

Copy link
Copy Markdown

Builds on #635. Commit-Boost's ePBS bid stream, merged in commit-boost-client #509, is a client of this endpoint.

What this does

Before Gloas, a proposer client can open header_stream and receive the relay's improving bid until its deadline, instead of making one get_header request. Gloas replaces get_header with getExecutionPayloadBid, a POST, and has no stream. This PR adds the Gloas equivalent:

GET /eth/v1/builder/execution_payload_bid_stream/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}

The request upgrades to a websocket. Each time the top bid changes, the relay sends one binary frame, [kind = 1][fork = 7 (gloas)][ssz SignedExecutionPayloadBid], the same layout as the header stream. When the stream ends, the relay sends a close frame. Without it, a client cannot tell a stream that ended from one that failed.

A GET has no body, so the client sends its SignedBuilderRequestAuth in an X-Request-Auth header, as standard padded base64 of the ssz.

Admission

Every check runs before the upgrade, so a refused request gets an HTTP status and no socket opens. The cheap checks run first, and the BLS verify runs last:

  • The slot must be a Gloas slot by the chain's fork schedule. The client's Eth-Consensus-Version header is not trusted for this.
  • The same validate_header_request checks as header_stream: the slot being bid for, a registered proposer, timing.
  • The path pubkey must be the slot's registered proposer. A wrong key is refused before any signature work.
  • admit_header_stream decides whether this client may stream.
  • X-Request-Auth is length-capped at the base64 size of the largest valid auth (5612 characters), then decoded.
  • Last, the auth's slot and BLS signature are checked. This check is shared with the POST endpoint.

Implementation notes

  • The first commit moves header_stream's schedule and polling loop into StreamSchedule, without behaviour changes, so both streams run the same loop. The second commit adds the bid stream on top.
  • The bid stream reuses the header_stream config and admit_header_stream on purpose. An ApiProvider sees the same inputs for both streams and cannot tell PBS admission from Gloas admission, so the two streams share one admission policy.
  • ExecutionPayloadBidStream joins the condensed ProposerApi route set, so a relay serving ProposerApi serves the stream. admit_header_stream still gates every connection.
  • Each poll passes the bid the stream last sent. When the top bid has not changed, the auctioneer answers BidUnchanged without building or signing a bid.
  • The auctioneer now returns the bid unsigned, and the API task signs it. This applies to the POST endpoint as well, so no BLS signature runs on the auctioneer thread. The POST's responses do not change. The bid reads prev_randao and gas_limit straight from the held payload instead of converting the whole payload to the Gloas shape. That conversion can only fail on extra_data over 32 bytes, which submission validation already refuses.
  • The stream stops early when the auction has moved past the request: a later slot, a different proposer, or a payload already being delivered. It keeps polling when there is no bid yet, or when the auctioneer has not yet reached the requested slot.
  • header_stream gets the same close frame in a separate change.

Tested

On a Gloas devnet running this branch (7d3fc65) and Commit-Boost 601b744 (main plus commit-boost-client #511), with every relay entry set to stream:

  • Commit-Boost took the bid from the stream in every slot with a bid, 12 of 12, and sent no HTTP bid request.
  • The relay ended all 12 no-bid streams with a close frame, and Commit-Boost answered 204 for each.
  • This branch refused 7 of 7 malformed upgrades with a 4xx, and no socket opened.

Known limitation

The stream sends until the client's deadline, Date-Milliseconds + X-Timeout-Ms. getExecutionPayloadBid asks the builder to respond by that time, and the client discards anything later, so a frame sent in the last one-way network trip before the deadline arrives too late. If the one-way latency is longer than the streaming window (300 ms by default), no frame arrives in time at all. Ending the stream one return trip early would fix this, but the relay would first have to measure that trip, for example with a ping over the open stream. header_stream uses the same schedule and has the same gap, so both are left for a separate change.

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 gattaca-com#489).
The SSZ dispatch short-circuited above the fork gate gattaca-com#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 (gattaca-com#590), so the bump from gattaca-com#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.
0w3n-d and others added 5 commits September 29, 2026 18:42
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.
Moves header_stream's schedule (handshake, window, start, end, interval)
into StreamSchedule, and its timed polling loop into StreamSchedule::run,
which calls a ProposerStream for each tick. build_frame takes any ssz bid.
The getExecutionPayloadBid request auth check (the auth's slot, then its
BLS signature) moves into verify_request_auth.

No behaviour changes: the terminating check moves from send_bid to the
top of the loop, where it ran before every send_bid call anyway.
GET /eth/v1/builder/execution_payload_bid_stream/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}
upgrades to a websocket and streams the proposer's Gloas bid as it
improves, the way header_stream does for get_header. Each update is one
binary frame, [kind = 1][fork = gloas][ssz SignedExecutionPayloadBid].
The stream ends with a close frame: a client cannot tell a dropped
socket from a failed stream.

A GET carries no body, so the SignedBuilderRequestAuth arrives in the
X-Request-Auth header as standard padded base64 of its ssz. Every check
runs before the upgrade, so a refused request is a plain HTTP status and
no socket opens. The slot must be a Gloas slot by the chain's schedule,
and the path pubkey must be the slot's registered proposer. The auth
header is length-capped and decoded only after these cheap checks, and
its BLS signature is verified last.

The stream reuses header_stream's config and admit_header_stream. An
ApiProvider decides admission from the same inputs for both streams and
cannot tell them apart, so the two share one admission policy. The
route joins the condensed ProposerApi set.

Each tick passes the bid the stream last sent. When the top bid is
unchanged, the auctioneer answers BidUnchanged without building a bid.
The auctioneer now returns the bid unsigned and the API task signs it,
for the POST endpoint too, so no BLS signature runs on the auctioneer
thread. The POST's responses are unchanged. The bid reads prev_randao
and gas_limit from the held payload instead of converting it to the
Gloas payload shape. That conversion can only fail on an extra_data over
32 bytes, which submission decoding already refuses.

The stream ends early when the auction has moved past the request: a
later slot, another proposer, or a payload already being delivered.
@0w3n-d
0w3n-d force-pushed the od/gloas-stack-collapsed branch 3 times, most recently from 0d2af83 to 6ce54e9 Compare October 19, 2026 17:01
@0w3n-d
0w3n-d deleted the branch gattaca-com:od/gloas-stack-collapsed October 19, 2026 17:07
@0w3n-d 0w3n-d closed this Oct 19, 2026
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