Skip to content

ePBS (Gloas) support, part 3: bid streaming - #509

Open
JasonVranek wants to merge 7 commits into
epbs-pr2from
epbs-stream
Open

JasonVranek wants to merge 7 commits into
epbs-pr2from
epbs-stream

Conversation

@JasonVranek

@JasonVranek JasonVranek commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #506. A relay entry with get_header = "stream" reads that relay's bids over a websocket. This PR:

  • runs the relay's HTTP getExecutionPayloadBid alongside its stream on every request rather than only retrying HTTP if the handshake fails with time left (what the current PBS implementation does and is fragile for high latency connections).

Relays with get_header = "http", the default, behave as before. No new config keys or metric names.

How it works

  • ePBS stream: each bid request to a streaming entry also opens ws(s)://<relay>/eth/v1/builder/execution_payload_bid_stream/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}. The SignedBuilderRequestAuth goes in X-Request-Auth (padded standard base64 of its SSZ), since an upgrade has no body. Frames use the PBS header_stream layout.
  • Which bid wins: the higher value + execution_payment, the stream's on a tie. Each transport offers its latest bid that decodes (PBS: the latest of the last 8 that validates). The beacon node still validates the bid and applies the key's cap.
  • Timing: a streaming request returns when the HTTP request has answered and the stream has ended, or at the beacon node's deadline minus proposer_deadline_buffer_ms. The handshake's X-Timeout-Ms subtracts an estimate of one trip from the relay, so the last bids arrive in time: without it, during testing, 2 or 3 of 7 rising bids were lost per slot at 100 ms one-way latency, and all 7 in 7 of 8 slots at 300 ms.
  • Unchanged edges: a builder Commit-Boost dials (feat(pbs): dial builders outside the config for ePBS requests #506) is asked over HTTP only. A builder's HTTP 400 or 401 reaches the beacon node only when the stream has no bid. The stream's SSZ bid passes through unchanged.

Commits

  1. merge: the PBS bid stream race: five commits written on v0.11.0-rc3 (a7b273d..48ebe07). The ePBS stream reads through the generic stream reader they add, so they land together. git show --remerge-diff <merge> shows only the hand resolutions.
  2. feat(pbs): stream the ePBS bid alongside the HTTP bid request: the ePBS stream, tests and docs.

Testing

  • cargo test --all-features: 427 pass (413 at the merge alone).
  • Kurtosis Gloas devnet (Lodestar, geth, buildoor) on this tip: default, p2p, block-submission, builder-down, trusted-payment, manual-config, config-matrix, relays-only and dial modes pass (dial on a test build allowed to dial private addresses). Their relays are HTTP only, so these show such relays behave as on feat(pbs): dial builders outside the config for ePBS requests #506.
  • Live against Helix streaming ePBS bids, over 24 slots: the streamed bid won all 12 bid slots; the 12 slots with no bids were answered 204 with no failed streams; Helix ended every stream with a close frame. Helix refused with a 4xx all 7 bad handshakes sent to it directly (wrong signer or pubkey, corrupted signature, wrong slot, missing or malformed X-Request-Auth).
  • Not tested: a wss relay (the devnet streams over plain ws), and real network latency (the loss figures above come from netem against the same Helix setup).

The get_header stream's handshake, TLS config, frame format and read loop
move to bid_stream.rs, behind a reader that holds the last frame a parse
closure accepts. get_header decodes and validates that frame at the
deadline, as before, so the result is unchanged. Another bid stream can
use the same reader with its own parse closure.

RelayClient::get_header_request becomes get_header_stream_url, which
returns the stream URL only for a relay that streams.

test_decode_streamed_bid goes: every stream test decodes a streamed bid.
The stream kept the latest frame that parsed and validated only that one
at the deadline, so a last bid that failed validation (a bad signature, the
wrong parent, below min_bid) discarded a valid bid the relay had sent
before it. The reader now holds the newest 8 frames, validated newest
first at the deadline, and the first that passes is returned. The usual
case is still a single validation. When all 8 fail, the stream returns
the newest one's error, as before.

The cap bounds the memory a stream holds and the validation left for the
deadline. A relay that sends 8 invalid bids after a valid one loses the
valid one.
A relay with no bid for the slot can answer the stream handshake 204, as
it answers get_header. The stream treated every 2xx without an upgrade as
a failed connect, recorded it as 556 and fell back to HTTP. A 204 is now
no bid: recorded as 204, no fallback. Any other 2xx without an upgrade is
still a failed connect.
A relay with get_header = "stream" now also gets the relay's normal HTTP
get_header, timing games included, sent at the same time as the stream
handshake. The two are separate candidates in the bid selection: the
stream's latest valid bid and the HTTP bid compete like bids from any two
relays. A stream whose handshake times out or is refused, or that breaks
before a bid, leaves the HTTP bid in the auction, and an operator with a
slow link to the relay no longer needs a second HTTP entry for it.

This replaces the HTTP fallback that ran only when the handshake failed
with bid window left. cb_pbs_relay_stream_fallback_total keeps its name and
now counts every stream that failed while the HTTP request ran alongside,
including a window whose held bids all fail validation. relay_header_value
reports the relay's best bid across both transports, and relay_last_slot
is set when either delivers one. The four relay_stream metrics gain an
endpoint label (get_header_stream) and help text that holds for any bid
stream, and the auction winner log names the transport.
…line

A relay honouring the timing headers ends the stream at Date-Milliseconds
+ X-Timeout-Ms, which CB set to its own read deadline, so every frame sent
in the last one-way latency arrived after CB stopped reading. Measured
against Helix with 7 rising frames: at 100 ms one-way 2 frames were lost,
at 200 ms 4, and at 300 ms or more all 7, so HTTP served a 1.1 s old bid.

The stream now opens TCP first and takes the connect time as the round
trip (SYN to SYN-ACK, no clock skew), stamps Date-Milliseconds then, and
sends X-Timeout-Ms as the time left to CB's deadline less half that round
trip, before completing TLS and the upgrade on the same connection. CB's
own deadline is unchanged; with nothing left after the connect, the
stream times out as before. The reader adds the timing headers itself,
so the ePBS stream gets the same behaviour.
A relay with get_header = "stream" now also streams its ePBS bids. Each
bid request addressed to it opens
ws(s)://<relay>/eth/v1/builder/execution_payload_bid_stream/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}
on the PBS stream reader, with the SignedBuilderRequestAuth as padded
standard base64 of its SSZ in X-Request-Auth, and races it against the
relay's HTTP request. The bid with the higher value + execution_payment
wins, unclamped, and the stream's bid wins a tie. A builder's HTTP 400
or 401 reaches the proposer only when neither leg has a bid. A builder
Commit-Boost dials is asked over HTTP only. The stream's bid keeps its
SSZ frame, so a beacon node that asks for SSZ gets it unchanged. Each
frame is decoded on arrival, so the stream holds only its newest bid.

A streamed request can last until the deadline less
proposer_deadline_buffer_ms, which #506 requires to be above 0. A failed
stream is logged and counted in one place, and the race's docs mark what
is unreleased.

The stream reports the bid stream series under
endpoint="get_execution_payload_bid_stream". The ePBS page gets a Bid
streaming section and two troubleshooting rows: a relay that streams
get_header but not ePBS bids, and a stream that times out or cannot
connect.
@JasonVranek
JasonVranek added this pull request to stack #507 October 9, 2026 19:27

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.

1 participant