Skip to content

feat(pbs): dial builders outside the config for ePBS requests - #506

Open
JasonVranek wants to merge 1 commit into
epbs-pr1from
epbs-pr2
Open

JasonVranek wants to merge 1 commit into
epbs-pr1from
epbs-pr2

Conversation

@JasonVranek

@JasonVranek JasonVranek commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #505. There, a bid or preferences request whose auth data matches no relay entry gets 400, so a validator key whose builder config names a builder the operator has not added to Commit-Boost gets no bids from it. This PR has Commit-Boost dial the builder the auth data names, behind an SSRF guard, and routes auth data written as a builder URL. It also answers the signed block with 202 once it has been forwarded, whatever the builders answer, which addresses a review comment on #505.

What it does

  • URL-form auth data. Besides the hostname (the builder-specs default), auth data that is an http or https URL matches the first relay entry with the same scheme, host and port. The path and the pubkey in the relay URL do not count.
  • Builder parameters. Either form may end in ? and form-encoded parameters for the builder, such as builder-a.example.com?filter=1, a way for a proposer to state a preference like transaction filtering. Commit-Boost routes on the part before ? and forwards the signed auth, parameters included, unchanged, so the parameters arrive signed by the proposer. A builder that does not accept them answers 400, so a proposer sets them only for builders that accept them. Without a ?, routing is unchanged.
  • Dialing builders outside the config. A bid or preferences request whose auth data matches no relay entry goes to the builder it names: https://<hostname> on the default port, or the URL's scheme, host and port. There is no setting to turn it off. Dialed requests count under relay_id="dial", and each dial logs the builder's origin and the addresses it connects to. A key without builder config sends Commit-Boost's own hostname, so Commit-Boost dials https://<its hostname> on port 443: 400 when that hostname resolves to a loopback or private address, as it usually does, and otherwise one request to whatever serves HTTPS on that host, which is Commit-Boost only behind a TLS proxy, where the loop guard below stops it.
  • The signed block gets 202 once it has been forwarded, whatever the builders answer. This answers a review comment on epbs builder api endpoints #505: relays may do nothing with these requests, so an apparent failure should not be reported as one, and the beacon node gossips the block anyway. The block still goes only to configured relays, since Commit-Boost keeps no auction state; a dialed builder gets it over gossip.
  • A test for epbs builder api endpoints #505's JSON passthrough. epbs builder api endpoints #505 returns the relay's bid body unchanged when the beacon node asks for the encoding the relay answered in. A new mock relay option serves its JSON bid pretty-printed, and a test checks that the beacon node gets exactly those bytes.

The ePBS docs page gets a section on builders outside the config, which also says what a key without builder config gets, the URL form and ? parameters in the routing reference, 500 in the metrics table for preferences only, and troubleshooting rows for both 400s and for that warning.

Scope

Commit-Boost now makes outbound requests to hosts named in request data. The proposer signs that data, but Commit-Boost does not verify the signature, so the target is treated as untrusted, and anyone who can reach Commit-Boost's PBS port can choose it:

  • The host is resolved once, after the timing headers and the deadline are checked; an IP address needs no lookup. The lookup and the dial share the bid's remaining time (timeout_register_validator_ms for preferences). If the name does not resolve, the answer is 400 and nothing is dialed. A lookup or dial setup that runs out of time counts as a builder that did not answer: no bid (204), or 500 for preferences.
  • At most 32 dial lookups run at once, and further requests get the same "did not answer" result. A lookup that times out keeps its blocking thread until the resolver returns, and configured relays' own lookups share that pool, so without the cap, requests naming slow-resolving hosts could stall the relays' DNS.
  • The dial connects only to the checked addresses (reqwest resolve_to_addrs), ignores HTTP(S)_PROXY and ALL_PROXY (a proxy would resolve the host again), follows no redirects, and sends none of the operator's relay headers.
  • A request carrying X-CommitBoost-Version, which every Commit-Boost dial sends, never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Configured relays still serve such a request, so chained Commit-Boosts keep working.
  • Auth data comes from the request, and a dialed builder's response comes from a host the request chose, so Commit-Boost logs both escaped, keeps a logged error body to 1 KiB, and logs a dial target as its origin only, without any user:password@.
  • A public address of the host Commit-Boost runs on is not recognized as its own. A dial to it reaches services listening on all interfaces, as a POST of an SSZ body to a fixed builder-API path.
  • Each dial builds its own HTTP client, because the address pin is per client, so it pays a client build and a fresh connection. Configured relays are unaffected.

