Repository navigation
feat(validator): propose builder blocks (fulu) - #705
MegaRedHand wants to merge 10 commits into
Conversation
🤖 Kimi Code ReviewI'll review this PR for the builder proposals feature in ethlambda. This is a substantial change adding MEV-Boost style builder block proposals to the validator client. Security & CorrectnessCritical: Blinded block publish isolation is correct but fragile
Critical:
|
🤖 Codex Code ReviewLooks good overall. The validator-side builder flow is careful about proposer-safety: the slot is recorded at signing time, blinded publishes are isolated onto a background task, and the timeout path no longer misclassifies a slow relay as a missed proposal.
Minor nits only:
If you want, I can also do a second pass on the beacon-node / RPC-side builder changes from the same PR for spec conformance and edge cases. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview of PR 705 (validator client: builder proposals and blinded publish)I read the diff through the end of the HTTP client changes, including the CLI flags, the fallback node, The design is sound. The main safety properties hold:
Minor points and questions
Tests. Coverage is good. It includes status mapping, the connect-failure case, fallback routing and no-failover, a registration JSON round trip, and end-to-end tests against the real router with a mock relay. Moving the builder test world into Overall: LGTM on what I read. The points above are minor, with Point 1 the most worth a doc tweak. Automated review by Claude (Anthropic) · sonnet · custom prompt |
1b1125c to
a940907
Compare
When the beacon node's builder wins a slot, produceBlockV3 answers with a blinded block. The validator client needs a ProducedBlock variant for it that keeps the full block's root, so the signing path and the proposal guard stay unchanged, and that encodes as a SignedBlindedBeaconBlock for the blinded_blocks endpoint.
Builder proposals need the client to tell the node how to weigh a bid, to accept a blinded answer only when it will publish one, and to hand the signed block to the node's builder. BlockRequest carries an optional builder_boost_factor (None keeps today's behaviour: factor 0, blinded refused), and the trait gains register_validators and publish_blinded_block. A blinded publish that fails with a 5xx or no answer is its own error, BlindedUnconfirmed, because the relay may still publish the block, unlike a 4xx which is a definite refusal. Registrations go to every node, since they install state on each node's builder.
…ed it Only the producing node's builder holds the payload a blinded header commits to, and only that node claimed the slot. Failing over to another node would hand a signed block to a builder that never saw the bid, so the fallback remembers which node produced the latest blinded block and publishes it there alone, returning that node's answer unchanged so the caller can tell a refusal from an unconfirmed send.
A builder only builds for a validator that registered a fee recipient and gas limit, signed under the builder domain (genesis fork version, zero validators root) so it verifies on any fork. RegistrationService caches each signature until the settings change, since builders keep the newest timestamp and a fresh signature per epoch would cost a BLS signature per validator for nothing, re-sends the full set every epoch because nodes forget registrations, and stops at gloas where proposer preferences replace them. Failures are logged and counted, never propagated: a missed registration costs at most a builder block, not a duty.
The pieces for the builder flow (blinded blocks, registrations, publishing on the producing node) had no caller. This wires them: the proposal service sends a builder_boost_factor, signs a blinded block under the same root and guard as a full one, and skips the fee-recipient comparison since the header names the builder's address. A publish the node fails to confirm is reported as unconfirmed rather than a missed proposal, because the relay may still publish the block and a retry would be an equivocation. Registrations are sent from a spawned task at each epoch refresh and for changed keys every slot, so a slow node never delays duties. Three flags (--builder-proposals, --builder-boost-factor, --prefer-builder-proposals) configure it, the latter two requiring the first.
Each half of the builder flow was tested alone: the node's routes against the mock relay, and the client against a mock node. Nothing checked that the two agree on the wire. These tests run the client's own HttpBeaconNode and SigningContext against this node's routes with a Builder layered over the mock relay: a registration the relay verifies, a blinded block produced under a boost factor and published, and the two cases that must stay on a full block (builder proposals off, relay down). The builder world helpers from proposal.rs are exposed to the test tree instead of copied.
Operators need to know the three new flags and their constraints, the four counters that show whether registrations and blinded blocks are landing, and the three places the client departs from the builder flow (no failover for a blinded publish, no slashing record beyond the in-process guard, no registrations from gloas), since the first one decides what an unconfirmed publish means for a proposal.
The beacon node waits up to 5 s on the relay, so the duty loop's budget could fire first and count a block that was signed and handed to the builder as a failed proposal. The blinded publish now runs on its own task, which logs and counts its real outcome, and the loop treats an overrun after a blinded signing as neither a failure nor a delay sample. An unconfirmed publish no longer feeds blocks_proposed or the publication delay either. Builder registrations are signed on the blocking pool in chunks that take the store lock per chunk, are spawned only after a refresh that succeeded (so a failing refresh cannot respawn a full send every slot), and the per-slot check returns on one atomic read of a settings generation counter unless a keymanager write moved it. Removed keys are pruned from the cache. Also tightens the blinded publish's error contract (a connect error and a 503 are definite refusals), gives register_validators its own timeout, makes the tests sensitive to the boost factor and the builder domain, and moves the builder test world into a test_support module.
…ish task Once the duty loop's budget fires, nothing awaits the blinded publish, so a 4xx that arrived afterwards was silent and a late success never reached the publication delay histogram. The task now logs and counts a definite refusal as a failed proposal and observes the delay itself (the service takes the slot clock for this), and the loop skips both for a block it knows was signed blinded so nothing is counted twice. A test drops a proposal mid-publish and checks the block is still sent.
The keymanager table still called `gas_limit` "stored and served only" because "there is no builder flow"; with `--builder-proposals` a changed value goes out in the next slot's builder registration, and so does a changed fee recipient, which an ethlambda node then applies. The README's validator flag table, rewritten on main by #716, gains `--builder-proposals`.
a940907 to
353fb90
Compare
Fifth step of MEV-Boost builder support: the validator client side. With
--builder-proposals, the client registers its validators with the beacon node's builder and accepts a blinded block when the builder's bid wins. It signs the blinded block and publishes it back to the node that produced it. Stacked on #704.Scope:
produceBlockV4keeps its self-build configuration, and registrations stop at the gloas fork. The gloas builder API comes in phase B.Flags
--builder-proposalsbuilder_boost_factorwith every pre-gloas block request--builder-boost-factor <u64>100. Requires--builder-proposals--prefer-builder-proposalsu64::MAX, so the builder wins whenever there is a valid bid, unless the execution client asks to override the builder. Requires--builder-proposals; conflicts with--builder-boost-factorWithout
--builder-proposals, nothing changes: the client sendsbuilder_boost_factor=0and refuses a blinded answer, as before.What it adds
ValidatorRegistrationV1per validator the node resolved, signed under the builder domain (genesis fork version, zero validators root). The signature and timestamp are cached until the validator's fee recipient or gas limit changes. The full set is sent after each epoch refresh and changed keys every slot, in batches of 500, to every configured node, on spawned tasks with signing off the async runtime. Validators with no fee recipient are skipped with one warningProducedBlockgains a blinded variant. Its root is the full block's, so the proposal guard and the signing path are unchanged. Slot and proposer are checked as for a full block. The fee-recipient check is skipped, since the header names the builder's addressPOST /eth/v2/beacon/blinded_blocks, SSZ, only on the node that produced the block, with no failover: only that node's builder holds the payload, and only that node claimed the slot. The request runs on a spawned task, so the proposal budget cannot cut it offethlambda_validator_builder_registrations_sent_total,..._builder_registration_failures_total,..._blinded_blocks_proposed_total,..._blinded_blocks_unconfirmed_totalKnown limitations
PATCHroute) are out of scope; the flags apply to the whole process.Tests
make lintpasses, as do the validator lib, rpc lib and binary tests.