Skip to content

feat(validator_store): configurable proposer delay before beacon block production #1207

Description

@shane-moore

Goal

Add an operator-configurable delay that holds the beacon block request until a target offset into the slot, so MEV-boost relays have longer to return a better bid. Behavioural parity with go-ssv's ProposerDelay.

Context and motivation

A large operator reports this as the single blocker preventing them adopting Anchor. It is a per-operator knob with no wire or serialized surface.

go-ssv v2.4.3 (31010e70b1) semantics, verified at source:

  • The wait sits in ProcessPreConsensus (protocol/v2/ssv/runner/proposer.go:150-161), after RANDAO quorum and reconstruction, before GetBeaconBlock.
  • remainingProposerDelay (:521-528) returns max(0, slot_start + delay - now). It is an absolute floor from slot start, not added latency. If pre-consensus already ran past the target there is no wait.
  • Not re-entrant, guarded by hasQuorum.
  • Default 0; refuses above 1s without AllowDangerousProposerDelay.
  • Read by nothing else in go-ssv. No compensating timeout or QBFT timer adjustment anywhere.

Suggested approach

Anchor's only hook between duty start and block fetch is randao_reveal (anchor/validator_store/src/lib.rs:2241); Arc<AnchorValidatorStore> goes straight to BlockServiceBuilder (anchor/client/src/lib.rs:720-732) with no wrapper. This placement is sound rather than incidental: get_validator_blocks_v3_ssz/v4_ssz take the reveal as a required parameter (common/eth2/src/lib.rs:2481-2493), so Lighthouse structurally cannot request the block first.

  • Add a decision function returning Disabled | TargetPassed | Waited(Duration) | ClockUnavailable, not Option<Duration>, so metrics distinguish a no-op from missing instrumentation.
  • Consume the clock_slot already captured at :2263 before collect_signature. Never re-sample.
  • checked_add on slot_start + delay, plus a hard upper bound refused regardless of the acknowledgement flag.
  • Fail open if start_of(slot) returns None. This must never cause a missed proposal through its own error path.
  • Call it at the single success return point of randao_reveal, skipped on the error path.
  • --proposer-delay-ms <u64> (default 0) and --allow-dangerous-proposer-delay on PayloadBuildingOptions (anchor/cli/src/cli.rs:466). Gate in config.rs::from_cli, copying the acknowledgement-flag pattern at :244-252.
  • 1s is a conservative policy threshold for operator parity, explicitly not a claimed safe ceiling. Anchor's round model cannot yield one: max_round = 6 at QUICK_TIMEOUT = 2s already exceeds the publication window.

Acceptance criteria

  • Behaviour identical to go-ssv: absolute floor from slot start, no wait when the target has passed, at most once per proposal.
  • Default 0 is a strict no-op with no added latency and no behaviour change.
  • Values above the acknowledgement threshold are refused at startup unless the flag is set; values above the hard bound are refused either way.
  • A checkpoint and a pre-wait span field are recorded before the wait, so RANDAO_REVEAL_COMPLETED stops attributing policy sleep to RANDAO work.
  • A decision histogram records no-wait outcomes, not only waits.
  • A histogram of successful RANDAO completion offset is recorded immediately after collect_signature, before any wait. This data does not exist today, because success emits only trace! (:2298) while logfile_debug_level defaults to DEBUG. It is what shows an operator whether the floor is biting at their configured value.
  • Documentation states the recommended starting value of 300ms, matching go-ssv, and states plainly that the setting is a minimum offset from slot start rather than added latency.
  • Regenerated docs/docs/generated/cli-node-options.mdx, or cli-reference-check fails.

Tests

Zero delay; normal delay; target already passed; clock unavailable fails open; overflow; exact threshold boundaries. Config gate accepted, rejected, and acknowledged, plus hard bound refused either way. Delay applied only on the success path. Cancellation drops the wait cleanly. In-slot invocation contract regression test. Mixed-delay cluster: operators with differing delays initialise proposer QBFT at different times and followers buffer up to MESSAGE_BUFFER_LIMIT = 100 (anchor/qbft_manager/src/instance.rs:28).

Notes

  • Follow go-ssv's guidance: default 0, recommended starting value 300ms. The absolute-floor semantics make the configured value portable across clients: the block request lands at max(randao_completion, slot_start + delay), so the same value produces the same in-slot request time on go-ssv and Anchor. What differs between clients is the downstream budget after that point, not the delay.
  • Do not present go-ssv's ~1.2s as Anchor's ceiling. That figure is derived from go-ssv's own latency budget (qbftTime = 350ms, blockSubmissionTime = 1000ms, miscellaneousTime = 150ms). Anchor's equivalents are unmeasured. Keep the 1s acknowledgement gate for operator parity, and validate our own headroom before endorsing values above it.
  • Note that go-ssv's docs/MEV_CONSIDERATIONS.md budget formula treats RANDAOTime + ProposerDelay as additive, while remainingProposerDelay implements max(). RANDAO time is absorbed by the delay, not added to it, so their ~1.2s is slightly conservative rather than wrong. It errs safe.
  • The completion-offset histogram is tuning support and headroom validation, not a gate on shipping. It is what lets an operator see whether the floor is actually biting at their configured value, and what we would use to justify raising guidance above 300ms.
  • Under Gloas the wait still executes and still consumes proposal budget; only the builder-bid benefit is absent while Lighthouse always builds locally (block_service.rs:624). Warrants a startup or fork-transition warning, not documentation alone.
  • Early RANDAO (feat(validator_store): early RANDAO pre-sign service behind --early-randao #1179) does not break this seam: it adds a separate early_randao_reveal(pubkey, proposal_slot) and preserves Lighthouse's unchanged in-slot call. But it will restructure randao_reveal's internals, hence the single-return-point requirement, and its eligibility is Gloas-gated so the synergy is Gloas-only.
  • Anchor has no config file or environment variable support, so operators set a CLI flag rather than porting ProposerDelay: 300ms from YAML. Broader gap, tracked separately.
  • Alternatives rejected: upstreaming a proposal_request_offset to Lighthouse's BlockService (architecturally cleaner and Lighthouse owns the true request boundary, but there is no precedent there for a knob trading missed-proposal risk for MEV, and the correct value is a function of SSV QBFT latency); sleeping in sign_block (too late, block already fetched); a relative sleep instead of a floor (removes late-duty recovery); sender-side buffering; a humantime dependency for 300ms ergonomics.

Issues are directionally correct, not prescriptive; verify symbols at PR time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions