feat(validator_store): add configurable proposer delay for MEV bids - #1213
Conversation
Operators cannot currently hold a block request to let MEV-Boost relays return a better bid. P2P.org named the absence of go-ssv's ProposerDelay as the blocker preventing them running Anchor. --proposer-delay-ms is an absolute floor from the start of the slot, not added latency: the request lands at max(randao_completion, slot_start + delay), so a duty whose pre-consensus already ran long is never delayed further. This matches go-ssv, so a value carries across clients. The wait sits at the success return of randao_reveal, which is the last point Anchor owns before block production: the reveal is a required parameter of the block request, so Lighthouse cannot ask earlier. Defaults to 0. Values above 1s require --allow-dangerous-proposer-delay, matching the SSV node's threshold; above 4s is refused as a typo guard. Two histograms record pre-consensus completion offset and the wait actually applied, so an operator can tell whether the delay ever bites. Closes sigp#1207
|
@claude, please take a look |
|
Claude finished @shane-moore's task in 4m 44s —— View job PR Review: configurable proposer delay for MEV bids
Overall this is a high-quality, well-scoped PR. The design is sound and I did not find any correctness, safety, or security issues I'm confident about. A few things I specifically checked and confirmed are correct: Verified correct
Minor observation (non-blocking) The policy ( Everything else — CLI plumbing, config threading, testing harness default ( |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## unstable #1213 +/- ##
============================================
+ Coverage 64.84% 65.00% +0.16%
============================================
Files 163 163
Lines 27785 28005 +220
============================================
+ Hits 18017 18205 +188
- Misses 9768 9800 +32
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…cision The policy had thorough pure-function coverage, but the async wrapper that actually sleeps and emits the metric had none, so "the decision is right" was proven while "the wait is applied" was not. await_proposer_delay only ever read self.proposer_delay, so make it a free function like its two neighbours. That drops the store dependency and lets the tests run on a paused clock, where tokio::time::Instant observes the same virtual clock that sleep advances. Also stop linking a public doc comment to a private item, which rustdoc warned about and CI does not check.
|
@claude Added the paused-clock coverage in 34faefd.
Also dropped an intra-doc link from a public item to a private one, which rustdoc warns about and CI does not check. |
|
Claude finished @shane-moore's task in 2m 4s —— View job Review: paused-clock coverage (34faefd)
This directly closes the gap I flagged in the earlier review — the async wrapper's side effects (sleep, metric emission) are now exercised, not just the pure decision. The changes are sound and I found no correctness issues. Refactor is safe. The paused-clock tests are the right shape and the arithmetic is correct. Traced against
Using Doc link fix is right and consistent. The public trait method One note, non-blocking: the no-wait test still calls Verification: I could not run Nice, tightly-scoped follow-up. |
|
Claude finished @shane-moore's task in 20s —— View job Acknowledged — global metric mutation stays as-isAgreed. Isolating it (per-test registry or a mockable metrics handle) would add more machinery than the risk warrants for what is a harmless observation on the no-wait branches. Recording The only thing to keep in mind is the note you already flagged — if someone later adds a test that asserts on those histograms, they'll need to account for the process-global state these tests write. That's a future-test concern, not a change to make now. Nothing outstanding from my side. With CI green on |
|
I found 1 that might deserve a look. Feel free to discard this is its not needed. There is currently no test that calls
Things that protect us a little bit -
You also note that early randao reveal in #1179 will restructure the internals of Pre-setup,
-use tokio::sync::watch;
+use tokio::{sync::watch, time::Instant}; pub(super) const TEST_SLOT: u64 = 1;
-const SLOT_DURATION_SECS: u64 = 12;
+pub(super) const SLOT_DURATION_SECS: u64 = 12; pub(super) struct CapturedSignatureCall {
pub(super) requester: SignatureRequester,
pub(super) validator_pubkey: PublicKeyBytes,
pub(super) signing_root: Hash256,
+ /// When the call was made.
+ pub(super) captured_at: Instant,
} self.captured.lock().push(CapturedSignatureCall {
requester,
validator_pubkey: signing_data.validator_pubkey,
signing_root: signing_data.root,
+ captured_at: Instant::now(),
}); pub(super) struct ValidatorStoreTestHarness {
pub(super) validator_store:
Arc<AnchorValidatorStore<ManualSlotClock, MainnetEthSpec, MockConsensusDecider>>,
committee_setups: Vec<CommitteeSetup>,
pub(super) captured_calls: CapturedCalls,
+ /// Shares `current_time` with the store's clone, so tests can reposition the clock.
+ pub(super) slot_clock: ManualSlotClock,
pub(super) is_synced_tx: watch::Sender<bool>,
_slashing_db_dir: TempDir,
_exit_signal: async_channel::Sender<()>,
} pub(super) fn new_with_fork(
committee_setups: Vec<CommitteeSetup>,
our_operator_id: OperatorId,
active_fork: Fork,
+ ) -> Self {
+ // No proposer delay by default: most tests assert timing-free behaviour.
+ Self::new_with_options(
+ committee_setups,
+ our_operator_id,
+ active_fork,
+ Duration::ZERO,
+ )
+ }
+
+ pub(super) fn new_with_proposer_delay(
+ committee_setups: Vec<CommitteeSetup>,
+ our_operator_id: OperatorId,
+ proposer_delay: Duration,
+ ) -> Self {
+ Self::new_with_options(
+ committee_setups,
+ our_operator_id,
+ Fork::Boole,
+ proposer_delay,
+ )
+ }
+
+ fn new_with_options(
+ committee_setups: Vec<CommitteeSetup>,
+ our_operator_id: OperatorId,
+ active_fork: Fork,
+ proposer_delay: Duration,
) -> Self { 30_000_000,
None,
false,
- Duration::ZERO, // no proposer delay: tests assert timing-free behaviour
+ proposer_delay,
false,
is_synced_rx,
executor, Self {
validator_store,
committee_setups,
captured_calls,
+ slot_clock,
is_synced_tx,
_slashing_db_dir: slashing_db_dir,
_exit_signal: exit_signal,
}Then comes the actual test implementation: 2.
|
|
at
Something like this? |
|
Queued — the merge queue status continues in this comment ↓. |
Add integration tests calling randao_reveal to prove the wait runs after signature collection on the success path only, and expand the ClockUnavailable warn to name both causes. Both from review by @jnhsigmap.
|
Both applied in 591bc23: your |
Merge Queue Status
Waiting for
All conditions
|
Problem, Evidence, and Context
Anchor requests a block as soon as RANDAO pre-consensus finishes. Relays that hold out for a better bid have not answered yet, so operators leave MEV on the table
Closes #1207.
Change Overview
--proposer-delay-msis an absolute floor from the start of the slot, not added latency. The request lands atmax(randao_completion, slot_start + delay), so a duty whose pre-consensus already ran past the target is never delayed further. This matches go-ssv exactly, so an operator's existing value carries across clients.The wait sits at the success return point of
randao_reveal. That is the last point Anchor owns before block production: the reveal is a required parameter of the block request, so Lighthouse structurally cannot ask earlier.Reading order:
cli.rsfor the two flags,client/src/config.rsfor the startup gate, thenvalidator_store/src/lib.rsfor the decision and the wait. Everything else is plumbing, metrics, and docs.Unchanged: default is
0, so behaviour is identical for anyone who does not set the flag. No change to the signing path, slashing protection, QBFT, or publication. Not fork-gated.Risks, Trade-offs, and Mitigations
The feature deliberately spends proposal headroom, so a large value can cost a proposal. That is the trade the operator opts into, and the docs say so rather than presenting it as free.
0; above1000needs--allow-dangerous-proposer-delay(matching the SSV node's threshold); above4000is refused as a typo guard, not as a safe ceiling.anchor_randao_reveal_completion_offset_seconds,anchor_proposer_delay_applied_seconds) let an operator see whether the delay bites at all. The applied wait is measured after the sleep, not before, so it reports the real wait.Known and deliberate: the floor is approximate under a backward clock adjustment, since elapsed time is sampled once and the wait runs on a monotonic timer. A re-check loop could turn a clock correction into extra proposal-critical delay, so failing open is preferred.
Post-Gloas this keeps working but buys something different, and go-ssv splits it into a second fork-gated knob. Tracked separately in #1212; out of scope here.
Validation
make testfull suite green;make lint,make cargo-fmt-check,make mdlint,make cli-reference-checkall pass.Rollback
Revert the commit. The default is
0, so no operator is affected unless they opted in, and there is no config, data, or on-disk migration.