Repository navigation
feat(validator): propose builder blocks (fulu) - #705
MegaRedHand wants to merge 26 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
a940907 to
353fb90
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`.
353fb90 to
e07123f
Compare
|
Security read of this PR, with the signing side and the publish/network side reviewed separately. No critical or high issues: the slashable path is unchanged apart from which container wraps it, the per-validator guard is recorded before 1. Bound the epoch registration send (
2. Make the registration timestamp strictly newer per key
3. Refuse the zero address in The CLI parser checks hex and length only, while the keymanager refuses the zero address. With the zero default, every registration is signed naming the burn address and the relay pays it. Pre-existing for Smaller
Tests I'd add: a blinded block refused by Checked and clean: guard keyed per pubkey and strictly advancing, checked before |
|
/review |
🤖 Codex Code ReviewNo actionable findings. The blinded-block SSZ handling, signing domain, proposal guard, and publication path look consistent. No consensus-transition or fork-choice changes were introduced. Tests could not run because Rust toolchain initialization attempted to write to the read-only rustup directory. Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: validator-client builder proposals (PR 705)Overall this is solid, well-documented work. The equivocation-sensitive parts are handled correctly:
The clap Correctness1. Registration timestamps can collide (
2.
3.
4.
Design / robustness5. Implicit coupling through
6. 503 treated as a definite refusal (
7. Fee-recipient check fully skipped for blinded blocks (
Nits
Nothing blocking. Item 1 and Item 3 are the ones I'd address before merge. Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewThis PR is well-structured, thorough, and cleanly handles the subtle failure modes of the builder flow (routing blinded blocks strictly to the producing node, distinguishing definite refusals from One concurrency edge case worth considering in
Automated review by Gemini · gemini-4-argon · custom prompt |
🤖 Grok Code ReviewThe builder flow is carefully split between definite refusal and unconfirmed publish, and keeping blinded publishes off the failover path is the right anti-equivocation choice. A few correctness gaps are still worth fixing.
The unconfirmed-vs-4xx split, the proposal-guard interaction, builder-domain tests, and the no-failover tests are in good shape. Item 1 is the one that can pay the wrong address; Item 3 is the one that can drop or misroute an already-signed block. Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 DeepSeek Code ReviewOverall the builder flow is well-structured and the blinded-block signing/root handling looks correct. No consensus-state transitions changed. Main concerns:
Minor: Automated review by DeepSeek · deepseek-v4-pro · custom prompt |
🤖 Kimi Code Review
Otherwise, the builder-domain signing, pre-gloas fork gating, no-retry/no-equivocation behavior, and detached-publish metrics handling look carefully implemented, with good test coverage. Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
Registrations: - Run one epoch send and one per-slot send at a time (`in_flight`), so a slow node no longer stacks up sends that each re-send the whole set. - Send to every beacon node at once rather than in turn, as Lighthouse broadcasts: a call takes as long as its slowest node, not the sum. - Sign each re-signed registration strictly newer than the one it replaces, max(now, last + 1), so a relay keeps it after two changes in a second or a clock stepped back. - Read a validator's fee recipient and gas limit under one lock. - Say at error when --suggested-fee-recipient is the zero address. Accepted, as every other client's flag accepts it. Blinded blocks: - Refuse a blinded block before signing when builder proposals are off, besides the HTTP client's own refusal. - Record the producing node with the block's root as well as its slot, check it at publish, and clear the record when no node produced. HTTP client: - Read a failed answer's body up to 1 MiB and carry at most 1 KiB of it into an error, with line breaks written out. Docs: the status codes the blinded publish reads the wrong way, the signed-but-undelivered window, and why a refused batch is not split. Tests: the timestamp rule, overlapping epoch sends, concurrent fan-out, a blinded answer with builder proposals off, the signature over the full block's root, wrong slot/proposer/fork on a blinded answer, a retry after the dropped proposal, the root check, a failed production clearing the record, error bodies, and end to end through this node: relay refuses, relay fails to confirm, and the node refusing a second block. Claude-Session: https://claude.ai/code/session_011vghNneoZ1Ta9W6JNqzjxm
`then_some` built the guard on the refused path too, and dropping it cleared the flag of the send still running, so a third send could start beside it. Claude-Session: https://claude.ai/code/session_011vghNneoZ1Ta9W6JNqzjxm
Claude-Session: https://claude.ai/code/session_011vghNneoZ1Ta9W6JNqzjxm # Conflicts: # crates/net/rpc/src/beacon/validator_client_tests.rs
|
Thanks for the reviews. The changes are in ee5fed8, e919655, 7eced44 and 25f3ca5, with #704's latest merged in at 9823ed7. I checked each point against Lighthouse, Prysm, Teku, Lodestar and Nimbus, so nothing here goes against what they do. @pablodeymo's security review
Smaller points:
Tests: all added:
End to end through ethlambda's node and the mock relay, I added three cases:
They go through Automated reviews
|
Brings in main's durable EIP-3076 slashing protection (#734, #736), which replaces the in-process ProposalGuard. Conflicts resolved by routing the blinded path through it: ProposalService takes both the shared protection and the builder boost factor, the blinded block's slot is recorded durably before signing like any other block's, and the docs that said a blinded block had no slashing-protection record now say it has one. Claude-Session: https://claude.ai/code/session_011vghNiy8GBD3TEfaFKPJoQ
pablodeymo
left a comment
There was a problem hiding this comment.
Reviewed by reading the diff against feat/beacon-builder-blocks at c09478b; I did not build or run it. I found no equivocation or signing-safety problem: the blinded block goes through check_and_record_block like a full one. Three behaviour points first, then smaller ones. Paths are under crates/validator/src/ unless they say otherwise.
Worth a look
- A stale registration can overwrite a fresh one (
registration.rs:308).register_allandregister_changedrun under separate in-flight guards, andregister_allsends a snapshot in sequential 500-key batches. A later batch can therefore deliver a pre-change registration afterregister_changedalready sent the new one. The node stores the fee recipient of every registration it is sent (crates/net/rpc/src/beacon/registrations.rs:159), so the old address wins until the next epoch. It needs more than 500 validators and a slow node. - A publish that never left the client is reported as unconfirmed (
beacon_node/http.rs:820). The client sets a total timeout (8s) and noconnect_timeout, and onlyis_connect()is treated as definite. With the producing node unreachable, the total timeout fires during connect, so a slot that is certainly missed is logged as "the relay may still publish" and lands on the unconfirmed counter.ClientBuilder::connect_timeoutwould make it a connect error. - One hung node throttles registrations to every node (
registration.rs:358).sendawaits each batch across all nodes before starting the next, so a node that hangs costsREGISTRATION_TIMEOUT(12s) per batch. With two nodes, one hung, and 5,000 validators, the healthy node gets its last batch about two minutes into the epoch.
Smaller
beacon_node/http.rs:538: the partial-failure path ofhandle_pool_submissionlogs eachfailure.messageraw, so the new one-line, cappederror_textdoes not cover it.proposal.rs:1093:a_blinded_publish_survives_a_dropped_proposalassumes the proposal reaches signing within a 50 ms wall-clock timeout. The slashing-protection write and the signatures sit before that point, so it can flake on a loaded runner.registration.rs:291:register_alltakes the std mutexsignedon an async worker, whilecurrent()holds it on a blocking thread across a whole 64-key signing chunk, so the worker can stall for that long.beacon_node/mod.rs:412: the trait contract says any 5xx or transport failure isBlindedUnconfirmed, but the HTTP implementation returns a definite error for a 503 and for a connect failure, while the mock follows the doc. The doc and the implementation should state one rule.beacon_node/http.rs:94:error_textandread_error_bodyre-implementerror_textandread_bodyfromcrates/net/rpc/src/beacon/builder_client/mod.rs. This copy escapes the whole body (up to 1 MiB) before cutting it to the limit, and can cut an escape sequence in half.registration.rs:359:senduses amatchwhose arms are both side-effect only;CLAUDE.mdasks forinspect/inspect_errthere.
Design, take or leave
beacon_node/mod.rs:173:Published::BuilderUnconfirmedlives in the enum a node returns, though onlyProposalServicebuilds it, so every consumer carries arms for a state a node cannot answer.lib.rs:717: the duty loop reads a proposal's outcome throughsigned_blinded_at, shared state set insidepropose, in three guard arms. The outcome could carry that fact.beacon_node/fallback.rs:46: which node produced a blinded block is kept as single-slot state in the fallback. Returning the node's position with theProducedBlockand passing it back to publish would remove the record and the mismatch path.
Description
"No slashing-protection record, as for every block this client signs" is out of date since #734: the blinded block is recorded in the EIP-3076 record before it is signed.
Registrations: - One send lock shared by the epoch's full send and the per-slot send, taken before the cache is read and held until the last batch is answered, so a change is never overtaken at a node by an older copy a full send read before it. A per-slot send that finds it taken skips to the next slot; a full send waits for a per-slot one. The cache is only touched under it, so register_all never waits on a signing chunk. - Batching moved below the fallback's fan-out (BeaconNodeApi::register_validators_in_batches): each node works through its own batches beside the others, and one that cannot be reached is sent no more that round, so a hung node costs one timeout and holds back nobody else. A batch counts as sent when any node took it, as before. Blinded publish: - A 3 s connect timeout inside the 8 s total, so an unreachable producing node fails as a connect error (definite) rather than the total timeout (unconfirmed). - One rule for definite versus unconfirmed, stated in the trait, the error, Published, metrics.md and spec_deviations.md; the mock's unreachable node is now definite like the HTTP one. Error text: one implementation, ethlambda_types::text::one_line, used by the builder client and the validator client; it stops at the cap and never cuts an escape in two. A partial pool rejection's per-entry messages go through it too. Tests: the dropped-proposal test and the registration concurrency tests wait on gates in the mock rather than on wall-clock delays.
…ne into propose Review points 10 to 12 of the second builder proposals review. - Producer travels with the block: ProducedBlock gains a Producer the fallback fills in at produce_block, and publish_blinded_block(producer, fork, body) goes back to that node. The fallback's single-slot record, its mutex, the root-mismatch path and the HTTP client's unused slot and root parameters are gone. The proposal path takes the producer from the block it signed, so the pairing the record checked at runtime holds there without a check; a position the fallback never handed out is refused. - The proposal budget moved into ProposalService::propose_by(.., deadline). Production, signing and a full block's publish end at it with Error::ProposalDeadline; a signed blinded block's publish goes on, and past the deadline comes back as BlindedOutcome::StillPublishing. The publish task hands its outcome back over a oneshot: the duty loop reports it when still waiting, the task when not, and close() plus try_recv() at the deadline decides which for a caller that awaits to the end, as the duty loop does. signed_blinded_at and its shared state are gone, and the duty loop matches on the outcome instead of three guarded arms. - Published::BuilderUnconfirmed is gone: Published is what a node can answer, and propose returns its own Proposed outcome. Tests: propose_by within its deadline (full, blinded, a refusal), and the deadline cases, which pause and advance the clock once the mock holds the call (new produce gate), so neither depends on timing.
|
Thanks @pablodeymo. Every point and the description are addressed in ad59936 (points 1 to 9) and 2e4ec29 (10 to 12). Worth a look
Smaller
DesignAll three are taken.
DescriptionThe slashing-protection line is fixed: the blinded block's slot goes into the durable EIP-3076 record before signing. The "Registrations", "Blinded publish" and "Unconfirmed publish" rows now describe the per-node batches, the send lock, the connect timeout, the producer token and the deadline. |
Brings in #705's second builder-proposals review (one registration send lock, per-node batching, the producer carried with the block, the proposal deadline in propose_by) and #704's refusal of block production while syncing. Conflicts resolved by keeping both sides: - BeaconNodeApi and the fallback gain register_validators_in_batches beside submit_proposer_preferences. - ProposalService keeps builder_min_bid and drops last_blinded_slot, which the producer carried with the block replaces. - The mock keeps the preference delay and proposers_at, and takes the blinded publish gate in place of its delay. - cli.md's fee recipient and gas limit rows keep the gloas text and say the registration usually goes at the next slot.
|
/review |
🤖 Codex Code ReviewNo actionable correctness or security findings. The blinded-block flow preserves durable slashing protection, validates slot/proposer/fork before signing, and publishes back to the producing node. Tests could not run: Rustup attempted to update the configured toolchain but its temporary directory is read-only. Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: PR 705, validator-client builder proposalsThis PR is careful and well documented. I found no consensus-safety bugs. Two properties hold:
The notes below are mostly about edges and small hardening. Correctness
Security and hardening
API and readability
Tests
Minor
Overall this looks good to merge once Item 1 is confirmed. Items 2–4 are small hardening follow-ups. Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewThis PR looks solid and well-tested. Key aspects verified:
No issues found — LGTM! Automated review by Gemini · gemini-4-argon · custom prompt |
🤖 DeepSeek Code ReviewOverall this is a well-structured PR with strong documentation and tests. I did not see consensus-critical changes to fork choice, attestation processing, or state transition. A few issues worth addressing:
pub fn is_blinded(&self) -> bool {
matches!(self.contents, Contents::Blinded { .. })
}This moves matches!(&self.contents, Contents::Blinded { .. })This is the most likely compile error in the PR.
while bytes.len() < ERROR_BODY_READ_LIMIT {
match response.chunk().await {
Ok(Some(chunk)) => bytes.extend_from_slice(&chunk),
_ => break,
}
}
bytes.truncate(ERROR_BODY_READ_LIMIT);The cap is applied only after extending let remaining = ERROR_BODY_READ_LIMIT - bytes.len();
bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
break;This only bounds the local
for (batch, outcome) in outcomes.into_iter().enumerate() {
...
accepted[batch] = true;
last[batch] = ...
}This assumes every No issues found in the XMSS/SSZ consensus paths; the blinded-block root equality is covered by tests and the signing-domain tests look correct. Automated review by DeepSeek · deepseek-v4-pro · custom prompt |
🤖 Grok Code ReviewThe blinded-publish path is carefully built: no failover, slashing protection before the signature, and the deadline cannot cancel a signed blinded publish. A few correctness and safety issues should be fixed before this lands.
The publish classification (4xx/503/connect timeout definite, other 5xx unconfirmed), the Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 Kimi Code ReviewNo blocking correctness or security issues found. The high-risk paths are handled carefully:
The remaining edge cases—ambiguous 5xx/503 publish classification, timed-out registrations later being processed, and incomplete reporting if a proposal future is dropped—are explicitly documented and do not appear to introduce unsafe signing behavior. Test coverage is particularly strong around these concurrency and failure paths. Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
pablodeymo
left a comment
There was a problem hiding this comment.
Re-reviewed at dffbfaa. This time I built it: the validator crate's 427 unit tests pass, and 36 concurrent runs of the proposal, registration and fallback tests showed no flake. I also checked two fixes by removing them: without connect_timeout the new test fails after 8 s with BlindedUnconfirmed, and without the per-slot try_lock the overtaking test fails.
All twelve points and the description are addressed. The deadline handling in propose_by reads correctly to me: a signed block always reaches its publish, and the oneshot decides who reports.
Two notes, neither blocking:
- The send lock is global, so a changed registration waits behind the slowest node's full send. A node whose relay hangs answers each batch with a 500 after 10 s and is still given the rest, so 5,000 validators hold the lock for about 100 s. The module doc says "several slots"; a number there would help.
one_lineno longer marks a cut body, so a truncated error text looks complete.
…number From the third review of the builder proposals flow. `one_line` ends a text it cuts in `...`, counted within the cap, so a truncated error body no longer reads as all the node or relay said. A text that fits exactly stays unmarked, and the cut still never splits a character or an escape: it backs up to the last whole one that leaves the marker room. The validator's own `error_text` had the marker before the shared function replaced it; the beacon node's builder client gains it too. The registration module doc said a per-slot send can wait "several slots" behind a full send. It now says how long: at most 12 s per `REGISTRATION_BATCH` validators, and about 100 s (eight slots) for 5,000 validators against an ethlambda node whose relay does not answer, which replies to each batch with a 500 after its own 10 s and is still sent the rest.
- A signing chunk whose blocking task fails no longer lets `signed_generation` advance. The keys in it were left out of the send, so a keymanager change among them waited for the next epoch; the generation now stays behind and the next slot's per-slot send signs them. - `read_error_body` copies only what fits under its limit out of each chunk, instead of extending by the whole chunk and truncating after. - `--prefer-builder-proposals`' help said the execution client sends `builder_boost_factor = u64::MAX`. This client sends it; the execution client's say is `shouldOverrideBuilder`, which the beacon node still honours.
From the other review comments on the builder proposals flow. A per-slot send carries a keymanager fee-recipient or gas-limit change. When no node accepted it, the change waited for the next epoch's full send, and blinded blocks meanwhile paid the old address. The keys in a refused batch are now kept in `unsent` and their registrations sent again, as signed, at every slot until a node takes them: nothing is re-signed, so timestamps stay where they were. `signed_generation` stays behind while any are waiting, so the per-slot check is still one atomic read. Every full send ends the retry, delivered or not. It carries those keys too, and one it could not deliver waits for the next epoch's full send with the rest of the set, so a node that keeps refusing costs a warning a slot for an epoch at most. A failed full send still waits for the next epoch, since re-sending every key each slot would load a node that is already failing. Also: - A blinded block's debug line names the header's fee recipient and the configured one, so a log shows afterwards who built the block and where this client asked to be paid. - `docs/cli.md`: a keymanager `DELETE` that leaves a validator with no fee recipient sends nothing, so the builder keeps the last registration it was given.
|
Thanks @pablodeymo. Both notes are addressed in 285f3ac. I also went back over every bot round on this PR (10-26, 11-05 and 11-13). The points still open are fixed in 99f899e and 6fd0fa5. #707 has this merged in.
From the bot rounds
Left as they are
|
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 node's execution client setsshouldOverrideBuilder. 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, and a re-signed one is always newer than the last (max(now, last + 1)). The full set is sent after each epoch refresh and changed keys every slot, on spawned tasks with signing off the async runtime, in batches of 500 to every configured node, each node working through its own batches beside the others; a node that cannot be reached is sent no more batches that round. One send runs at a time, and each reads the cache only once the previous one was answered, so an older copy does not arrive after a newer one: a per-slot send that finds another running skips and a later slot sends the change, a full send waits for a per-slot one, and a full send that finds the last full one still running skips its epoch. A per-slot send no node accepts is sent again, as signed, every slot until a node takes it or the next full send ends the retry. Validators with no fee recipient are skipped with one warning, and a zero--suggested-fee-recipientis reported at error at startupProducedBlockgains a blinded variant. Its root is the full block's, so the signing path is unchanged, and its slot goes into the durable EIP-3076 slashing-protection record (#734) before it is signed, like a full block's. 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 produced block carries its producer (Producer, filled in by the fallback), and the proposal path sends the signed block back to that node. A blinded answer is refused before signing when builder proposals are off. The request runs on a spawned task, so the proposal budget cannot cut it offProposalService::propose_by, which returns aProposedoutcome: past the budget a signed blinded block comes back asStillPublishing, and whichever of the duty loop and the publish task ends up holding the outcome (decided by a oneshot channel) reports itethlambda_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.