Repository navigation
Stream Gloas execution payload bids over a websocket - #670
Closed
JasonVranek wants to merge 35 commits into
Closed
JasonVranek wants to merge 35 commits into
JasonVranek wants to merge 35 commits into
Conversation
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.
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
force-pushed
the
od/gloas-stack-collapsed
branch
3 times, most recently
from
October 19, 2026 17:01
0d2af83 to
6ce54e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_streamand receive the relay's improving bid until its deadline, instead of making oneget_headerrequest. Gloas replacesget_headerwithgetExecutionPayloadBid, a POST, and has no stream. This PR adds the Gloas equivalent: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
SignedBuilderRequestAuthin anX-Request-Authheader, 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:
Eth-Consensus-Versionheader is not trusted for this.validate_header_requestchecks asheader_stream: the slot being bid for, a registered proposer, timing.admit_header_streamdecides whether this client may stream.X-Request-Authis length-capped at the base64 size of the largest valid auth (5612 characters), then decoded.Implementation notes
header_stream's schedule and polling loop intoStreamSchedule, without behaviour changes, so both streams run the same loop. The second commit adds the bid stream on top.header_streamconfig andadmit_header_streamon purpose. AnApiProvidersees the same inputs for both streams and cannot tell PBS admission from Gloas admission, so the two streams share one admission policy.ExecutionPayloadBidStreamjoins the condensedProposerApiroute set, so a relay servingProposerApiserves the stream.admit_header_streamstill gates every connection.BidUnchangedwithout building or signing a bid.prev_randaoandgas_limitstraight from the held payload instead of converting the whole payload to the Gloas shape. That conversion can only fail onextra_dataover 32 bytes, which submission validation already refuses.header_streamgets 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:
204for each.Known limitation
The stream sends until the client's deadline,
Date-Milliseconds + X-Timeout-Ms.getExecutionPayloadBidasks 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_streamuses the same schedule and has the same gap, so both are left for a separate change.