Skip to content

feat(validator): publish proposer preferences (gloas) - #707

Open
MegaRedHand wants to merge 32 commits into
feat/beacon-builder-vcfrom
feat/beacon-builder-preferences
Open

MegaRedHand wants to merge 32 commits into
feat/beacon-builder-vcfrom
feat/beacon-builder-preferences

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

First gloas step of the builder work: the validator client publishes signed proposer preferences (fee recipient, target gas limit) for its gloas proposals. Stacked on #705.

Why: from gloas, beacon-APIs makes SignedProposerPreferences the source of a proposer's fee recipient and gas limit, superseding prepare_beacon_proposer. A proposer without preferences also gets no trustless bids: p2p bids for its slot are ignored.

What it adds

Piece Behavior
Publishing Every slot, for the current and next epoch that are gloas epochs (so warm-up starts one epoch before the fork). Fetches v2 proposer duties, whose dependent_root is the shuffling dependent root the preferences must carry; the duty loop keeps v1. Keeps only duties inside the target epoch, one per slot, so a node's answer cannot make it sign more than an epoch's worth. Signs each own duty whose slot has not started and which not every node holds yet for that (slot, dependent_root). Sends one preference per request to every node, all of a poll's at once (both target epochs' fetches and sends too), so one refusal does not hold back the others and a hung node costs a poll one timeout. A preference only some nodes accepted is re-sent, as first signed, each slot until every node holds it. One every node holds is re-sent once an epoch, at the epoch's second-to-last slot (its last, if a slow node made the client skip that poll), for a node that restarted. A reorg that moves the dependent root re-publishes. With the slot clock, a slot that started while the poll waited is not sent
Unconditional Not gated on --builder-proposals, as in Lighthouse: from gloas the preferences carry the fee recipient for every proposal. Validators with no fee recipient are skipped, with one warning per epoch
Signing domain DOMAIN_PROPOSER_PREFERENCES at the fork version of the proposal epoch, as consensus-specs v1.7.0-beta.4 signs and verifies it, and as Lighthouse, Prysm, Teku, Lodestar and Nimbus do. ethlambda's beacon node also still accepts beta.2's domain (the fork of the epoch before the proposal's), since our fixtures are pinned to beta.2
Eth-Consensus-Version The target epoch's fork (gloas)
Gloas boost factor and bid floor With --builder-proposals, produceBlockV4's body carries the configured builder_boost_factor, so pooled p2p bids can win on value, and --builder-min-bid (gwei, default 0) is sent as min_bid with or without it. Without it the factor stays 0: the local payload unless it cannot be built, so a node whose execution client fails at the slot may still build on a p2p bid, and the client signs that block, as Lighthouse, Prysm and Teku do (#796 revisits the default)
--gas-limit The default target gas limit (60000000), for registrations before gloas and preferences from it, which produceBlockV4 builds this client's own payload toward. Not tied to --builder-proposals
Fee recipient check A gloas block may pay the current address or that of a preference a node accepted for the slot, which a node pays in preference to the current one
Metrics ethlambda_validator_proposer_preferences_published_total, ..._failures_total (sends some node refused or did not answer, re-sends included), ..._duty_fetch_failures_total (failed v2 duty fetches). A refusal by every node is warned once per preference, then logged at debug; preferences newly held by only some nodes are warned once per poll

Known limitations

  • A preference cannot be replaced for the same slot and dependent root, so a fee recipient changed after publishing applies from the next dependent root on.
  • A beacon node restarted mid-epoch lacks the rest of that epoch's preferences; the once-an-epoch re-send covers the next epoch from its first slot.
  • GAS_LIMIT_SCHEDULE is not read, so a default configuration on a chain whose gas limit is not 60M still pulls toward 60M until --gas-limit is set (validator: take the default target gas limit from GAS_LIMIT_SCHEDULE #795). Sepolia and Platåberget schedule 200M, and the docs say to pass --gas-limit 200000000 there.

Tests

  • Unit: the v2 path; per-entry submission; refusal isolation; reorg re-publish; the pre-gloas gate; started slots, by the poll's slot and by the clock; missing fee recipients; no keys loaded; single-flight polling; the fork-version choice on both sides of the boundary; the boost factor and min bid in the gloas body; the once-an-epoch re-send (identical bytes, counted once, the next epoch's first slot covered, also when the round's first poll is skipped), and a re-send one of two nodes or every node refuses; five sends to a slow node costing one wait; --gas-limit and --builder-min-bid parsing; the fee recipient check's accepted addresses, the proposer's own only; a zero gas limit refused by the keymanager.
  • End to end through ethlambda's own beacon node: preferences signed by the validator client from its own v2 duties pass the node's gossip rules and are gossiped.

make lint passes, as do the validator lib, rpc lib and binary tests. The second-round changes (once-an-epoch re-send, concurrent sends, --gas-limit, --builder-min-bid, the fee recipient check) are not devnet-tested yet.

Devnet results

Kurtosis (ethereum-package) with this branch's head (18a907cf), Lighthouse and Geth glamsterdam-devnet-8, and buildoor as the builder. Fulu from genesis, gloas at epoch 6. Three nodes: Lighthouse alone; ethlambda's validator client on a Lighthouse beacon node (--builder-proposals --prefer-builder-proposals, 40% of stake); ethlambda for both (--builder-proposals). All three stayed on one head through the fork.

Works

  • v2 duties: 0 fetch failures, against both ethlambda's and Lighthouse's beacon node.
  • Publishing: from the warm-up epoch on, once per (slot, dependent_root), 0 failures through ethlambda's beacon node, which validated, pooled and gossiped every preference.
  • Lighthouse accepts our preferences from the second gloas epoch on: buildoor, which follows a Lighthouse node, cached all 32 slots of epochs 7 and 8, ours included.
  • Fee recipient timing is exactly as documented: changed through the keymanager in epoch 9, epoch 10's preferences and blocks kept the old address, and epoch 11's carried and paid the new one. The pre-sign check logs an ERROR for every block in between (20 here), and its hint ("reaches the node only at the next one") is wrong at gloas, where it can take two epochs.
  • Metrics move as documented.
  • The builder stack underneath (feat(validator): propose builder blocks (fulu) #705 and below) works in fulu: registrations, filtering, blinded blocks through both ethlambda's and Lighthouse's beacon node, the bid gas-limit check, boost factor 0, and the circuit breaker.

Fails at the fork boundary

  1. Signing domain (fixed since: the client now signs at the proposal epoch's fork version, as beta.4 and every client do). Lighthouse refused all 16 epoch-6 preferences from the validator client with BadSignature, then accepted the same client's epoch-7 and epoch-8 ones. The client retried each refused preference every slot until its slot started (749 failures).
  2. Warm-up preferences are gossiped to nobody (follow-up: Warm-up proposer preferences are gossiped before peers subscribe to the gloas topics #794). ethlambda's beacon node joins the gloas topics an epoch before the fork and gossips the warm-up preferences at once, but Lighthouse subscribes only 2 slots before the fork. No node gossips them again (the client's once-an-epoch re-send gets a 200 for an identical entry, which the node does not republish), so no Lighthouse node, and no builder, saw them. The node still logs Published proposer preferences to gossipsub at info, because the publish error is logged only at debug (swarm_adapter.rs). Possible fixes: re-send the pooled preferences at the fork, or publish the warm-up epoch's preferences closer to it.

Together, on a network with Lighthouse, none of our validators had a preference for the first gloas epoch.

Not tested

  • Gloas bid weighing (builder_boost_factor in produceBlockV4): buildoor's builder was never onboarded (the builder registry was empty on both clients), so there were no bids and every gloas block was self-built. Both beacon nodes accepted the request body.
  • Preferences without --builder-proposals, the reorg re-publish, and validators with no fee recipient.
  • Whether ethlambda's beacon node accepts the other signing version: Lighthouse's epoch-6 preferences never reached it.

Found along the way, not from this PR (same on main)

  • Finality stopped at the fork. At gloas the validator client omits committee_index from attestation_data and sends payload_attestation_data?slot=, as beacon-APIs master says. Lighthouse glamsterdam-devnet-8 requires committee_index and serves /payload_attestation_data/{slot}, answering 400 to both. The client on Lighthouse missed every attestation from the fork on: target participation fell from 97% to 58%, and justification stopped.
  • The validator client had no --gas-limit: it registered (and from gloas preferred) 60M unless set per validator through the keymanager. On a 200M chain every builder bid failed the gas-limit check. Added in this PR, since from gloas the preference also steers the node's own payload.
  • ethlambda's local payloads are empty (getPayload right after forkchoiceUpdated), so any bid beats a local build, and boost factors above 0 cannot be told apart. --builder-min-bid now sets a floor.
  • Lighthouse glamsterdam-devnet-8's gloas blocks pay the zero address, which our client flags as a fee recipient mismatch.

https://claude.ai/code/session_011vghNjfTQmZNRCjPkRs1wP

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 707: validator client publishes signed proposer preferences

I read the diff and the checked-out preferences.rs, signing.rs and the fork-choice helper. I did not build the PR or run its tests. The change looks correct and well tested, with only minor points.

Correctness

  • The DependentRoot::V2 refactor in rpc/src/beacon/validator.rs is behavior-preserving. compute_shuffling_dependent_slot is start_slot(epoch.saturating_sub(MIN_SEED_LOOKAHEAD)).saturating_sub(1), which matches the old inline code when MIN_SEED_LOOKAHEAD is 1. Sharing it with the state transition also removes a drift risk.
  • sign_proposer_preferences signs at the fork version of proposal_epoch - MIN_SEED_LOOKAHEAD, matching the gossip rule. Both sides of the fork boundary are tested. The PR documents the one case where this differs from the validator guide.
  • publish_epoch skips started slots, sends one preference per request, and remembers each success by (slot, dependent_root). Pruning is keyed correctly. Poisoned mutexes are recovered, and InFlightGuard clears the flag on panic.
  • The mock now bumps proposer_duties_v2_calls only after guard(), so failed calls are not counted. The mock's doc comments say the same.

Minor points

  1. preferences.rs:269-310: each preference is sent in turn, and each send goes to every node. A client with many keys and a slow node could take a while per poll. The in_flight guard keeps polls from stacking, but a poll that overruns skips the next slot's poll. That could cost preferences for the slots after it, which gossip IGNOREs once they have started. A handful per epoch is fine, so this only matters at high key counts. Sending with bounded concurrency (join_all or buffer_unordered) would remove the risk cheaply.
  2. preferences.rs:183-198: a failed duties fetch is counted and logged at debug!, and also warns once per epoch. That is intentional, but fetch_warned is pruned with retain(>= epoch), so a node that stays down warns once per target epoch. That is acceptable.
  3. published is keyed on (slot, dependent_root) only. A fee recipient or gas limit changed later is not re-sent for the same root, which the PR documents under "No replacement". Fine.
  4. http.rs: builder_config formats the JSON by hand. That is safe because the factor is a u64, but serde_json::json! would be more idiomatic. This is a nit.
  5. lib.rs: the spawn of preferences.poll(...) is a detached task, so panics are swallowed apart from the guard reset. The guard makes this safe.

Tests
Coverage is thorough: v2 path, per-entry submission, refusal isolation, reorg re-publish, the pre-gloas gate, started slots, missing fee recipients, single-flight, fork-version choice, the boost factor on the wire, and an end-to-end run against ethlambda's own beacon node. The Lighthouse fork-boundary interop is flagged as unverified, which is honest.

No blocking issues.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

I reviewed the PR diff and the touched Rust paths in context. Overall this looks careful and the added tests materially improve confidence around proposer-preferences signing and v2 duties. I did not find a clear consensus-breaking bug in the new logic.

  • No blocking correctness/security issue stands out in the added proposer-preferences flow.
  • The main behavior change — signing DOMAIN_PROPOSER_PREFERENCES using the epoch before the proposal and sourcing dependent_root from v2 proposer duties — is well-motivated and covered by tests in crates/validator/src/signing.rs:335, crates/validator/src/preferences.rs:183, and crates/net/rpc/src/beacon/builder_market_tests.rs:1065.

Findings

  • Minor robustness concern: PreferencesService keeps published, refusal_warned, no_fee_warned, and duty_fetch_warned behind std::sync::Mutex in async code (crates/validator/src/preferences.rs:99). If any of those locks are ever held across future .await refactors, this becomes a latent footgun. It looks safe today, but I’d consider tokio::sync primitives or extra care/comments around lock scope for maintainability.
  • Potential operator-confusion/API concern: the CLI help now says --builder-proposals “boost factor weighs p2p bids in produceBlockV4” even though the option still sounds boolean and gloas publishing is now unconditional (bin/ethlambda/src/validator.rs:80). The implementation seems intentional, but the UX semantics have shifted enough that this may surprise operators. Not a correctness bug, but worth ensuring docs/help text make it crystal clear that on gloas this flag only influences boost factor, not whether proposer preferences are published.
  • Test gap, low severity: the end-to-end test explicitly notes it does not pin the fork-boundary version choice (crates/net/rpc/src/beacon/builder_market_tests.rs:1072). Since fork-boundary domain mismatches are exactly the kind of subtle consensus-adjacent bug that can reappear later, I’d keep an eye on whether the unit coverage in crates/validator/src/signing.rs:335 is enough, or add an integration-style boundary case if practical.

What looks good

  • Correct use of v2 proposer duties for dependent_root, which matches the gossip validation contract and avoids a likely source of silent rejection (crates/validator/src/beacon_node/http.rs:642, crates/validator/src/preferences.rs:183).
  • Good defensive behavior around blinded responses when builder proposals are off; refusing a blinded block in that mode avoids signing something this client cannot publish (crates/validator/src/beacon_node/http.rs:668).
  • Sensible deduplication semantics keyed by (proposal_slot, dependent_root), which handles reorgs without suppressing valid resubmission (crates/validator/src/preferences.rs:99).
  • Strong test coverage for HTTP encoding/decoding, gossip acceptance, duplicate handling, and signature-domain behavior.

Looks good to me aside from the minor maintainability/UX notes above.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which implements proposer preferences for the Gloas fork. Let me analyze the changes systematically.

Overall Assessment

This is a well-structured PR adding Gloas fork proposer preferences support. The core logic is sound, but I've identified several issues ranging from minor to potentially serious.


Critical Issues

1. Potential Division by Zero in builder_boost_factor Documentation vs. Implementation

File: crates/validator/src/beacon_node/http.rs, lines 88-96

The documentation states 0 prefers local payload, but the JSON sends "0" as a string. The spec's produceBlockV4 expects this to be a number, not a string. However, looking more carefully, the format string produces "0" (quoted string), not 0 (number).

fn builder_config(boost_factor: Option<u64>) -> String {
    let factor = boost_factor.unwrap_or(0);
    format!(r#"{{"min_bid":"0","builder_boost_factor":"{factor}","builders":[]}}"#)
}

Issue: builder_boost_factor is serialized as a string ("0", "250"), but the Beacon API spec defines it as a number (uint64). This will cause spec-compliant beacon nodes to reject the request or parse it incorrectly.

Fix: Remove quotes around {factor}:

format!(r#"{{"min_bid":"0","builder_boost_factor":{factor},"builders":[]}}"#)

Similarly, verify whether min_bid should be a string or number per spec.


Security Issues

2. Missing Input Validation on builder_boost_factor

File: crates/validator/src/beacon_node/http.rs, line 93

boost_factor is passed directly from CLI/config into JSON without bounds checking. While u64 is inherently bounded, consider whether extremely large values (e.g., u64::MAX) could cause issues in downstream parsing.

Mitigation: The current code is probably safe, but consider capping or documenting maximum sensible values.


Correctness Issues

3. Fork Version for Signing Domain at Fork Boundary

File: crates/validator/src/signing.rs, lines 335-342

let proposal_epoch = compute_epoch_at_slot(preferences.proposal_slot);
let domain = self.domain(
    DOMAIN_PROPOSER_PREFERENCES,
    proposal_epoch.saturating_sub(preset::MIN_SEED_LOOKAHEAD),
);

Issue: MIN_SEED_LOOKAHEAD is 4 epochs (mainnet), not 1. This subtracts 4 epochs, not 1.

Wait—let me re-check. The comment says "the epoch before the proposal's" but the code subtracts MIN_SEED_LOOKAHEAD (4).

Looking at the test at line 689:

let preferences = preferences_for(gloas_epoch * preset::SLOTS_PER_EPOCH + 2);

And the test verifies it signs with fulu_fork_version when proposal_epoch == GLOAS_EPOCH. With MIN_SEED_LOOKAHEAD = 4, GLOAS_EPOCH - 4 is still Fulu (assuming Fulu ends at GLOAS_EPOCH - 1).

But what if proposal_epoch == GLOAS_EPOCH + 3? Then proposal_epoch - 4 = GLOAS_EPOCH - 1, still Fulu. Only at GLOAS_EPOCH + 4 does it become Gloas.

This contradicts the comment which says "the epoch before the proposal's." The spec's validate_proposer_preferences_gossip uses the fork at the dependent block's epoch, which for v2 duties is compute_shuffling_dependent_slot(epoch) — that's start_slot(epoch) - 1, i.e., the last slot of the previous epoch, which falls in epoch - 1.

So the correct fork epoch should be proposal_epoch - 1, not proposal_epoch - MIN_SEED_LOOKAHEAD.

File: crates/validator/src/signing.rs, line 339

proposal_epoch.saturating_sub(preset::MIN_SEED_LOOKAHEAD),

Fix: This should be proposal_epoch.saturating_sub(1) to match the actual gossip rule domain.

However, looking at the test a_preference_for_the_first_gloas_epoch_is_signed_under_the_fulu_version — it passes with GLOAS_EPOCH because GLOAS_EPOCH - 4 is still in Fulu territory. But the test a_preference_for_the_second_gloas_epoch_is_signed_under_the_gloas_version uses GLOAS_EPOCH + 1, and GLOAS_EPOCH + 1 - 4 = GLOAS_EPOCH - 3, which is still Fulu!

Wait, let me re-read the test... The test at line 691 only checks that it verifies under gloas_fork_version, but doesn't check that it fails under fulu_fork_version. Let me check...

Actually, looking more carefully at line 720-724:

assert!(verify(
    &pubkey,
    &signature,
    under(context.config.gloas_fork_version)
));

This only asserts it verifies under Gloas, not that it fails under Fulu. So the test doesn't catch the MIN_SEED_LOOKAHEAD issue.

This is a bug: The signing domain uses MIN_SEED_LOOKAHEAD (4) instead of 1, meaning for epochs GLOAS_EPOCH through GLOAS_EPOCH+2, the signature would verify under Fulu in ethlambda but potentially fail against other clients' gossip validation that uses the correct epoch-1 semantics.

Wait—I need to re-read the spec deviation document more carefully. It says:

validate_proposer_preferences_gossip verifies against the dependent block's state advanced to the lookahead start

The "lookahead start" might indeed be MIN_SEED_LOOKAHEAD epochs before. Let me re-check the consensus spec...

Actually, looking at the CLAUDE.md change:

the gossip rule's signing domain

And the spec deviation says:

the gossip rule's state (at the start of the previous epoch)

Hmm, "previous epoch" suggests 1 epoch, not 4. But "lookahead start" in the spec might mean MIN_SEED_LOOKAHEAD.

Given the explicit test and the detailed comment, this might be intentional. However, the comment says "the epoch before the proposal's" which is 1, not 4. This inconsistency between comment and code is confusing and potentially wrong.

Recommendation: Verify against the actual consensus spec for validate_proposer_preferences_gossip. If the spec uses epoch - 1, fix the code. If it uses epoch - MIN_SEED_LOOKAHEAD, fix the comment.

4. compute_shuffling_dependent_slot vs. V1 Definition Consistency

File: crates/net/rpc/src/beacon/validator.rs, lines 540-544

Self::V2 => compute_shuffling_dependent_slot(epoch),

Good: This delegates to the state transition function, preventing drift. But verify that compute_shuffling_dependent_slot is actually exported and correct.


Performance Issues

5. Lock Contention in PreferencesService::poll

File: crates/validator/src/preferences.rs, lines 181-186

for target in [epoch, epoch + 1] {
    if self.context.config.fork_at_epoch(target) < ForkName::Gloas {
        continue;
    }
    self.publish_epoch(current_slot, target, &pubkeys, &store).await;
}

The loop processes epochs sequentially. Each publish_epoch awaits v2 duty fetches and submissions. Since the two epochs are independent, they could be parallelized with try_join! or join!.

Mitigation: Low impact (only 2 epochs), but worth considering for high-latency nodes.

6. Per-Preference Sequential Submissions

File: crates/validator/src/preferences.rs, lines 252-280

Preferences are sent one-by-one in a loop with await each iteration. For validators with many proposal slots, this serializes network round-trips.

Mitigation: Consider limited concurrency (e.g., futures::stream::iter(...).buffer_unordered(4)) to parallelize submissions while maintaining per-preference error handling.


Rust Best Practices

7. Mutex Poison Recovery Hides Bugs

File: crates/validator/src/preferences.rs, lines 121-125

fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
    mutex
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

While documented, silently recovering from poisoned mutexes can hide panics in other threads. In a consensus client, this could mask critical failures.

Recommendation: Consider whether parking_lot::Mutex (which doesn't poison) would be cleaner, or at least log when recovering from poison.

8. AtomicBool Ordering Could Be Relaxed

File: crates/validator/src/preferences.rs, line 178

if self.in_flight.swap(true, Ordering::AcqRel) {

AcqRel is stronger than needed for a simple flag. Acquire on load, Release on store would suffice, or even Relaxed since there's no data dependency being synchronized (the actual data goes through Arc clones).

Fix: Use Ordering::Acquire for the load part of swap... actually swap doesn't let you split. Consider compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) for better performance on some architectures.

9. std::sync::Mutex in Async Code

File: crates/validator/src/preferences.rs, multiple locations

Using std::sync::Mutex and immediately locking/unlocking in async code is acceptable for very short critical sections, but parking_lot::Mutex or tokio::sync::Mutex would be more idiomatic. The current pattern of lock(&self.published).insert(...) is fine since it doesn't hold across await points.


Error Handling & Edge Cases

10. Empty preferences List Early Return

File: crates/validator/src/beacon_node/http.rs, lines 837-839

if preferences.is_empty() {
    return Ok(());
}

Good defensive coding. But verify this matches the spec—does the Beacon API allow empty POSTs? The mock test confirms this behavior, but ensure it's consistent with other similar endpoints.

11. Missing Handling of dependent_root Mismatch

File: crates/validator/src/preferences.rs

The service remembers published preferences by (slot, dependent_root), but what if a node accepts a preference with a different dependent_root than what the service expects? The v2 duties fetch gives one root, but the node's head might have moved on.

Current behavior: The preference would be signed with root A, sent to node with head at root B, and the node's gossip rule would reject it. The service would retry next slot.

Potential issue: If there's a consistent mismatch (e.g., node sees different fork choice), the service would warn once and then debug-log retries, potentially missing proposals silently.

Mitigation: Consider adding metrics or more visible logging for persistent mismatches.


Testing Issues

12. Test verifies Function Duplicates Production Logic

File: crates/validator/src/preferences.rs, lines 437-463

The verifies helper reconstructs domain computation manually instead of using SigningContext. This creates a maintenance burden and risk of test-production drift.

Recommendation: Use SigningContext::domain or a test helper that shares code.

13. Missing Test for Fork Boundary Signing

File: crates/validator/src/signing.rs

The tests cover first and second Gloas epochs, but miss:

  • Epoch exactly at GLOAS_EPOCH + 3 (where MIN_SEED_LOOKAHEAD subtraction would still yield Fulu if using 4, but Gloas if using 1)
  • The transition from Fulu to Gloas during the epoch before Gloas (when signing preferences for GLOAS_EPOCH during GLOAS_EPOCH - 1)

Documentation & Comments

14. Inconsistent Terminology: "gloas" vs "Gloas"

Minor: Some comments use lowercase "gloas" for the fork name. Standardize to "Gloas" when referring to the fork.

15. CLAUDE.md Update Is Good but Could Be Clearer

The CLAUDE.md change correctly documents the behavior, but the sentence:

From the epoch before gloas it signs proposer preferences every slot for its proposals in the current and next epoch

Could be misread as "from the epoch before Gloas starts" vs "starting from the epoch before each target epoch." The actual behavior is: starting from GLOAS_EPOCH - 1, sign preferences for slots in GLOAS_EPOCH and GLOAS_EPOCH + 1.


Minor Issues

16. builder_config Function Name

File: crates/validator/src/beacon_node/http.rs, line 88

The name builder_config is slightly misleading—it's the self-build configuration, not general builder configuration. produce_block_v4_body or self_build_config might be clearer.

17. Redundant std::slice::from_ref Import Style

File: crates/validator/src/preferences.rs, line 269

std::slice::from_ref(&entry)

Consider importing std::slice::from_ref at the top for consistency, or use &[entry] directly if the trait accepts it.

Actually, looking at the trait:

preferences: &[gloas::SignedProposerPreferences],

std::slice::from_ref(&entry) produces &[SignedProposerPreferences], which is correct. &[&entry] would be wrong. This is fine.


Summary Table

Priority Issue Location Fix
Critical builder_boost_factor serialized as string not number http.rs:94 Remove quotes: "{factor}" → {factor}
High Signing domain uses MIN_SEED_LOOKAHEAD (4) vs epoch-1; comment/code mismatch signing.rs:339 Verify spec, fix code or comment
Medium Mutex poison recovery hides bugs preferences.rs:121 Use parking_lot or log recovery
Medium Sequential epoch processing preferences.rs:181 Consider join!
Medium Sequential preference submission preferences.rs:252 Consider limited parallelism
Low AtomicBool ordering could be relaxed preferences.rs:178 compare_exchange_weak
Low Test logic duplicates production preferences.rs:437 Share domain computation

The PR is well-tested and the architecture is sound. The string-vs-number JSON serialization is the most critical fix needed before merge.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

@MegaRedHand
MegaRedHand added this pull request to stack #731 October 29, 2026 19:41
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-builder-preferences branch from ae1a3d2 to 5931f19 Compare October 29, 2026 20:11
@MegaRedHand
MegaRedHand removed this pull request from stack #731 October 29, 2026 20:41
@MegaRedHand
MegaRedHand added this pull request to stack #733 October 29, 2026 20:41
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-builder-preferences branch from 5931f19 to 652bfc0 Compare October 29, 2026 20:50
…nces

Signed proposer preferences must carry the shuffling dependent root, which
only v2 of the proposer duties endpoint reports (v1's root is one epoch
later). Add both calls to the beacon node trait, with the fallback sending
preferences to every node since each one gossips what it accepts.
…main

The gossip validator verifies a preference at the fork version of the epoch
before its proposal, while the validator guide signs with the head state.
The two differ only in the first epoch of a fork, where the gossip rule is
what peers accept, so sign for that one.
From gloas a proposer's fee recipient and gas limit travel in signed
proposer preferences, and a node ignores trustless bids for a slot whose
proposer published none. A per-slot service signs one per owned proposal in
the current and next gloas epoch, keyed by (slot, dependent root) so a reorg
re-publishes, and sends it to every node.
…oas bids by the boost factor

Wire the preferences service into the slot loop, unconditionally since from
gloas they carry every proposal's fee recipient. produceBlockV4 now sends the
configured builder_boost_factor in its body, so with --builder-proposals a
pooled p2p bid can win on value; without it the factor stays 0 as before.
…'s gossip rules

The signing domain is the part of the preferences flow most likely to drift
between the signer and the verifier, so drive the validator client's own
signer and HTTP client against the real route and gossip rules.
Document the per-slot preferences, their two counters, the signing domain
departure from the validator guide, and that the boost factor now also
weighs gloas p2p bids.
… their reporting

A node answers 400 for a whole call when any entry fails, and one it holds
for the same slot and root with other content is such a failure, so a
batch kept every entry unpublished and re-sent until that slot passed (a
restart after a fee recipient change triggers it). Send each preference
alone and remember each success.

Also: a failed v2 duties fetch no longer warns "duty likely missed" twice a
slot (the service warns once per epoch and counts it), the fork header is
the target epoch's, the poll is spawned ahead of the proposal, the
signing-domain explanation is corrected (get_domain reads state.fork, so
the guide and the gossip rule differ only for a proposal in a fork's first
epoch signed during it), v2 dependent slot reuses the state transition's
definition, and the end-to-end test now reads its duty and dependent root
through the client.
A preference a node keeps refusing is retried every slot until its slot
passes, which warned up to two epochs of slots per entry. Warn on the first
refusal of each (slot, dependent root), log repeats at debug, and keep
counting every failure. Reword the failures metric to what it now counts.
…split the fetch counter

try_all warned per node on every refusal, so a preference a node kept
refusing still warned every slot despite the per-entry dedup; add a quiet
variant for it. Poll after the proposal rather than before, so the next
epoch duties fetch does not compete with block production. Count failed v2
duty fetches on their own counter, since a syncing node is not a refused
preference.
From gloas the proposer preferences carry each validator's fee recipient
and target gas limit, so the keymanager's `feerecipient` and `gas_limit`
reach the node through them, from the next dependent root on, whether or
not `--builder-proposals` is set. The README's duty list, written on main
by #716, gains the preferences, which are published every slot.
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-builder-preferences branch from 652bfc0 to 34a914f Compare October 30, 2026 16:03
@pablodeymo

Copy link
Copy Markdown
Collaborator

Security read of this PR, with the signing side and the network/node side reviewed separately. No critical or high issues: the message is signed under DOMAIN_PROPOSER_PREFERENCES alone, so nothing here is slashable or replayable under another domain; only keys in the store are ever signed for, and a key removed between the duties fetch and signing is skipped; no secret is logged or cloned; no lock is held across an await; the single-flight guard clears on panic and drop; and the signing domain (fork version at proposal_epoch - 1), the v2 dependent root (compute_shuffling_dependent_slot, the same function the gossip rule uses) and the Eth-Consensus-Version all line up with what the node verifies, pinned by the signing.rs tests and the end-to-end gossip test. Two things I'd fix before merging, plus smaller ones.

1. Bound and check the duties before signing (preferences.rs::publish_epoch, http.rs::proposer_duties_v2)

The v2 duties are taken as they come: no cap on the entry count and no check that duty.slot lies in the target epoch. The fallback's try_each hands the list from the first node that answers, so one buggy or hostile node can return N entries naming our keys at far-future slots with a rotating dependent_root. Each slot the client then signs N messages while holding the store read guard (tokio's RwLock is write-preferring, so a keymanager write queued behind it blocks the slot loop's next read), sends N sequential POSTs to every node at up to 8 s each inside the single-flight poll, and inserts N keys into published or refusal_warned. The prune keeps every slot at or above the current epoch's start, so far-future keys are never dropped. Nothing dangerous is signed (own domain, own fee recipient), so this is amplification and memory growth, not key exposure. Dropping duties whose slot is outside [start(target), start(target + 1)) bounds everything to 32 entries per target and makes the prune effective.

2. A node that refused once never receives the preference again (fallback.rs::try_all_inner, preferences.rs::publish_epoch)

try_all answers Ok as soon as one node accepts, and the caller then marks (slot, dependent_root) published and skips it on every later poll. The retry-every-slot the module doc promises only happens when every node refused. A node that was syncing, restarting, or lacked the dependent block at that moment ends up with no entry in its builder market, and since gossip never queues or replays, nothing fills it in later. If the client fails over to that node for produceBlockV4 at the slot, it ignores every p2p bid and self-builds, and the gas target falls back to the parent bid's. Registrations (#705) re-send the whole set every epoch and so self-heal; preferences have no periodic resend. Keying published by (slot, root, position), or marking it published only when every configured node accepted, would close it.

Smaller

  • validator_index and dependent_root go into the signed message verbatim from the node. The duty loop already resolved the indices, so a cross-check is cheap; a wrong index is rejected by gossip today, so the cost is only a refused send.
  • The zero address passes --suggested-fee-recipient (hex and length only), and from gloas this PR signs and publishes it unconditionally, so a mistyped zero address burns rewards. Same point as on feat(validator): propose builder blocks (fulu) #705, with more reach here.
  • The settings are read twice per message (fee recipient, then gas limit), so a keymanager write in between pairs an old recipient with a new gas limit for that (slot, root), which cannot then be replaced. One snapshot per message would do.
  • Response bodies are still reflected uncapped into BeaconNodeStatus and from there into the once-per-key warn line; the client keeps reqwest's default redirect policy. Both pre-existing (feat(validator): propose builder blocks (fulu) #705).
  • The route comment about a validator client naming fulu during the epoch before gloas describes other clients; this one always sends the target epoch's fork.

Tests I'd add: duties at slots outside the target epoch are dropped, and a list of hundreds of entries is bounded; a node that refused while another accepted receives the preference on a later poll; a key removed between the duties fetch and signing is skipped without losing the others; a preference signed during the first gloas epoch itself passing the node's rules end to end (the gossip test has gloas at epoch 0, so the boundary is unit-pinned only).

Checked and clean: pre-gloas target epochs skipped before any fetch; started slots skipped, matching the node's IGNORE; one request per preference, so one refusal cannot hold back the others; an empty list never reaches the wire; builder_boost_factor is a u64 in a format!, None renders 0, and the node multiplies in u128, so no clamp is needed; poisoned mutexes recovered; a bounced poll exits at once; no_fee_warned and fetch_warned hold at most two live epochs; the node route accepts a fulu or gloas header and caps entries at the lookahead length; nothing new derives Debug on a secret, and logs carry pubkeys, slots and indices only.

@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Gemini Code Review

This PR looks great. The implementation of PreferencesService, the v2 proposer duties client method, the produceBlockV4 builder boost factor handling, and the gossip signing domain handling at the fork boundary are all clean, well-documented, and thoroughly covered by unit and integration tests.

A few highlights:

  • Using compute_shuffling_dependent_slot directly in DependentRoot::slot (crates/net/rpc/src/beacon/validator.rs:543) ensures the RPC route and state transition logic cannot drift.
  • Sending preferences individually and tracking state per (slot, dependent_root) in PreferencesService (crates/validator/src/preferences.rs:270-310) cleanly avoids a single stale/conflicting preference blocking the rest of the epoch's preferences, while handling reorgs and mid-epoch configuration updates naturally.
  • Signing at proposal_epoch.saturating_sub(MIN_SEED_LOOKAHEAD) (crates/validator/src/signing.rs:342-345) accurately matches the fork version in the lookahead state used by validate_proposer_preferences_gossip during the first epoch of Gloas.

No issues found — ready to merge.


Automated review by Gemini · gemini-4-argon · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  • [P2] Retry preferences on nodes that failed to accept them — preferences.rs:281. FallbackBeaconNode::try_all_inner returns success when any node accepts, so this permanently marks the preference published even if another node was unavailable. When that node recovers, subsequent polls skip delivery; gossip does not guarantee replay of preferences published during its downtime. If block production fails over to it, builder bids may be ignored and the requested gas target unavailable. Track acceptance per node and retry failed deliveries using the same signed preference. Add a regression test covering partial failure followed by recovery.

Tests could not run: Rustup attempted to write to its read-only temporary directory.


Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: proposer preferences in the validator client (PR 707)

Overall this is well-structured. Single-flight polling, per-entry submits, per-(slot, root) dedup and once-per-epoch warnings are all sensible, and the tests are thorough. The main gap is in the gloas proposal flow (Item 1); the rest are smaller.

Correctness / consensus

1. A non-zero gloas boost factor lets a p2p bid win, but the proposal path is unchanged.

  • Where: http.rs builder_config, BlockRequest docs in mod.rs.
  • Problem: With --builder-proposals, produceBlockV4 can now return a block whose bid has builder_index != BUILDER_INDEX_SELF_BUILD. The trait doc for execution_payload_envelope still describes it as "the envelope the node cached for the self-built payload". The diff does not touch proposal.rs.
  • Consequence: If the proposal service unconditionally fetches, signs and publishes the envelope after a gloas block, a won builder bid will produce a 404. That gets logged as a missed envelope, or retried, on every builder-won proposal. Nothing should ever sign an envelope for a builder's payload.
  • Fix: Confirm the proposal path branches on the bid's builder index. Add a test where the mock returns a non-self-build gloas block.

2. Unconditional publishing combined with factor 0 sends builders a mixed signal.

  • Without --builder-proposals, preferences are published, so builders are invited to bid. The body then carries builder_boost_factor: "0", so no bid can ever win.
  • Publishing is defensible (fee recipient), and the docs say this. Still, consider stating explicitly in docs/cli.md that bids are solicited but never taken without the flag. Operators reading the metrics may otherwise be confused.

3. The signing domain deviates from validator.md, which is an interop risk.

  • Where: signing.rs sign_proposer_preferences.
  • The reasoning about get_domain(state.fork) vs. the schedule is sound. In the common case, first-gloas-epoch preferences are signed during the fulu epoch, so both definitions agree.
  • The divergence only hits re-signs inside the first gloas epoch, e.g. after a reorg moves the root or after a restart.
  • Risk: If Lighthouse, Prysm or others verify against the head state rather than the advanced dependent state, those re-signed preferences will be rejected on their mesh. Only ethlambda's endpoint is known to accept both.
  • Ask: Worth confirming against another client's gossip implementation before relying on it. The deviation doc should mention which side other clients took.

4. Verify that the DependentRoot::V2 refactor matches the old formula at the edges.

  • Where: rpc/beacon/validator.rs, around line 540.
  • The old code was compute_start_slot_at_epoch(epoch.saturating_sub(1)).saturating_sub(1), which gives slot 0 for both epoch 0 and epoch 1.
  • Please confirm compute_shuffling_dependent_slot saturates identically for epochs 0 and 1. If it does not, the served root changes there.
  • A unit test pinning DependentRoot::V2.slot(0), slot(1) and slot(2) would lock this down.

Behaviour

5. Only the first node's duties are signed against.

  • Where: fallback.rs proposer_duties_v2 / submit_proposer_preferences.
  • Duties come from the first node that answers (try_each_quiet), but the submit goes to all nodes. A secondary node on a different head refuses the entry, and that refusal is only logged at debug!.
  • published is then set as soon as one node accepts, so the refusing node is never retried even after it converges.
  • This is acceptable given the cost, but worth a sentence in the module doc ("Why every node gets them") so nobody assumes full fan-out is guaranteed.

6. Pre-fork submissions carry a gloas Eth-Consensus-Version.

  • Where: preferences.rs publish_epoch.
  • During the epoch before gloas, fork_at_epoch(target) yields Gloas while the node's head is still fulu.
  • That is correct per the endpoint semantics ("the fork the preferences belong to"). However, the only test with a fulu header is the mock/HTTP one; the node-side integration test runs with gloas at epoch 0.
  • An ethlambda-node test at a gloas boundary with this header would close the gap.

Minor

  • preferences.rs, fee-recipient check: lock(&self.published) is taken once per duty inside the loop. The lists are tiny so this is fine, but a single guard held across the loop would read cleaner.
  • metrics.rs, failures counter: the doc comment says "no beacon node accepted", while docs/metrics.md says "refused or failed to take". Align the wording.
  • Docs layout: CLAUDE.md and README.md reflow is fine. The README line now exceeds the surrounding wrap width.
  • builder_market_tests.rs, integration test: the spawned axum::serve task is never shut down. That is fine in tests, but holding the JoinHandle and calling abort() at the end is tidier.

Nice work extracting test_support and the quiet fallback variants; the try_*_inner split keeps the logging policy in one place.


Automated review by Claude · claude-opus-5-5 · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Finding

  • Partial fallback submission is incorrectly remembered as fully published — crates/validator/src/preferences.rs, in publish_epoch’s successful submit_proposer_preferences arm, together with crates/validator/src/beacon_node/fallback.rs’s try_all_inner. try_all_inner returns Ok(()) when at least one node accepts, as the new three-node fallback test demonstrates. The service then inserts (slot, dependent_root) into the global published set and never submits that preference again. If node A accepts while node B is down or refuses, B is never retried once it recovers. This contradicts the module’s “a node that refused a send must be tried again” behavior and can reduce gossip/builder coverage or leave a later block-producing node without the preference’s fee recipient. Track publication per node, or return incomplete coverage from the fallback submission and retry until every configured node has accepted. Add a test where one node accepts initially and a failing node recovers on a later poll.

Otherwise, the change looks well considered: the v2 dependent-root calculation now shares the state-transition helper, the fork-boundary signing domain is explicitly tested, single-flight cleanup is panic-safe, and one-preference-per-request avoids batch-wide rejection poisoning unrelated entries.


Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt

…k version

consensus-specs v1.7.0-beta.4 signs (`get_signed_proposer_preferences`) and
verifies (`validate_proposer_preferences_gossip`) proposer preferences with
`compute_fork_version(proposal_epoch)`, and Lighthouse, Prysm, Teku, Lodestar
and Nimbus all do the same. The validator client still signed by beta.2's
gossip rule, the fork of the epoch before the proposal's, which differs only
in the first epoch of a fork; on a devnet Lighthouse refused every one of its
first-gloas-epoch preferences as a bad signature.

- Signer: `SigningContext::sign_proposer_preferences` now uses the proposal
  epoch's fork. Its tests assert gloas's version and not fulu's for the first
  and the second gloas epoch.
- Node: `proposer_preferences_domains` still accepts all three candidates,
  but tries the proposal epoch's first; beta.2's two are kept until the
  fixtures (pinned to beta.2) move past it.
- New end-to-end test with fulu before gloas: the validator client's
  preference for the first gloas epoch passes the node's gossip rules and
  verifies under gloas's version, not fulu's.
- The route comment and docs no longer say a validator client names fulu, or
  that the client signs by the gossip rule's epoch-before domain.

Claude-Session: https://claude.ai/code/session_011vghNneoZ1Ta9W6JNqzjxm
…holds them

Security review of the proposer preferences service:

- Bound the v2 duties before signing: drop entries outside the target
  epoch and keep the first per slot, so a node's answer costs at most
  SLOTS_PER_EPOCH signatures, sends and remembered keys per epoch.
- A preference is done only once every node accepted it. The fallback
  now answers Delivery::{Everywhere, Partly}; a partly delivered
  preference is kept as signed and re-sent to every node each slot, so a
  node that was syncing still gets it, and a fee recipient change in
  between cannot split the nodes. Counted and logged once, at the first
  acceptance.
- Read the fee recipient and gas limit in one settings snapshot.
- Use the shared InFlight guard, and fix InFlight::enter: then_some
  built the guard eagerly, so a refused enter dropped it and cleared the
  running run's flag.

Claude-Session: https://claude.ai/code/session_011vghNneoZ1Ta9W6JNqzjxm
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Thanks for the reviews. The changes are in 68cb7e0, 7edb397 and 3d3220b, on top of #705's (merged in at 382fa78). I checked each point against Lighthouse, Prysm, Teku, Lodestar and Nimbus. That check turned up one thing nobody had flagged, so it comes first.

The signing domain: we were the odd one out

consensus-specs v1.7.0-beta.4 signs and verifies proposer preferences with compute_fork_version(proposal_epoch):

  • the signer is get_signed_proposer_preferences;
  • the gossip rule is validate_proposer_preferences_gossip.

All five clients do the same: Lighthouse spec.fork_at_epoch(proposal_epoch), Prysm params.Fork(epoch), Teku spec.fork(proposalEpoch), Lodestar config.getDomain(proposalSlot), Nimbus forkAtEpoch(proposal_slot.epoch).

This client signed by beta.2's rule instead: get_domain on the lookahead state, which is the fork of the epoch before the proposal's. That is why Lighthouse refused every first-gloas-epoch preference on the devnet ("Known limitations" in the description).

  • The client now signs at the proposal epoch's fork version. The tests assert gloas's version, and not fulu's, for both the first and the second gloas epoch.
  • The node still accepts all three candidate domains: beta.4's (now tried first), plus beta.2's two. Our fixtures are pinned to beta.2, and a client still on that rule loses nothing. This can narrow once the fixtures move.
  • A new end-to-end test with fulu before gloas covers this: a preference the client signs for the first gloas epoch passes our node's gossip rules, and verifies under gloas's version only.

@pablodeymo's security review

  1. Bound and check the duties: done. Duties whose slot is outside the target epoch are dropped, and only the first one per slot is kept. Each target epoch therefore costs at most SLOTS_PER_EPOCH signatures, requests and remembered keys, whatever the node answers, and the prune now covers everything kept. No other client checks this: Lodestar has a window of 8 slots ahead, and the rest take the duties as they come. So this is extra, not a different direction.

  2. A node that refused never got the preference again: fixed. A preference now counts as done only once every node accepted it. Until then it is re-sent to all of them each slot, as first signed rather than re-signed, so a fee recipient changed in between cannot give the late node a second, different preference. Re-sending to a node that already holds it is safe: ethlambda's node answers 200 for an identical entry, and Lighthouse's treats it as already seen. The published count takes each preference once. No client retries a single refusing node:

    • Lighthouse and Lodestar stop at the first success;
    • Nimbus counts any 200;
    • Teku re-posts on every duty reload;
    • Prysm re-pushes on a host switch.

    Sending to every node is what Teku and Nimbus do.

Smaller points:

  • Validating validator_index: not cross-checked. A wrong index can only fail the node's gossip check (the proposer lookahead REJECTs it), so it costs one refused request. No client cross-checks it either; Lighthouse, Teku and Lodestar all take the index from the duty.
  • Zero address: reported at error at startup, not refused (see feat(validator): propose builder blocks (fulu) #705: no client refuses it on the command line).
  • One settings snapshot per message: done. The fee recipient and gas limit are read together, under one lock.
  • Error bodies and redirects: see feat(validator): propose builder blocks (fulu) #705. Bodies are capped and every control character is written out. Redirects stay followed, as in every client.
  • The route comment: corrected. No client names fulu: Prysm hardcodes gloas, and the others, ours included, take the fork of the preference's slot. The node still accepts fulu as leniency.

Tests: added:

  • duties outside the target epoch are dropped;
  • 400 duties give one preference per slot;
  • with two nodes, the refusing node gets the preference once it accepts, and the acceptance is counted once;
  • a key removed between the duties fetch and signing is skipped without losing the others;
  • the first-gloas-epoch end-to-end test above.

Automated reviews

  • builder_boost_factor as a string (Kimi): correct as it is. beacon-APIs quotes every Uint64.
  • MIN_SEED_LOOKAHEAD is 4 (Kimi): it is 1 on both presets. The signing domain changed anyway, as described above.
  • Concurrency across nodes and entries (Claude, Kimi): calls to every node now run concurrently (feat(validator): propose builder blocks (fulu) #705). The work per poll is bounded at one entry per slot.
  • std::sync::Mutex in async code (Codex, Kimi): no lock is held across an await, and every write is a single set operation.
  • --builder-proposals help text (Codex): it now says preferences are published with or without the flag, and that from gloas the flag only sets the boost factor.
  • A boundary test end to end (Codex): added, as above.
  • builder_config by hand (Claude): kept. The only input is a u64.

@pablodeymo

Copy link
Copy Markdown
Collaborator

Second pass, on 36416956. Read-through only: I did not build or run it.

The first-round follow-ups are all in: within_epoch bounds the duties, Delivery::Partly keeps a preference as signed and re-sends it until every node holds it, the settings are read in one snapshot, and the tests I asked for are there. The move to the proposal epoch's fork version looks right to me.

I found no logic error in the happy path. What is left is degraded-mode and default behaviour. Four I'd settle before merging.

1. The 60M default now steers our own payload (preferences.rs:249, gloas_proposal.rs:705)

Every validator with a fee recipient signs DEFAULT_GAS_LIMIT as target_gas_limit, and produceBlockV4 writes the preference's value over inputs.target_gas_limit, which without a preference is the parent bid's gas limit. On a chain that is not at 60M (the devnet here ran 200M), every default-config proposal moves the limit toward 60M. The description lists the missing --gas-limit as pre-existing, which holds for registrations, where it could only cost a builder bid. From this PR it sets what our own payload targets, for every gloas proposal, with or without --builder-proposals. Keymanager overrides do not survive a restart, so there is no durable way to set it. I'd add the flag here rather than later.

2. A restarted node loses its preferences for up to two epochs (preferences.rs:350)

BuilderMarket is in memory. Once every node accepted, the client keeps only the (slot, root) key and skips that slot on every later poll. A node restarted mid-epoch then has nothing for the rest of the current epoch and the next: it ignores p2p bids for those slots and self-builds with prepare_beacon_proposer's fee recipient (or the zero address) and the parent's gas target. This is round one's point 2 with a restart in place of a refusal. Keeping the signed message after Everywhere and re-sending it once per epoch, as registrations do, would cover it; the node answers 200 for an identical entry.

3. One slow node delays every send (preferences.rs:279-287)

Each preference is its own submit, awaited in sequence, and each waits on join_all over every node under the 8 s REQUEST_TIMEOUT. With one node blackholed and 25 preferences owed (a stake share like the devnet's 40%), the healthy node gets the last one about 200 s after the poll began. current_slot is the slot the poll started in, so some are sent for slots that have since started. The healthy node refuses those, and the client warns "No beacon node accepted" and counts a failure for what was a delay. Single-flight drops every poll in between. Sending them concurrently, or as one batch per node (see below), removes the delay.

4. min_bid is fixed at 0 while the boost factor is now live (http.rs:141)

The comment ties min_bid to the later builder-API phase, but choose_payload applies it to the pooled p2p bids this PR turns on. With --prefer-builder-proposals (u64::MAX) a 1-gwei bid beats the local payload. With the default factor the same happens while our local payload's value is 0, which the description says it is today. A flag for the floor would let an operator turn --builder-proposals on safely.

Already in the description, still open

  • Warm-up preferences are gossiped before peers subscribe. The client takes the 200 as delivered and the node does not republish an identical entry, so nothing sends them again. Fine as a follow-up; worth an issue so it is not lost.
  • check_fee_recipient (proposal.rs:193) compares the block against the live setting. From gloas the node pays the address in the preference already published, so a keymanager change logs an ERROR and bumps fee_recipient_mismatches_total on every proposal until the next dependent root, and the hint names the wrong delay. Comparing against the preference signed for that slot would fix both.

Smaller

  • The node still ACCEPTs, and so relays, signatures under the two beta.2 domains (gossip/proposer_preferences.rs:242). A beta.4 peer REJECTs those, so relaying one costs us score with that peer, in the fork epoch. No client signs that way any more, so this is one more reason to narrow it as soon as the fixtures allow.
  • Partial delivery is invisible: logged at debug only and never counted. A second node that always refuses leaves every preference Partly, with published_total rising and failures_total at 0. docs/metrics.md says the failures counter takes what "a beacon node refused or failed to take"; the code and the metric's help text count only what no node accepted.
  • The poll snapshots every pubkey before the gloas gate, and after the fork fetches both epochs' duties each slot even with no keys loaded (preferences.rs:167, :206). refresh_proposers returns early on an empty set; this could too.
  • sign's warn! puts pubkey before slot (preferences.rs:318).

Simplifications, take or leave

  • handle_pool_submission already parses a per-index 400. Using it here allows one batch per node with each success marked on its own, which removes point 3.
  • published, partly_published and refusal_warned could be one map from (slot, root) to the signed message and its status, under one lock. That keeps the message point 2 needs.
  • proposer_duties_v2 repeats proposer_duties except for the path, in the client, the mock and the fallback.
  • Longer term, moving DutiesService to v2 and deriving preferences from it would leave one proposer schedule instead of two that can disagree after a reorg.

One map from (slot, dependent_root) to the signed message and its stage
replaces published/partly_published/refusal_warned. It keeps the message
after every node accepted it, which the rest needs:

- A beacon node holds preferences in memory only, so a restarted one had
  none for up to two epochs. Every kept preference whose slot is ahead is
  now sent again, as signed, once an epoch, at the poll of the epoch's last
  slot, so the next epoch's first slot is covered: a node restarted
  mid-epoch lacks only the rest of that epoch's. A re-send some node
  misses is sent each slot from then on; it is never counted as published
  again.
- Sends were one awaited request after another, so one node that does not
  answer delayed the last of 25 by minutes. Each preference is still its
  own request, but all of a poll's go out at once, and both target epochs
  are fetched and sent concurrently: a hung node costs one timeout per poll.
  With the slot clock, a slot that started while the poll waited is not
  sent, and one that started in flight is not reported as refused.
- Partial delivery is counted: the failures counter takes every send some
  node refused or did not answer, re-sends included, and the preferences
  newly held by only some nodes are warned about once per poll.
- The pre-sign fee recipient check accepts the address of a preference a
  node accepted for the slot, which a gloas node pays in preference to the
  current one, and its hint names the dependent-root delay.

Also: the poll gates on gloas and on an empty key set before reading the
store or fetching duties, sign's warning puts slot first, and the HTTP
client and the mock share one proposer duties lookup for v1 and v2.
From gloas the proposer preferences carry each validator's target gas
limit, and produceBlockV4 builds this client's own payload toward it, with
or without --builder-proposals. The default was the fixed 60M, so on a
network running elsewhere (the devnet ran 200M) every default proposal
pulled the limit toward 60M, and only a keymanager override, lost on
restart, could change it.

--gas-limit (default 60000000, under Lighthouse's flag name) sets the
default every validator without an override gets: builder registrations
before gloas, proposer preferences from it. Unlike Lighthouse's, it does
not require --builder-proposals, since the preferences are published
either way. Zero is refused. The keymanager's gas_limit endpoints serve
and fall back to it.

A default configuration still pulls a non-60M chain toward 60M. Reading
the network config's GAS_LIMIT_SCHEDULE, which Lighthouse tries before its
own 60M fallback, is left for later.
produceBlockV4's body sent min_bid 0 while this PR turns the boost factor
on, and choose_payload applies the floor to the pooled p2p bids: with
--prefer-builder-proposals (u64::MAX) any 1-gwei bid beat the local
payload, and with the default factor so did any bid while the local
payload's value is 0.

--builder-min-bid (gwei, default 0, requires --builder-proposals) is sent
as that min_bid. Gloas only; produceBlockV3 has no such parameter.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Thanks. The changes are in 9fed8e4, af7caeb and 320eb7b.

The four

  1. The 60M default: --gas-limit (af7caeb). It sets the default every validator gets unless the keymanager gives it its own: builder registrations before gloas, proposer preferences from gloas.

    • Default 60000000. The flag name is Lighthouse's.
    • Not tied to --builder-proposals, since the preferences are published either way. Lighthouse's flag requires it.
    • Zero is refused. The keymanager's gas_limit endpoints serve this value and fall back to it.

    A default configuration on a chain that is not at 60M still pulls toward 60M. Lighthouse first reads the network config's GAS_LIMIT_SCHEDULE, which this client does not. That is the follow-up.

  2. Restart: kept and re-sent once an epoch (9fed8e4). published, partly_published and refusal_warned are now one map from (slot, dependent_root) to the signed message and its stage. Once every node has accepted a preference, the client keeps it and re-sends it, as signed, once an epoch while its slot is ahead.

    • When: at the poll of the epoch's last slot, so the next epoch's first slot is covered too.
    • What is re-sent: only what the duties lookup asks for, not every entry in the map. A root from before a reorg, a started slot or a removed key is never re-sent.
    • Counting: a re-send is never counted as published again.
    • When a node misses it: if some node does not take the re-send, or none does (every node restarted and is still syncing), the preference is sent each slot until every node holds it.

    What remains: a node restarted mid-epoch lacks the rest of that epoch's preferences. Closing that would mean re-sending every kept preference every slot.

  3. One slow node (9fed8e4). Still one preference per request, but a poll sends all of them at once, and fetches and sends both target epochs concurrently.

    • Delay: a node that never answers now costs a poll one duties failover plus one request timeout, about 16 s, rather than one timeout per preference. A paused-clock test pins five sends to one 8 s wait.
    • Late slots: the service now holds the slot clock. A duty whose slot started while the poll waited is not sent. A failure that comes back after its slot started is logged at debug, not warned about or counted.

    I kept one request per preference instead of one batch per node. A request's own status is then the answer for its one entry, and no 400 body has to be parsed and trusted for the rest.

  4. min_bid: --builder-min-bid (320eb7b). In gwei, default 0, requires --builder-proposals, and sent as produceBlockV4's min_bid. Gloas only: produceBlockV3 has no such parameter.

Already in the description

  • Warm-up preferences gossiped before peers subscribe: filed as Warm-up proposer preferences are gossiped before peers subscribe to the gloas topics #794. The once-an-epoch re-send does not fix it, because the node answers an identical entry with 200 and does not republish it.
  • check_fee_recipient: a block may now pay either the current address or the address of any preference a node accepted for that slot.
    • This covers the window between a keymanager change and the next dependent root, so that ERROR no longer fires there.
    • The hint names both delays: preparation reaches the node at the next epoch boundary, and from gloas a preference keeps the old address until the next dependent root.

Smaller

  • beta.2 domains: unchanged. The fixtures are still pinned to v1.7.0-beta.2 (CONSENSUS_SPEC_TESTS_VERSION). Agreed that it should narrow when they move, for the scoring reason you give.
  • Partial delivery: now visible.
    • ..._failures_total takes every send that some node refused or did not answer, re-sends included. Its help text and docs/metrics.md now say so.
    • A poll warns once about the preferences that newly reached only some nodes.
  • Gloas gate and empty key set: both are checked before the store is read or any duties are fetched.
  • sign's warning: slot now comes first.

Simplifications

  • One map: done, as part of point 2.
  • Batch per node: not taken; see point 3.
  • proposer_duties_v2: the HTTP client and the mock now share one lookup. The fallback's two one-liners differ only in try_each versus try_each_quiet, so I left them.
  • DutiesService on v2: agreed, left for later.

Tests

Added:

  • the once-an-epoch re-send: identical bytes, counted once, covering the next epoch's first slot;
  • a re-send refused by one of two nodes, and one refused by every node;
  • the clock skip;
  • no keys loaded;
  • the slow-node wait;
  • --gas-limit and --builder-min-bid parsing, and the min bid in the request body;
  • the fee recipient check's accepted addresses.

Not covered: a refusal that arrives after its slot started, and the proposal service reading the preferences, which is three lines of wiring.

make lint, the validator lib, rpc lib and binary tests pass. Not devnet-tested yet.

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.
@pablodeymo

Copy link
Copy Markdown
Collaborator

Third pass, on the three response commits (9fed8e40, af7caebf, 320eb7b2). Read-through only: I did not build or run it. Line numbers are at 027c7ad2; I did not re-read what its merges brought in from #705.

The four are in and do what the reply says: --gas-limit replaces the default and a cleared keymanager override falls back to it, a preference every node took is kept as signed and sent again at the epoch's last slot, a poll sends everything for both epochs at once and skips a slot that started while it waited, and --builder-min-bid reaches choose_payload as the floor on value plus execution payment. The fee recipient check, the failures counter, the two early returns and the log order are as described too.

Three things I'd settle before merging. All are small.

1. The 60M default is wrong on both built-in gloas networks (bin/ethlambda/src/validator.rs:89)

Sepolia's and Platåberget's config.yaml both schedule a 200M gas limit (assets/sepolia/config.yaml:223, assets/plataberget/config.yaml:235). A validator started with default flags on either is last round's case: every proposal signs 60M and the node builds toward it, where before this PR it kept the parent's limit. The flag is the way out, but nothing tells the operator they need it; startup only carries gas_limit as a field. I'd file the GAS_LIMIT_SCHEDULE follow-up as an issue, and until it lands name the two networks and the value next to --gas-limit in docs/cli.md and the README.

2. The keymanager still takes a zero gas limit (crates/validator/src/http_api/proposer.rs:295)

--gas-limit refuses 0, but set_gas_limit hands parse_uint64's result to the settings as it comes, and "0" is well formed. From gloas that value is signed as target_gas_limit, the node builds toward it, and the preference cannot be replaced for its dependent root. A 400 for zero matches the flag.

3. A builder's bid can be signed with --builder-proposals off (crates/net/rpc/src/beacon/bid_selection.rs:69, crates/validator/src/proposal.rs:465)

Without the flag the client sends a factor of 0, and choose_payload returns the best pooled bid whenever there is no local build, whatever the factor. The module doc says a node ignores bids for a slot whose proposer published nothing, so until this PR there was no bid to take. Now the preferences go out unconditionally, and an execution client that fails at the proposal slot leaves the block on a builder the operator never opted into. Before gloas the client refuses a blinded block when the flag is off; the gloas path has no such check and signs it. --builder-min-bid requires the flag, so there is no floor either.

docs/cli.md does say 0 prefers the local payload "unless it cannot be built", but builder_config's doc (beacon_node/http.rs:142) says "self-build only, as before". Either answer is defensible: refuse a block that is not self-built when the flag is off, as for a blinded one, or keep it and say that from gloas the flag off still allows a bid when the local build fails. I'd want it decided and written down.

Fine as follow-ups

  • An identical preference can be answered with a 400 (crates/net/rpc/src/beacon/proposer_preferences.rs:116). The identical-entry check runs once, before validate. The client posts each preference to every node at once and the first node gossips it, so the same message can be recorded by gossip while the second node is still validating its HTTP copy. cheap_checks or record_preferences then reports AlreadySeen, and the client keeps the preference as partly delivered, counts a failure and warns. The next slot's re-send gets its 200. Reading the held entry again on AlreadySeen and treating an identical one as a success would close it. I have not seen it happen.
  • A restart with changed settings is refused every slot (crates/validator/src/preferences.rs:530). A client restarted with another --suggested-fee-recipient or --gas-limit, or after losing its keymanager overrides, signs a preference that differs from the one the node holds. The node refuses it, and the client signs and sends it again each slot until the slot starts, for the current and the next epoch, counting a failure each time. The one WARN says builders' bids are ignored and the node may not know the fee recipient, and here neither is true: the node holds the earlier preference. The text could at least name this case.
  • One node that is down keeps every preference partly delivered. Delivery is one flag per preference, so with one of two nodes unreachable nothing reaches Everywhere, and each slot posts every unstarted preference to both nodes again. Recording which nodes accepted would bound it. Take or leave.

Smaller

  • accepted_fee_recipients(slot) (preferences.rs:220) returns the address of every accepted preference for the slot, whatever the validator or root. After a reorg that moves the slot between two of this client's validators, a block paying the other one's address passes the check. Filtering on the duty's validator_index would fix it.
  • The partial-delivery WARN (preferences.rs:424) carries the epoch and a count. Which node refused, and why, is at debug only.
  • with_default_gas_limit (proposer_settings.rs:136) takes the lock apart and rebuilds the struct to set one field; get_mut() does it in place.

Accepted as they are

  • One re-send per preference, at the last slot before its epoch. The gap left, the rest of the current epoch after a node restart, is the one prepare_beacon_proposer has. Lighthouse's client does not re-send at all, and its node answers 200 to a preference it has seen, so the round costs nothing against it.
  • One request per preference instead of a batch.
  • The beta.2 domains, until the fixtures move.
  • Warm-up proposer preferences are gossiped before peers subscribe to the gloas topics #794 for the warm-up gossip.

…works

POST gas_limit refuses 0, as --gas-limit does: from gloas the value is signed into a proposer preference that cannot be replaced for its dependent root, and the node builds toward it.

--gas-limit's help, docs/cli.md and the README name Sepolia and Platåberget, whose GAS_LIMIT_SCHEDULE is 200000000, until #795 reads the schedule.

with_default_gas_limit sets the field in place through get_mut().
…osals

Without the flag a gloas block request carries a boost factor of 0, the local payload unless it cannot be built, so a node whose execution client fails at the slot may build on a pooled p2p bid. The client signs that block, as Lighthouse, Prysm and Teku do (Lodestar refuses it only in its opt-in executiononly mode). builder_config's 'self-build only, as before' said otherwise; proposal.rs's module doc, the CLI help, docs/cli.md and the README now say so, and a_builders_bid_gets_no_envelope_from_this_client pins it.

Revisiting the default itself is #796.
- accepted_fee_recipients takes the duty's validator index, so after a reorg that moves a slot between two of this client's validators, a block paying the other one's address no longer passes the fee recipient check.
- The node answers 200, not 400, for an identical preference gossip recorded while its HTTP copy was being validated: validate's AlreadySeen and a refused record_preferences both re-read the held entry. AlreadySeen is validate's first rule, so the earlier pre-check is folded into it.
- The 'No beacon node accepted' warning, and the module doc, name the restart with other settings, where the nodes keep and build with the preference signed before it.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Thanks. The changes are in e7decb6, ebc5a76 and 86d3b17.

The three

  1. The 60M default on Sepolia and Platåberget: filed as validator: take the default target gas limit from GAS_LIMIT_SCHEDULE #795, and documented (e7decb6). --gas-limit in docs/cli.md, the README and the CLI help now says both networks schedule 200000000 and to pass --gas-limit 200000000 on either, until validator: take the default target gas limit from GAS_LIMIT_SCHEDULE #795 reads GAS_LIMIT_SCHEDULE. The issue covers what that takes on both sides: the node does not parse or serve the key today, so /config/spec cannot hand it to the client.

  2. A zero gas limit through the keymanager: refused (e7decb6). POST gas_limit answers 400 for "0", as --gas-limit does, and leaves a value already set as it was. It is a check of its own rather than part of parse_uint64, since "0" is a well-formed Uint64. That route and the flag are the only ways a gas limit gets into the settings; validator_definitions.yml carries none.

  3. A builder's bid with --builder-proposals off: kept, and written down (ebc5a76). Without the flag the factor is 0, which beacon-APIs defines as the local payload unless it cannot be built. A node whose execution client fails at the slot may therefore build on a pooled bid, and the client signs that block.

    I checked what the other clients do at gloas with their builder setting off:

    Client Sends by default Validator client refuses a block not self-built Node falls back to a p2p bid at boost 0 when the local build fails
    Lighthouse boost 100; the CLI builder flags do not reach gloas production no yes
    Prysm boost 100; --enable-builder "has no effect after the gloas fork" no yes
    Teku boost 100; the flag only empties the builder-API entries no yes
    Lodestar boost 90; executiononly sends boost 0 and min_bid 2^64-1 only under executiononly yes, unless min_bid rules the bid out
    Nimbus boost 100; --payload-builder only adds relays no no: it errors
    • Ours is already the most conservative default. No client refuses this block by default, and only Lodestar has a mode that does.
    • The pre-gloas refusal was for a different reason. It exists because, without the flag, the client has no route to publish a blinded block. A gloas block built on a bid is a full block.
    • Refusing would cost the slot as well as the payload. The bid pays the fee recipient the preference named.

    proposal.rs's module doc gives the reasons. builder_config's "self-build only, as before" is gone, and the CLI help, docs/cli.md and the README say what the flag-off case does. a_builders_bid_gets_no_envelope_from_this_client, which proposes with builder proposals off, now says it pins this.

    Whether --builder-proposals should default on at gloas, or be split so p2p bids compete without it as in the other clients, is validator: revisit CLI defaults, starting with --builder-proposals at gloas #796. That issue also covers a self-build-only opt-out shaped like Lodestar's.

Follow-ups

  • An identical preference answered with a 400: closed (86d3b17). The pre-check is folded into validate, whose first rule is AlreadySeen, so a resubmission still costs no signature check. That verdict, and a record_preferences that refuses, now both re-read the held entry, and an identical one is a 200 without a republish. The existing resubmission test covers the first path. The race itself has no test: it needs gossip to record the message between the two calls.
  • A restart with changed settings: the behaviour is unchanged. The WARN now names this case: unless the nodes already hold a preference for the slot and root, signed before a restart with other settings, which they keep and build with. The module doc's "No replacement" section describes it too.
  • Per-node delivery: left. One node down still keeps every preference partly delivered and re-posted each slot. Recording which nodes accepted needs the fallback to answer per node and to send to a subset. The cost today is one small request per pending preference per slot.

Smaller

  • accepted_fee_recipients: done (86d3b17). It takes the duty's validator_index. A new test moves a slot between two of the client's validators by a reorg, and checks that each one's block may pay only its own address.
  • Which node refused, in the partial-delivery WARN: left. The WARN sums up a poll's preferences, which can have different refusing nodes and reasons. The fallback logs each node's position and answer at debug (ethlambda_validator::beacon_node::fallback). Carrying them up would make Delivery carry text through Tally for one line.
  • with_default_gas_limit: done (e7decb6), with get_mut().

make lint, the validator lib, rpc lib and binary tests pass. Not devnet-tested yet.

The once-an-epoch re-send ran at the poll of the epoch's last slot. Polls are single-flight, so a poll held up behind a slow node skips the next one, and when that was the last slot's, the round ran at the next epoch's first slot, which had already started: a node restarted mid-epoch then lacked that slot's preference. The round now starts at the second-to-last slot and still runs at the last when that poll is skipped.

A duty whose pubkey does not parse is now logged at debug rather than skipped silently.
Without --builder-proposals a gloas request carries a boost factor of 0, and a node whose execution client fails at the slot may still build on a pooled p2p bid, which the client signs. The floor that bounds that bid could not be set: clap required --builder-proposals for --builder-min-bid. It no longer does, and the value moves from BuilderProposals to ValidatorConfig::builder_min_bid, so it reaches produceBlockV4's min_bid either way.
From the bots' review of the proposer preferences flow.

At a real fork every preference for the first gloas epoch is signed and
sent in the epoch before it, so the node runs the gossip rules on a fulu
head. The existing boundary test put the head inside the first gloas
epoch. `World::before_the_gloas_fork` has a fulu head at slot 32 with
gloas at epoch 2, and the new test submits a preference for slot 64 the
way the validator client does: the duty and dependent root from the v2
duties route (read from fulu's proposer lookahead), signed, posted, and
accepted by `validate`. With `fixed_proposer` answering `None` for a
fulu state, this test fails and the gloas-head ones still pass.
`submit_as_the_validator_client` now takes the proposal slot.

Also:
- `proposer_duties_v2`'s trait doc says the fallback fails over quietly
  there, which only a comment in `fallback.rs` said.
- A README line in the validator client section is rewrapped to the
  paragraph's width.

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.

2 participants