Skip to content

ePBS Implementation - #496

Draft
JasonVranek wants to merge 93 commits into
mainfrom
epbs
Draft

JasonVranek wants to merge 93 commits into
mainfrom
epbs

Conversation

@JasonVranek

Copy link
Copy Markdown
Collaborator

No description provided.

Adds the ePBS (gloas) builder-API surface and the strict header rulings,
rebased onto main so it sits on top of the SSZ rewrite (#468) and the
fork-from-slot fix (#487).

ePBS endpoints:
- getExecutionPayloadBid, submitBuilderPreferences, submitSignedBeaconBlock,
  with per-builder routing by SignedRequestAuth.data (no Eth-Builder-Url).
- SSZ-first request/response with JSON fallback; the bid poll ladder honors the
  proposer's timing headers.

Header discipline (builder-specs #165):
- Eth-Consensus-Version required for JSON and SSZ on the request-auth endpoints;
  absent -> MissingVersionHeader, present-but-unsupported -> InvalidVersionHeader,
  both 400. The accepted set is Gloas only; any other fork name is a client bug.
- A relay bid whose response fork is not Gloas on the Gloas-only endpoint is a
  bad relay response: dropped, never forwarded under the wrong fork.

Status + observability:
- zero addressed builders accepting an ePBS submission maps to 500, not 502
  (neither endpoint declares 502); new PbsClientError::NoBuilderResponse.
- decode/accept rejections counted in BEACON_NODE_STATUS via record_client_error;
  dropped relay responses in pbs_relay_invalid_response_total{reason,endpoint,relay_id}.

Legacy PBS, the websocket get_header stream (#483), and the #487 fork fix are
unchanged. Suite green, clippy clean.
… auth)

builder-specs types/gloas/builder_preferences.yaml declares the container as
[preferences, auth]; the struct had them reversed, so its SSZ encoding and
tree-hash did not match the spec. Swap the fields and re-pin the spec-vector
test to the canonical examples/gloas/builder_preferences_request.ssz fixed-part
bytes.
A zero-length auth.message.data is invalid per builder-specs
types/gloas/request_auth.yaml (pattern {1,4096}) and addresses no builder. It
was only caught incidentally by the relay demux and could slip through a
catch-all relay match. Add a shared validate_auth_data check (EmptyAuthData ->
400) run first in both the bid and preferences auth validators.
External KM projection tooling round-trips auth_data through
match_relays_by_auth_data, so pin the behaviors it relies on: userinfo
and default-port are ignored when matching a relay entry URL, configured
expected_auth_data takes exact-byte precedence per relay (cross-form
collision with another relay's URL bytes yields the union set), and one
auth_data may match several relays sharing the same configured bytes.
…anking clamp

beacon-APIs #630 makes the beacon node the enforcer on the ePBS path: it
MUST-rejects a bid below the per-key min_bid and values every bid at
value + min(execution_payment, max_execution_payment), clamping an
over-claimed trusted payment instead of rejecting it. CB's copies of
both checks therefore over-reject: a bid the BN would clamp-and-consider
or floor-check per key was dropped outright (worse still, the old
default cap of 0 rejected every nonzero execution payment). The legacy
get_header path keeps its own min_bid check (no per-key BN enforcement
exists there).

Since CB returns a single winner, the cap survives as a RANKING clamp so
CB's winner agrees with the BN's valuation: bids rank at
value + min(execution_payment, relay cap), where the per-relay
max_execution_payment_gwei overrides the global one. The config default
becomes u64::MAX (unclamped, matching the spec's MAX_EXECUTION_PAYMENT);
a 0 default would zero out every execution payment in ranking.

Dead plumbing removed: ValidationContext.{min_bid_gwei,
max_trusted_bid_gwei}, the validate_header_data params, the
TotalPaymentTooLow/TrustedBidTooHigh error variants, and the unread
HeaderInfo payment fields.
…nfig

BuilderConfigDoc/BuilderEntryDoc follow keymanager-APIs builder_config:
Uint64s as JSON strings, auth_data as 0x-hex, omitted fields off the wire.
CanonicalDoc compares stored docs by value (hex decoded, entries sorted,
Uint64 strings parsed) since the spec promises neither entry order nor hex
case. The overlay carries the operational side (advertised_url, VCs,
per-mux fallbacks) that does not belong in the CB fleet config.
…unknown mux keys

Three config additions for the ePBS KM builder_config flow:

- PbsConfig.advertised_urls: CB's externally-reachable URLs, consumed by
  the ePBS pipe self-URL guard (an unconfigured key's auth_data defaults
  to CB's own URL; the guard must recognize it instead of self-dialing).

- MuxConfig.builder_boost_factor and MuxConfig.min_bid_eth
  (as_opt_eth_str, same codec as the global min_bid_eth): projection-only
  fields consumed by KM tooling that projects the mux config into per-key
  keymanager builder_config docs; the PBS runtime parses them, logs an
  INFO note at mux load, and otherwise ignores them.

- warn-on-unknown [[mux]] keys: MuxConfig is shared with the legacy
  get_header path, so serde(deny_unknown_fields) would reject
  previously-valid configs; instead the config loaders make a best-effort
  pass over the raw TOML and WARN on any unrecognized mux key, giving
  typo visibility without a breaking change.
project() turns a CB mux config plus overlay into per-key KM docs: one
entry per auth_data equivalence class (identical candidate bytes:
expected_auth_data, else the configured relay URL with userinfo stripped
by string surgery so no normalization touches the bytes), builder_pubkeys
as the class union, entries sorted by (url, auth_data bytes).

The raw TOML is parsed alongside cb-common's parser because RelayEntry
holds a normalized Url while the auth_data convention wants the URL bytes
exactly as configured. MuxProjectionFields is the seam for the upcoming
MuxConfig builder_boost_factor/min_bid fields; the overlay per_mux map is
the fallback source until then. File key-loaders resolve offline; HTTP
and registry loaders are skipped with a warning.

Enforced at projection time: 64-entry and 64-pubkey KM maxima, auth_data
1..=4096 bytes, per-class max_execution_payment_gwei agreement, no
duplicate keys across muxes.
apply: per-VC preflight (authed keystores listing plus a builder_config
probe on an enumerated key, so a 404 means missing keymanager-APIs #88
support rather than key-elsewhere), POST per projected key with 202/404/
403 handling, exit non-zero unless each key is accepted by exactly one VC
(zero acceptors errors; several is a duplicate-key slashing alarm).
--dry-run prints, --emit writes per-key JSON plus a manifest, --prune
POSTs {} (spec-equal to DELETE) for stored-but-unprojected keys.

check: read-only canonical comparison with tiered findings and --fail-on;
fields the projection omits resolve to VC config on GET and are skipped
rather than flagged as drift.
axum mock VC covering: partitioned-key happy path (POST body equals the
projected doc), 401 preflight, missing-#88 detection, zero-acceptor
non-zero result, duplicate-key slashing alarm, 403 config-file-managed,
prune body exactly {}, dry-run/emit sending nothing, canonical check
(reordered entries + uppercase hex + VC-resolved fields produce no
drift), drift + unroutable auth_data + duplicate-key findings.
An unmatched getExecutionPayloadBid no longer 400s outright: the decoded
auth_data URL is dialed via a transient RelayClient through the shared
send path, guarded fail-closed against CB's own advertised_urls. Bid
sigverify is skipped per-context for the pipe relay only; the global
skip_sigverify and configured-relay verification are unchanged.
…r URL

Same demux, same semantics as the bid endpoint: empty match -> self-URL
guard -> transient client through the shared send path, expecting the
builder's 202.
…e lax-URL equivalent

CB's url_matches ignores userinfo, host case and the default port, so two
byte-distinct classes it treats as one relay would split builder_pubkeys
across KM entries and the VC would silently reject the missing pubkey's
winning bids. Conservative tool-local mirror; CB-side contract tests pin
the real matcher.
…ated keys

A 404 on a key the VC itself enumerated means the #88 route is absent, so
a no-#88 fleet must not check green; silent continue hid it.
v1 CB is a pure pipe: a stored builder URL outside the configured relays
still routes, so it is operator-notable, not a failure.
…L output

The log-settings pre-load went through CommitBoostConfig::from_env_path,
so every service warned about each unknown mux key twice (once for the
logs load, once for the primary config load). Route the logs load
through a silent variant so each load path warns exactly once.

Also skip serializing builder_boost_factor and min_bid_eth when None so
a MuxConfig re-serialized to TOML round-trips cleanly.
…support

A transport error or 5xx on the probe is its own error, never evidence of
no support; an empty-keystore VC warns and POSTs anyway (the 404/405
responses answer the support question).
Url::join with an absolute path drops a /prefix base; concat on the
trimmed base keeps it.
Group read (0o040) leaks the bearer token the same way world read does.
The mux_ext seam was written against pre-merge cb-common; now that
builder_boost_factor and min_bid_eth exist on MuxConfig, the accessors
read them directly (precedence: mux field > overlay > global).
Add optional PbsConfig fields gloas_fork_version (0x-hex, 4 bytes) and
genesis_validators_root (0x-hex, 32 bytes) and thread them into the ePBS
bid signature verification. Unset = the built-in constants, so existing
configs verify exactly as before; devnets/networks whose gloas fork
version or genesis root differ can now pass bid sigverify without
skip_sigverify. Deriving these from the fork schedule is tracked
separately.
Add projection-only PbsConfig fields min_bid_p2p_eth and
builder_boost_factor_p2p (consumed by KM tooling, not the PBS runtime;
INFO-logged when set). When set, the projected KM doc's KEY-LEVEL
min_bid/builder_boost_factor come from them: projected entries always
carry explicit per-entry values, so the key level only governs p2p bids
and entries that omit their own. Unset keeps the uniform projection
(key level = mux/global values) so existing docs are unchanged.
The transient pipe forwards a bid/preferences request to the builder URL the
proposer's signed auth_data names when no configured relay matches. It fails
closed without advertised_urls: CB cannot tell an unconfigured key's self-URL
default (which points at CB itself) from an external builder, so it will not
dial -- a deliberate, SSRF-conscious opt-in. The defect was that it did so
SILENTLY, rejecting with a bare 400 and no hint that one config field enables it.

Now CB warns once at startup (when muxes are configured but advertised_urls is
empty) and logs an actionable one-time hint on the first rejected pipe request.
No behavior change: the pipe stays opt-in via advertised_urls.
The fee_recipient removal dropped the top-level Address import, but a mock in
the test module still uses it. cargo check (non-test) did not flag it, so the
cb-pbs test build broke. Import Address in the test module.
proposer_deadline_buffer_ms and strict_block_decode were added to PbsConfig but
several test-only struct literals build it field-by-field, so the test build
broke (cargo check does not compile the test cfg, so it was not caught earlier).
Fill in the two fields in the signer, tests-utils, and cfg-file-update literals.
…ient tests

Zero budget (the proposer's Date-Milliseconds + X-Timeout-Ms deadline, minus the
buffer, has passed) means any bid would land too late for the beacon node to
use, so return 204 without a doomed relay call. This honors the proposer's own
deadline, not a CB late-in-slot cutoff, so it is not the preemptive skip the bid
path used to do.

Update the bid-path tests for the accepted behavior changes: fee_recipient is no
longer enforced on the ePBS path (repurpose the wrong-fee-recipient test to
assert the bid is served; drop the mux-fee-recipient test, which asserted the
removed mux-level enforcement).
…ults

/beacon_blocks decoding is now opt-in (strict_block_decode): the json-202,
non-gloas-400, and unsupported-content-type-415 tests exercise the decode path,
so enable strict mode in their configs. And builder preferences are no longer
gated on slot age (blind pipe), so repurpose the slot-passed-400 test to assert
a past-slot preference is forwarded and accepted.
Formats the ePBS bottleneck + transient-pipe work, and picks up five files with
pre-existing drift under the pinned toolchain (types/mod.rs, signature.rs,
apply.rs, mock_km.rs, mock_relay.rs).
# Conflicts:
#	Cargo.lock
#	Cargo.toml
…ipient knob

- max_execution_payment_gwei is now Option<u64> (None = unclamped), mirroring the
  relay-level override. The old u64::MAX default was documented as a literal that
  TOML cannot parse, so copying it from config.example.toml bricked startup.
- Add an unknown-field scanner for the [pbs] table (KNOWN_PBS_FIELDS): PbsConfig
  is flattened into StaticPbsConfig and cannot use deny_unknown_fields, so a typo'd
  knob (e.g. a security flag) parsed clean and silently stayed default. It now
  warns, matching the [[mux]] discipline. Wired on the default and custom-module
  load paths.
- RelayConfig::validate rejects a zero bid_poll_timeout_ms / frequency_get_header_ms
  (a stall, not "unset"); proposer_deadline_buffer_ms is capped at one slot.
- Document strict_block_decode in config.example.toml.
- Remove fee_recipient: the field was read nowhere (the BN filters bids by
  fee_recipient for ePBS), yet its doc claimed CB rejected mismatched bids. Drop
  the field, the mux override, the dead trait accessor, and the tests that pinned
  non-enforcement.
In the default blind pipe CB forwards the reveal bytes unparsed, so the reveal
must be SSZ. A JSON or otherwise non-SSZ reveal was relabeled octet-stream and
failed opaquely at the builder (a 500 with no explanation). It is now rejected up
front with 415. The Content-Type is resolved with the builder-specs default (JSON
when the header is absent), so an unlabeled reveal is treated as JSON and rejected
too, not assumed to be SSZ.
require_consensus_version_header accepted Gloas | Heze while its own doc said
GLOAS ONLY. Narrow it to Gloas: a later fork's ePBS semantics are not yet
validated here, so it is 400'd rather than silently handled as gloas. The match
stays exhaustive with no wildcard, so a future lighthouse fork stops it compiling
and forces an explicit decision to widen the set.
…g helper

Remove comments that restate the adjacent, self-documenting code, correct two doc
comments that no longer matched their code (the epbs_base_send_headers fork note
and the mock received_auth field), fix a run-on test comment, and delete the
unused content_type_encoding wrapper (only content_type_encoding_with_default is
called).
…elay_id, shared client)

The transient pipe dials a builder URL taken straight from untrusted auth data, so
before building the client it now resolves the target and refuses any address in
loopback / private / link-local / CGNAT (100.64.0.0/10) / v4-mapped-internal space,
failing closed on a resolution error (PbsClientError::PipeTargetBlocked -> 400). A
DNS-rebind (TOCTOU) between the check and the dial is a disclosed v1 limitation. The
check is compiled and active in every build; only the `testing-flags` feature can
skip it (a thread-local a test sets) so the e2e suite can dial a local mock.

The pipe relay id is now a fixed "pipe" sentinel instead of the attacker-supplied
URL host, which was an unbounded Prometheus relay_id cardinality vector.

The pipe reuses a single shared reqwest::Client (built once in PbsState) instead of
constructing a fresh one per bid - matching how configured relays already reuse
their client and keeping the connection pool warm across slots. Adds
RelayClient::with_client for that; resolve_addressed_relays / transient_pipe_relay
are now async for the resolution step.
The proposer-supplied X-Timeout-Ms sets how long CB solicits a bid, but it had no
upper bound: a large value pins an outbound relay connection open and grows the
timing-games poll ladder for that whole duration. Cap the budget at one slot
(chain-aware) before deriving the timeout, bounding the duration a single request
can hold a relay connection and the ladder depth.
…dvertised_urls

`cb-km check` now flags the self-dial misconfiguration the docs warn about: every
advertised_url km-tool projects is echoed by a VC in its auth data, decoded by CB,
and must be recognized as CB's own via [pbs] advertised_urls. If advertised_urls is
set but does not cover a projected URL, a bid addressed to it decodes to CB's own
URL and self-dials recursively. Since the operator runs `cb-km check` as the gate
before apply, an uncovered URL is an error. Mirrors cb-pbs `url_matches` locally
with a drift-guard test.
The three ePBS handlers carried the same error arm (4xx warns, 5xx errors,
count the status) and the two write routes the same spawn-and-join fan-out.
Both move to cb-pbs utils as `record_request_failure` and
`join_detached_sends`; `expect_status` is inlined into its only caller.

submitBuilderPreferences encodes the SSZ body once and shares it across the
sends instead of cloning and re-encoding the request per relay.
submitSignedBeaconBlock no longer re-stamps Eth-Consensus-Version with the
parsed fork: the header validator accepts Gloas only, which is the value the
base headers already carry.
…eader

getExecutionPayloadBid parsed the Accept header twice: once for the response
encoding and again to build the relay-side Accept. It is parsed once and
threaded through; `preferred(&[Ssz, Json])` always equals the primary, so
`encode_bid_response` takes an `EncodingType` and its unreachable 406 arm goes.
The relay Accept is one of two static headers, so `build_outbound_accept` and
its q-value ladder (only ever fed two entries) are deleted, with the JSON-first
static pinned by a test like its SSZ-first twin.

Also on the bid path: the request start time returned by the single poll was
never read, and was the only reason `select_max_bid` was generic over its
label; `ms_into_slot` and the per-relay ranking cap are computed once. The
handler adopts the shared failure arm.
The variant held a u16 that `status_code` converted back with a 502 fallback
its only constructor could never reach. It holds the `StatusCode` directly.
`load_pbs_config` and `load_pbs_custom_config` carried the same
advertised_urls warning; it moves to one helper, and the runtime once-warn in
the pipe stops restating it. km-tool's `resolve_min_bid` is inlined at its
only call site.
The bid suite hand-rolled 23 PBS boots and the preferences suite two; they
run through `spawn_mock_relay` + `setup_pbs`, which the existing
`setup_relay*` helpers now delegate to. The default JSON bid request and the
hand-built spec URL each get one helper, the proposer pubkey literal becomes
`TEST_PROPOSER_PUBKEY`, and two clippy nits in the rewritten file are fixed.
The mock relay computes its served fork and signing key once.
…ost ones

With `direct_entries = true` in the overlay, every Commit-Boost entry gets
a twin at each builder's own URL (userinfo stripped) carrying the same
auth_data, min_bid, boost and execution-payment cap. The beacon node then
asks the builder directly as well as through Commit-Boost, so it still
gets bids when Commit-Boost is down.

It lives in the overlay rather than on the command line so apply and
check always project the same docs. Relays grouped only by a shared
expected_auth_data keep one direct entry per builder URL, the same builder
listed twice gets one, and direct entries count against the 64-entry
keymanager cap. Entries are now sorted explicitly by (url, auth_data).
The pipe dials a URL taken from untrusted auth data. Its SSRF guard resolves
and checks only the first hop, but the client was built with reqwest's default
redirect policy (follow up to 10), so an allowed public target could answer 307
and steer CB into loopback or link-local space. Internal response bodies then
reach the logs through PbsError::JsonDecode.

Set Policy::none(), matching the keymanager client. A builder that needs a
redirect is a misconfiguration, not a bid path.
test_get_execution_payload_bid_deadline_clamps_ladder asserted exactly two
polls for a 400ms budget at a 200ms cadence. Under parallel load transit eats
the second rung, so it failed 3 of 24 concurrent runs. The ladder's actual
contract is the comparison that follows - a larger deadline buys more polls -
which was stable throughout. Bound the count and keep a lower bound so the
assertion cannot pass vacuously.
- url_matches existed twice, byte-identical, in cb-pbs and km-tool. It is pure
  Url comparison, so move it to cb-common beside the relay types and drop both
  copies along with the duplicated test.
- MAX_BUILDER_AUTH_DATA_SIZE was a typenum alias in cb-common and a re-typed
  usize literal in km-tool, so the spec limit could move on one side only.
  Derive the length from the alias and import it. Also removes the
  #[allow(non_camel_case_types)].
- The three sign_*_root functions shared one body and differed only by domain,
  and spelled the object root two different ways. One helper, three wrappers.
  B256::tree_hash_root is identity, so the signing roots are unchanged.
- deserialize_body inlined content_type_encoding_with_default, the helper added
  directly above it, and repeated its doc verbatim.
- relay_headers was parsed twice on the RelayClient::new path.
- Dead: KmClient::base and RelayAuthCandidate.projected had no readers outside
  a test asserting the value it had just been given.
Eighty-odd commits of spec churn left comments describing mechanisms that no
longer exist: a layer-3 catch-all in the auth-data demux, a preemptive
late-in-slot skip, auth forwarded 'byte-for-byte' where the send re-encodes,
and a decode_signed_beacon_block doc claiming an SSZ default while the code
passes JSON and its own body comment says JSON. Those actively misinform, so
they are corrected rather than deleted where the surrounding fact still matters.

The rest is bulk: docs that restate the function they sit on, rationale
duplicated two or three times within a few lines, narration that dates itself
('today's behavior', 'on this branch', 'for now'), and references to a working
document no outside reader can resolve. Field docs on new config knobs are
trimmed to the non-obvious constraint, since config.example.toml carries the
operator-facing prose.

Also: drop a dead duplicate header constant, use NO_PREFERENCE_DEFAULT where
its value was hardcoded, unqualify paths the file already imports, restore an
alphabetical dependency run, and uncomment strict_block_decode to match the
file's convention for [pbs] bools.
…secrets, rustls bump

Brings in #502 (submit-block relay error logging), the v0.11.0-rc2/rc3
releases with the rustls advisory bump, relay headers read from a secret
file or env var (#498), and the ws stream metrics endpoint (#499).

Conflict resolutions:
- relay.rs: kept epbs's relay_headers() helper, shared by the HTTP client
  and the stream handshake, and moved main's header resolution into it
  (HeaderSource literal/file/env, sensitive values, the secret-source log
  line). Its error messages now format the key, which epbs's did not.
- config/pbs.rs: one test module holding epbs's unknown-[pbs]-field tests
  and main's header-source tests.
- config/utils.rs: main's RELAY_URL test constant in test_env.
- Cargo.lock: main's lockfile, plus only what epbs adds.
Each was checked against the code it names. Most duplicate a surviving test with
the same input and assertions (the survivor is named); the rest still pass with
the behaviour they claim to check broken. 573 -> 547 tests, all passing.

Cannot fail:
- test_get_execution_payload_bid_malformed_auth_400: is rejected for its missing Eth-Consensus-Version header and never reaches the malformed-body path
- test_outbound_accept_json_first: asserts our own from_static literal back, not a spec value
- test_submit_builder_preferences_pipe_self_url_not_dialed: passes with the self-URL guard disabled: the SSRF guard rejects 0.0.0.0 first
- mux_config_none_fields_roundtrip: passes with every skip_serializing_if removed: toml drops None fields itself

Duplicates (survivor):
- test_get_execution_payload_bid_highest_wins (test_get_execution_payload_bid_highest_total_payment_wins)
- test_get_execution_payload_bid_execution_payment_within_cap (test_get_execution_payload_bid_execution_payment_over_cap_accepted)
- test_inflight_selection_prefers_max_total_not_latest (test_select_max_bid_by_total_payment)
- validate_auth_data_requires_nonempty_data (validate_builder_request_auth_rejects_empty_data)
- transient_pipe_relay_fails_closed_without_advertised_urls (test_get_execution_payload_bid_pipe_requires_advertised_urls)
- test_submit_builder_preferences_json (test_submit_builder_preferences_json_wire_form)
- test_submit_builder_preferences_auth_data_mismatch_400 (test_submit_builder_preferences_unmatched_opaque_auth_400)
- test_submit_builder_preferences_auth_data_match (test_submit_builder_preferences)
- test_submit_builder_preferences_two_relays_one_accepts_202 (test_submit_builder_preferences_one_202_one_400_is_202)
- decode_rejects_an_empty_body (test_decode_builder_request_auth_rejects_empty_body)
- decode_rejects_json_without_the_version_header (test_submit_builder_preferences_json_wire_form)
- stripped_userinfo_no_trailing_slash_in_auth_data (projects_literal_json_doc)
- mux_p2p_override_wins_over_global (mux_p2p_override_and_inherit_mix)
- global_p2p_applies_to_all_muxes (mux_p2p_override_and_inherit_mix)
- p2p_fields_unset_keep_uniform_projection (projects_literal_json_doc)
- boost_omitted_without_source (projects_literal_json_doc)
- distinct_hosts_do_not_lax_collide (projects_literal_json_doc)
- empty_doc_serializes_to_empty_object (prune_posts_exactly_empty_object)
- parses_minimal_overlay (projects_literal_json_doc)
- our_identity_wins_over_stored_resolved_version (preserve_entries_collision_is_replaced_not_duplicated)
- merge_exceeding_max_entries_fails (preserve_entries_over_cap_fails_without_posting)
- endpoint_without_prefix_unchanged (apply_partitioned_keys_accepted_once_each)

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