Testing

  • cargo test --all-features: 402 passed (392 on part 1). Unit: URL-form auth data decoding (only http and https; opaque data such as builder-a:prod and host:port parse as URLs and are refused), the dial target for each auth data form (URL, hostname, IPv6 hostname, a hostname with ? parameters, an internal address, a non-hostname, a non-HTTP scheme), every resolved address checked, the lookup cap refusing a hostname but not an IP address, the dial pinned to the given address with redirects refused, the blocked-address table, and URL-form routing to relay entries and routing on the part before ?. End to end: a bid dialed with its auth, ? parameters included, unchanged, a bid naming Commit-Boost's own URL dialed once and answered with the second hop's 400, a request from a Commit-Boost not dialed but still served by a configured relay, preferences dialed, an unmatched localhost refused as loopback, and no lookup once the deadline has passed. A builder's 400 and 401 reach the beacon node even with an error body over the 1 KiB read cap. The signed block gets 202 when every builder rejects it, and a pretty-printed JSON bid reaches the beacon node byte for byte.
  • Reverting each change fails a test: dropping the Commit-Boost version header from dialed requests fails the dial-to-self test, which then loops until the deadline and answers 204, putting the 500 for a block no builder accepted back fails the all-reject test, and removing epbs builder api endpoints #505's passthrough arm fails only the passthrough test.
  • Not covered by a test: the proxy bypass (the test would set HTTP_PROXY in a process that runs other tests in parallel), the escaping in logs and the 1 KiB cap on a logged error body, the refusal when the dial setup leaves no time, and the lookup's timing: its timeout and the time it leaves the dial (a local lookup finishes inside tokio's 1 ms timer tick, so a name slow enough to matter needs the network).
  • Kurtosis, on the full stack at part 4's tip, which carries this PR's code: the cb-testing modes pass, and so does the live bid-stream gate against Helix. In the block-submission mode, the signed block was forwarded 17 times and buildoor accepted all 17. Dialing ran on a devnet too, with a test-only build that turns off the private-address check (cb-testing --assert dial; Docker addresses are all private). Each key's auth data was http://buildoor:8080, which no relay entry matches, so Commit-Boost dialed buildoor 95 times, for bid and preferences requests. All 17 observed slots were built from those bids, no dial was refused, and none looped.

Auth data that is an http(s) URL now also routes to the relay entry with
the same scheme, host and port. Either form may end in `?` and
parameters for the builder: Commit-Boost routes on the part before `?`
and forwards the signed auth, parameters included, unchanged.

When no relay entry matches, Commit-Boost dials the builder the auth data
names for a bid or preferences request: `https://<hostname>`, or the URL.
The target is resolved once, and the lookup and the dial share the
request's budget. A name that does not resolve, or any address that is
not public unicast, gets 400; a lookup or dial setup that runs out of
time counts as a builder that did not answer. At most 32 lookups run at
once, since a timed-out lookup keeps its blocking thread. The dial goes
only to the checked addresses, with no proxy and no redirects. A request
from a Commit-Boost never leads to a dial, so a dial that reaches a
Commit-Boost, this one included, goes no further.

Auth data, a dialed builder's error body and a dial target come from the
request, so they are logged escaped, the body capped at 1 KiB and the
target as its origin.

The signed block gets 202 once it has been forwarded, whatever the
builders answer, since the beacon node gossips it anyway. A block whose
bid came from a dialed builder, which is not a relay entry, would
otherwise always get 500. When no builder accepts the block, Commit-Boost
logs a warning.
@JasonVranek
JasonVranek added this pull request to stack #507 October 7, 2026 04:11

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