From 5224e9617389a610a4ec065140d9117df8688f50 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 17:16:49 +0300 Subject: [PATCH 01/37] docs/MEV_CONSIDERATIONS: rewrite around PBS-side timing games Reframe MEV configuration guidance around PBS-side timing games (mev-boost >= v1.11 with -config, or commit-boost) as the recommended approach. Soft-deprecate SSV ProposerDelay to a legacy appendix; no removal date, no behavior change. Code-side changes are doc-comment alignment only: - config/config.example.yaml: MEV configuration block reframed. - cli/operator/node.go: ProposerDelay env-description softened; AllowDangerousProposerDelay unit consistency (s -> ms). - protocol/v2/ssv/runner/proposer.go: ProposerRunner.proposerDelay and ProposerRunnerOptions.ProposerDelay doc comments reframed; internal field comment DRYed to reference the exported field. --- cli/operator/node.go | 4 +- config/config.example.yaml | 14 +- docs/MEV_CONSIDERATIONS.md | 307 +++++++++++++++++++++-------- protocol/v2/ssv/runner/proposer.go | 11 +- 4 files changed, 242 insertions(+), 94 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index c3df393f1f..5a947a1140 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -110,8 +110,8 @@ type config struct { KeyStore KeyStore `yaml:"KeyStore"` SSVSigner SSVSignerConfig `yaml:"SSVSigner" env-prefix:"SSV_SIGNER_"` Graffiti string `yaml:"Graffiti" env:"GRAFFITI" env-description:"Custom graffiti for block proposals" env-default:"ssv.network"` - ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#getting-started-with-mev-configuration for detailed instructions on how to use it."` - AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1s (dangerous, may cause missed block proposals)"` + ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Legacy MEV configuration. The recommended approach is PBS-side timing games (mev-boost v1.11+ launched with -config, or commit-boost); leave ProposerDelay at 0 when those are configured. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1000ms (dangerous, may cause missed block proposals)"` OperatorPrivateKey string `yaml:"OperatorPrivateKey" env:"OPERATOR_KEY" env-description:"Operator private key for contract event decryption"` MetricsAPIPort int `yaml:"MetricsAPIPort" env:"METRICS_API_PORT" env-description:"Port for metrics API server"` EnableTraces bool `yaml:"EnableTraces" env:"ENABLE_TRACES" env-description:"Enable Open Telemetry traces"` diff --git a/config/config.example.yaml b/config/config.example.yaml index adc5f36b3c..9a9d737b5f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -37,13 +37,17 @@ p2p: OperatorPrivateKey: # MEV Configuration (Optional) -# Duration to wait before requesting block proposal if this operator is proposer-duty Leader. -# This allows extracting higher MEV by waiting for better bids. Default is 0 (no delay). -# Recommended starting value: 300ms. See docs/MEV_CONSIDERATIONS.md for details. +# Recommended approach: configure timing games on the PBS layer (mev-boost v1.11+ launched +# with -config, or commit-boost). With PBS-side timing games configured, leave ProposerDelay +# at its default value of 0. See docs/MEV_CONSIDERATIONS.md for details. +# +# ProposerDelay is the legacy SSV-side lever — a delay before requesting the block-proposal, +# extracting higher MEV by waiting for better bids. Use this only when your PBS does not +# support timing games. Default is 0 (no delay). # ProposerDelay: 300ms -# Safety flag to allow ProposerDelay values higher than 1s. -# WARNING: Values above 1s significantly increase the risk of missing block proposals! +# Safety flag to allow ProposerDelay values higher than 1000ms. +# WARNING: Values above 1000ms significantly increase the risk of missing block proposals! # Only set to true if you understand the risks and have carefully read the MEV documentation. # AllowDangerousProposerDelay: false diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 0db7877d38..985bc55564 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -1,112 +1,257 @@ -## Getting started with `MEV` configuration +# MEV considerations -To get the most out of MEV opportunities Operator can configure ProposerDelay configuration setting using a configuration -file (or `PROPOSER_DELAY` environment variable): +## TL;DR + +To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. + +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config`, or any other PBS lacking the feature), the SSV-side `ProposerDelay` configuration is still available — see Appendix A below. PBS-side timing games are the preferred path because they don't consume SSV's slot budget for the auction wait. + +## SSV proposer-duty flow background + +To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: +- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (call it `RANDAOTime`). +- The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays. +- The PBS returns the chosen block header, and the SSV cluster goes through the QBFT consensus phase to sign it as Validator (call it `QBFTTime`). +- QBFT may require multiple rounds in case of round-leader faults. Each round can take up to `RoundTimeout` (currently 2000ms on the SSV protocol level), so QBFT is allowed at most 2 rounds before the Ethereum block-propagation deadline of 4000ms after slot start. +- Once QBFT completes, the Operator submits the signed block to the Beacon node for propagation (call it `BlockSubmissionTime`). +- A small additional overhead (`MiscellaneousTime`) covers the glue code wiring this together. + +For an SSV cluster to function reliably, the following must hold: ``` -ProposerDelay: 300ms +RANDAOTime + (auction window) + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4000ms ``` +If this doesn't hold, the validator misses its proposal slot (the block must propagate within 4000ms after slot start). -As per our own estimates the max reasonable value of `ProposerDelay` for Ethereum mainnet is around ~1.2s, -although we recommend starting with something like 300ms gradually increasing it up - the higher -`ProposerDelay` value is the higher the chance of missing Ethereum block proposal will be. +For QBFT to complete in a single round — the common and desirable case — the following tighter constraint also has to hold: +``` +RANDAOTime + (auction window) + QBFTTime + MiscellaneousTime < 2000ms +``` -### Important Safety Limitation +Where the "auction window" sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within these deadlines. -**The SSV node will refuse to start if ProposerDelay is set higher than 1s without explicit confirmation.** +## PBS-side timing games (recommended) -If you attempt to use a ProposerDelay value higher than 1s, the node will exit with an error message. -If you understand the risks and want to proceed anyway, you must also set the `AllowDangerousProposerDelay` flag: +The PBS layer implements "timing games" — proactively polling relays at intervals defined in its own config, decoupling *when* the auction happens from *when* SSV asks for the block. SSV asks once and receives whatever bid the PBS has selected by its slot-relative cutoff. -```yaml -ProposerDelay: 2000ms -AllowDangerousProposerDelay: true +This is preferred over `ProposerDelay` because: +- The SSV node doesn't sit idle during the auction wait — its slot clock doesn't advance, so QBFT round 1 isn't squeezed. +- The PBS layer handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. +- Configuration is concentrated in the PBS, rather than coordinated across SSV-side and PBS-side knobs. + +### Configuration knobs + +Both mev-boost and commit-boost expose the same five knobs with identical names: + +- `timeout_get_header_ms` — per-relay-request timeout for a single `getHeader` call. +- `late_in_slot_time_ms` — slot-relative hard cutoff. The PBS returns to the caller no later than this point in the slot. +- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. +- `target_first_request_ms` — when the first poll for this relay fires, measured from slot start. +- `frequency_get_header_ms` — interval between subsequent polls for this relay. + +The effective per-request deadline is: ``` +max_timeout_ms = min(timeout_get_header_ms, late_in_slot_time_ms - ms_into_slot) +slot-relative cutoff = ms_into_slot + max_timeout_ms +``` +When the PBS receives the request early in the slot, the per-request `timeout_get_header_ms` tends to bind. When asked later, `late_in_slot_time_ms - ms_into_slot` binds, and the slot-relative cutoff equals `late_in_slot_time_ms`. -Or using environment variables: -```bash -PROPOSER_DELAY=2000ms ALLOW_DANGEROUS_PROPOSER_DELAY=true ./bin/ssvnode start-node +### PBS-specific notes + +- **commit-boost** validates `timeout_get_header_ms < late_in_slot_time_ms` at config load — refuses to start otherwise. Set `timeout_get_header_ms` just below `late_in_slot_time_ms` so the slot-relative cutoff binds for any realistic ask time. +- **mev-boost (v1.11+)** has the same knobs and the same budget math, but does not enforce that strict inequality — values may be equal. mev-boost requires `-config ` to enable the YAML-based timing-games config; `-watch-config` enables hot reload. +- Default `late_in_slot_time_ms`: mev-boost `2000ms`, commit-boost `3000ms`. Both are aggressive defaults assuming a well-tuned QBFT and BN setup. +- mev-boost selects the most-recently-received bid per relay, then compares across relays for the highest value. + +Upstream references: +- mev-boost timing-games doc: [github.com/flashbots/mev-boost/blob/main/docs/timing-games.md](https://github.com/flashbots/mev-boost/blob/main/docs/timing-games.md) +- commit-boost docs: [commit-boost.github.io/commit-boost-client](https://commit-boost.github.io/commit-boost-client/) + +## Configuration examples + +Two scenarios, each shown for both PBSes. The starting numbers below are reasonable defaults for a healthy mainnet cluster — operators should validate them against their own measured latencies before adopting (see [Tuning guidance](#tuning-guidance--measurement-methodology)). + +### Example A — "block header at SSV by ~1500ms" (recommended safe default) + +Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. That leaves headroom for QBFT round 1 to complete before the 2000ms round-1 deadline in a healthy cluster, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. + +The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 200`) fires polls at 700ms, 900ms, 1100ms, and 1300ms — four chances per relay, with ~150ms RTT margin for the last poll to complete before the cutoff. + +**commit-boost** (TOML): +```toml +[pbs] +late_in_slot_time_ms = 1450 +timeout_get_header_ms = 1430 # must be < late_in_slot_time_ms in commit-boost +timeout_get_payload_ms = 4000 + +[[relays]] +url = "https://@relay-1.example" +enable_timing_games = true +target_first_request_ms = 700 # polls at 700ms, 900ms, 1100ms, 1300ms +frequency_get_header_ms = 200 + +[[relays]] +url = "https://@relay-2.example" +enable_timing_games = true +target_first_request_ms = 700 +frequency_get_header_ms = 200 ``` -**Warning:** Using ProposerDelay values higher than 1s significantly increases the risk of missing block proposals, -which can result in penalties and lost rewards. +**mev-boost** (YAML): +```yaml +timeout_get_header_ms: 1450 +late_in_slot_time_ms: 1450 # mev-boost permits equality +relays: + - url: https://@relay-1.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 200 + - url: https://@relay-2.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 200 +``` + +### Example B — "close equivalent of `ProposerDelay = 1000ms`" -As per the notes in other sections of this document `ProposerDelay` depends on a number of things, to find -the best value Operator might want to start with lower values like 300ms gradually increasing it up. +`ProposerDelay = 1000ms` on the legacy path causes SSV to wait 1000ms before asking the PBS, which then queries each relay once at t≈1000ms. The PBS-timing-games equivalent below lands the last poll at ~1000ms — same auction-window position — without burning SSV's slot budget. QBFT round 1 starts as soon as the header arrives (around 1050ms) instead of after a 1000ms idle wait. -## MEV considerations & SSV proposer-duty flow background +This is more conservative than Example A; useful for operators migrating from a legacy `ProposerDelay = 1000ms` configuration who want to swap to timing games with minimal behavioral change. -To understand how MEV fits with the SSV cluster, here is some background on the SSV proposer-duty flow: -- SSV node participates in the pre-consensus phase to build RANDAO signature that will be used when - requesting block from Beacon node (let's say it takes `RANDAOTime`) -- SSV node (all nodes in the cluster really to handle round-changes, but current round Leader - specifically) requests blinded block header from Beacon node which in turn "proxies" this request - to MEV-boost that runs with some pre-configured timeout (call it `MEVBoostRelayTimeout`) -- MEV-boost sends multiple requests to Relays it knows about and waits until that - `MEVBoostRelayTimeout` time to choose the best block (based on the corresponding bid) -- SSV node receives the response with the chosen block header and goes through QBFT consensus phase - to sign it as Validator (let's say it takes `QBFTTime` at most - we can estimate it - statistically with some probability/confidence) -- QBFT consensus phase might require several rounds to complete in case there is a fault with the - chosen round leader, each round can take up to `RoundTimeout` (currently set to 2s on SSV-protocol - level, which also means there will be 2 rounds at most because Ethereum block must be proposed - within 4s from slot start) meaning if round 1 doesn't complete in under `RoundTimeout` another - leader will be chosen to try and complete QBFT in round 2, etc. -- once QBFT completes successfully, Operator needs to submit the signed block to Beacon node to - propagate it throughout Ethereum network (call it `BlockSubmissionTime`) -- there is some time spent on executing various code to "glue" this whole thing together - that's small but still matters (call it `MiscellaneousTime`) +**commit-boost**: +```toml +[pbs] +late_in_slot_time_ms = 1050 +timeout_get_header_ms = 1030 +timeout_get_payload_ms = 4000 -and so this means for the best SSV cluster operations we want the following condition to always hold true: +[[relays]] +url = "https://@relay-1.example" +enable_timing_games = true +target_first_request_ms = 700 # polls at 700ms, 850ms, 1000ms +frequency_get_header_ms = 150 ``` -RANDAOTime + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s + +**mev-boost**: +```yaml +timeout_get_header_ms: 1050 +late_in_slot_time_ms: 1050 +relays: + - url: https://@relay-1.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 150 ``` -if this equation doesn't hold, Validator will miss his opportunity to propose the block (slot will be -missed if the corresponding Ethereum block isn't published within 4s after slot start time) -and so the most straightforward approach to extract highest MEV is (and it probably works out-of-the box -with SSV nodes already, but with caveats mentioned below): +## Tuning guidance & measurement methodology + +The example configs are starting points. Tuning these knobs in production requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value won't be optimal for everyone. + +### Where the auction window should land + +Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: +- `QBFT + submission < 4000ms − cutoff`. The block must propagate by 4000ms after slot start. +- Ideally, round 1 completes within the 2000ms round-1 deadline. Cutoffs much beyond ~1500ms push round 1 past its deadline and force round-2 leadership every slot. + +Example A's 1500ms is the recommended starting point. Example B's ~1050ms is more conservative — useful while you learn your stack's behavior under timing games. -### approach 1 +### What to measure first -To set `MEVBoostRelayTimeout` as high as possible, and have plenty of Relays -(MEV-boost is configured with) so that Relays themselves decide "how much time they want to wait -since Slot start before replying with a block/bid" - some Relays would be fast to reply but not -as profitable as those that withhold for longer +Before changing knobs, baseline these values: -^ the problem of this approach is that SSV node Operator might not be able to find/choose Relays -that fit his MEV desires (maybe he can only use those that respond right away without any additional -delay for the sake of higher MEV) +- **RANDAO completion time** — how long pre-consensus takes. Visible via `measurements.PreConsensusTime()`. +- **BN → PBS RTT** — typically same machine, well under 10ms. +- **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this. +- **QBFT round-1 completion distribution** — via `measurements.ConsensusTime()`. +- **Submission round-trip** — includes the relay payload-reveal step. -thus an alternative approach would be: +### SSV telemetry -### approach 2 +Relevant logs and metrics already emitted by SSV: -To introduce an additional configurable delay `ProposerDelay` SSV Operator can set so -that it will work nicely even with Relays that "reply as fast as possible", the equation from above -becomes: +- `"got beacon block proposal"` log with `took` duration, in `protocol/v2/ssv/runner/proposer.go`. +- `"received proposal"` debug log with `score`, `latency`, `blinded`, and `pending`, in `beacon/goclient/proposer.go`. Emitted per BN response in multi-BN setups. +- `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. + +For multi-BN setups, per-BN scoring visibility comes from the parallel-fetch path in `beacon/goclient/proposer.go`. + +### Mainnet ground truth + +For quantifying MEV capture on mainnet, the relay data APIs are authoritative: + +- `/relay/v1/data/bidtraces/proposer_payload_delivered?proposer_pubkey=` — what bid was delivered to your validator, with timestamps. +- `/relay/v1/data/bidtraces/builder_blocks_received?slot=` — every bid the relay saw for a given slot. + +A useful capture-efficiency metric: `delivered_value / max_bid_at_T`, where T is your auction cutoff time. This lets you compare different configurations on equal footing. + +### Mainnet vs testnet + +Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic builders, and their bid distributions don't reflect mainnet economics. Use testnet for end-to-end plumbing validation only — proposer reliability, correct config parsing, no missed slots. For MEV-uplift quantification, use mainnet validator data + relay-data APIs. + +### Iteration discipline + +- Start with PBS defaults; tighten `late_in_slot_time_ms` toward later values gradually. +- Change one knob per iteration window. +- Monitor miss rate alongside bid-value distribution; back off if miss rate degrades. +- Allow enough observation time — proposals are sparse (roughly one per validator per month on mainnet), so small validator sets need long windows for statistical signal. + +### Multi-BN caveat + +The parallel-fetch logic in `beacon/goclient/proposer.go` exits as soon as one BN returns a blinded block, even if a slower BN would have returned a higher-scoring bid. With timing-games-capable PBSes on multiple BNs, the fastest BN's bid effectively wins regardless of score. Worth knowing if you're running redundant BN setups and expecting cross-BN bid scoring to matter. + +## Interaction with `ProposerDelay` + +When PBS-side timing games are configured, set `ProposerDelay = 0` (the default). Stacking is redundant — both mechanisms position the auction window in the slot, but only one should do so. Setting both also triggers SSV's `proposalSoftTimeout -= proposerDelay` reduction in `beacon/goclient/options.go`, which can shrink the multi-BN scoring window unnecessarily. + +## Appendix A — `ProposerDelay` (legacy approach) + +`ProposerDelay` remains supported. It is the right tool when: +- Your PBS does not support timing games (mev-boost < v1.11, or any PBS without the feature). +- You are running mev-boost without `-config ` and don't want to introduce a YAML config file. +- Operator constraints prevent PBS-side configuration changes. + +To configure, set in the SSV config file (or via the `PROPOSER_DELAY` environment variable): +```yaml +ProposerDelay: 300ms ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s + +With `ProposerDelay` active, the slot-budget equation becomes: +``` +RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4000ms +``` + +Plugging in realistic numbers: +``` +RANDAOTime ≈ 100ms +MEVBoostRelayTimeout ≈ 200ms +QBFTTime ≈ 350ms +MiscellaneousTime ≈ 150ms +BlockSubmissionTime ≈ 1000ms +ProposerDelay = 4000ms − (sum above) ≈ 2200ms ``` -plugging in some realistic numbers into that ^ formula we get a rough estimate of ~2.2s for `ProposerDelay`: -```go -const randaoTime = 100 * time.Millisecond -const mevBoostRelayTimeout = 200 * time.Millisecond -const qbftTime = 350 * time.Millisecond -const miscellaneousTime = 150 * time.Millisecond -const blockSubmissionTime = 1000 * time.Millisecond -const proposerDelay = 4*time.Second - randaoTime - mevBoostRelayTimeout - qbftTime - blockSubmissionTime - miscellaneousTime +The 2200ms figure is the absolute slot-deadline budget. The tighter QBFT round-1 deadline matters more in practice: ``` -but on top of that, another consideration Operator needs to take into account is QBFT round timeout, specifically -round 1 timeout. For proposer duty round 1 times out at ~2s after slot start time (so that proposer duty can execute 2 -QBFT rounds, if necessary, and still complete before that desirable 4s after slot start deadline). To avoid round 1 timing out -we'd want the following equation to hold: -```go -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + MiscellaneousTime < 2s +RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + MiscellaneousTime < 2000ms +``` +which gives `ProposerDelay ≤ ~1200ms` for round 1 to complete on time. + +We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet. Going beyond risks missed block proposals. + +We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. + +## Appendix B — Safety limits + +**The SSV node refuses to start if `ProposerDelay` is set higher than 1000ms without explicit confirmation.** + +If you attempt to use a `ProposerDelay` value higher than 1000ms, the node exits with an error message. If you understand the risks and want to proceed anyway, you must also set the `AllowDangerousProposerDelay` flag: + +```yaml +ProposerDelay: 2000ms +AllowDangerousProposerDelay: true ``` -and with the values listed above this gives us `ProposerDelay` value of ~1.2s. -Therefore, we consider ~1.2s to be the maximum reasonable value for `ProposerDelay`, going beyond that value might -result in missed block proposal. +Or via environment variables: +```bash +PROPOSER_DELAY=2000ms ALLOW_DANGEROUS_PROPOSER_DELAY=true ./bin/ssvnode start-node +``` -**To enforce proposer safety limits, the SSV node will automatically prevent startup if ProposerDelay exceeds 1s -unless the Operator explicitly acknowledges the risk by setting `AllowDangerousProposerDelay: true`.** +**Warning:** `ProposerDelay` values higher than 1000ms significantly increase the risk of missed block proposals, which can result in penalties and lost rewards. diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 06316ea8a6..2c16565423 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -45,9 +45,7 @@ type ProposerRunner struct { // ValCheck is used to validate the qbft-value(s) proposed by other Operators. ValCheck ssv.ValueChecker - // proposerDelay allows Operator to configure a delay to wait out before requesting Ethereum - // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. + // proposerDelay; see ProposerRunnerOptions.ProposerDelay. proposerDelay time.Duration // cachedFullBlock holds the initially fetched full (non-blinded) block @@ -69,9 +67,10 @@ type ProposerRunnerOptions struct { ValCheck ssv.ValueChecker HighestDecidedSlot phase0.Slot Graffiti []byte - // ProposerDelay allows Operator to configure a delay to wait out before requesting Ethereum - // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. + // ProposerDelay is the legacy SSV-side MEV-extraction lever — a delay before requesting + // the Ethereum block to capture later (higher-value) bids. The recommended approach is + // PBS-side timing games (mev-boost v1.11+ with -config, or commit-boost), in which case + // this stays at 0. See docs/MEV_CONSIDERATIONS.md. ProposerDelay time.Duration } From 159b35bd1ebebde796031ac55f2c58df9dc90645 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 17:51:09 +0300 Subject: [PATCH 02/37] docs+operator: MEV_CONSIDERATIONS rewrite review follow-ups Address review feedback on #2855: - cli/operator/node.go: ms-unit consistency in validateProposerDelayConfig error message and warn-log fields. The zap.Duration fields are renamed to proposer_delay_ms / max_safe_proposer_delay_ms (Int64) so the unit is explicit in structured log output. - docs/MEV_CONSIDERATIONS.md: - Configuration knobs: state enable_timing_games defaults to false. - Example A: parenthetical noting the 50ms overhead assumes BN and PBS co-located with SSV. - Example B: add second relay to match Example A's structure. - Appendix A: clarify that the 200ms MEVBoostRelayTimeout figure assumes legacy single-shot PBS behavior; not relevant when running timing-games-capable PBS. --- cli/operator/node.go | 8 ++++---- docs/MEV_CONSIDERATIONS.md | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index 5a947a1140..daf8c244b0 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -843,14 +843,14 @@ func validateProposerDelayConfig(logger *zap.Logger) error { if cfg.ProposerDelay > maxSafeProposerDelay { if !cfg.AllowDangerousProposerDelay { - return fmt.Errorf("ProposerDelay value %v exceeds maximum safe delay of %v. "+ + return fmt.Errorf("ProposerDelay value %dms exceeds maximum safe delay of %dms. "+ "This may cause missed block proposals. "+ "If you understand the risks and want to proceed, set AllowDangerousProposerDelay to true or use the ALLOW_DANGEROUS_PROPOSER_DELAY environment variable", - cfg.ProposerDelay, maxSafeProposerDelay) + cfg.ProposerDelay.Milliseconds(), maxSafeProposerDelay.Milliseconds()) } logger.Warn("Using dangerous ProposerDelay value that may cause missed block proposals", - zap.Duration("proposer_delay", cfg.ProposerDelay), - zap.Duration("max_safe_proposer_delay", maxSafeProposerDelay)) + zap.Int64("proposer_delay_ms", cfg.ProposerDelay.Milliseconds()), + zap.Int64("max_safe_proposer_delay_ms", maxSafeProposerDelay.Milliseconds())) } return nil diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 985bc55564..37b30c826a 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -44,7 +44,7 @@ Both mev-boost and commit-boost expose the same five knobs with identical names: - `timeout_get_header_ms` — per-relay-request timeout for a single `getHeader` call. - `late_in_slot_time_ms` — slot-relative hard cutoff. The PBS returns to the caller no later than this point in the slot. -- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. +- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. Defaults to `false` in both PBSes; must be set per-relay. - `target_first_request_ms` — when the first poll for this relay fires, measured from slot start. - `frequency_get_header_ms` — interval between subsequent polls for this relay. @@ -72,7 +72,7 @@ Two scenarios, each shown for both PBSes. The starting numbers below are reasona ### Example A — "block header at SSV by ~1500ms" (recommended safe default) -Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. That leaves headroom for QBFT round 1 to complete before the 2000ms round-1 deadline in a healthy cluster, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. +Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. (This assumes BN and PBS are co-located with SSV; a remote BN adds network RTT and the 50ms allowance should be widened accordingly.) That leaves headroom for QBFT round 1 to complete before the 2000ms round-1 deadline in a healthy cluster, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 200`) fires polls at 700ms, 900ms, 1100ms, and 1300ms — four chances per relay, with ~150ms RTT margin for the last poll to complete before the cutoff. @@ -129,6 +129,12 @@ url = "https://@relay-1.example" enable_timing_games = true target_first_request_ms = 700 # polls at 700ms, 850ms, 1000ms frequency_get_header_ms = 150 + +[[relays]] +url = "https://@relay-2.example" +enable_timing_games = true +target_first_request_ms = 700 +frequency_get_header_ms = 150 ``` **mev-boost**: @@ -140,6 +146,10 @@ relays: enable_timing_games: true target_first_request_ms: 700 frequency_get_header_ms: 150 + - url: https://@relay-2.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 150 ``` ## Tuning guidance & measurement methodology @@ -234,6 +244,8 @@ RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + MiscellaneousTime ``` which gives `ProposerDelay ≤ ~1200ms` for round 1 to complete on time. +**Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. + We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet. Going beyond risks missed block proposals. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 43ddf96878b481f1d7251b118f406488035cd27f Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 18:00:10 +0300 Subject: [PATCH 03/37] cli/operator: align node_test with renamed proposer_delay_ms log fields Test_validateProposerDelayConfig was asserting on the old zap field names (proposer_delay, max_safe_proposer_delay) and time.Duration values. The previous follow-up commit renamed these to proposer_delay_ms / max_safe_proposer_delay_ms with int64 type. Update the assertions to match. Fixes the unit-test job on #2855. --- cli/operator/node_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/operator/node_test.go b/cli/operator/node_test.go index 67ce2eadac..e9ec2c5c3b 100644 --- a/cli/operator/node_test.go +++ b/cli/operator/node_test.go @@ -459,10 +459,10 @@ func Test_validateProposerDelayConfig(t *testing.T) { // Check log fields fields := logs[0].ContextMap() - require.Contains(t, fields, "proposer_delay") - require.Contains(t, fields, "max_safe_proposer_delay") - require.Equal(t, delay, fields["proposer_delay"]) - require.Equal(t, 1000*time.Millisecond, fields["max_safe_proposer_delay"]) + require.Contains(t, fields, "proposer_delay_ms") + require.Contains(t, fields, "max_safe_proposer_delay_ms") + require.Equal(t, delay.Milliseconds(), fields["proposer_delay_ms"]) + require.Equal(t, int64(1000), fields["max_safe_proposer_delay_ms"]) }) } }) From e4525775df3331ab4f00e4840e96250ffab22af4 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 18:23:15 +0300 Subject: [PATCH 04/37] docs/MEV_CONSIDERATIONS: remove incorrect round-1 deadline framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original doc claimed QBFT round 1 times out 2000ms after slot start and used this to derive a "tighter constraint" formula. In reality the proposer round timer is round-relative (2000ms from when the round starts), not slot-relative — see protocol/v2/qbft/roundtimer/timer.go line 151-158 and #2429. Changes: - §2 background: expand the 4000ms budget equation to include QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime; drop the now-removed "tighter constraint" 2000ms equation entirely. - Example A intro: replace "round-1 deadline" rationale with a reference to the 4000ms slot deadline. - Tuning section: replace the "round-1 deadline" bullet with a safety-margin framing for the ~2000ms cutoff guidance. - Appendix A: update the legacy formula to use the new term breakdown; remove the incorrect "1200ms = 2000ms minus everything else" derivation; keep the same ~1200ms practical ceiling but rejustify it as a buffer against variance in QBFT, submission, and relay payload-reveal latencies. No behavior change. --- docs/MEV_CONSIDERATIONS.md | 52 +++++++++++++++----------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 37b30c826a..a3886aebc7 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -9,25 +9,19 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without ## SSV proposer-duty flow background To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: -- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (call it `RANDAOTime`). -- The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays. -- The PBS returns the chosen block header, and the SSV cluster goes through the QBFT consensus phase to sign it as Validator (call it `QBFTTime`). -- QBFT may require multiple rounds in case of round-leader faults. Each round can take up to `RoundTimeout` (currently 2000ms on the SSV protocol level), so QBFT is allowed at most 2 rounds before the Ethereum block-propagation deadline of 4000ms after slot start. -- Once QBFT completes, the Operator submits the signed block to the Beacon node for propagation (call it `BlockSubmissionTime`). -- A small additional overhead (`MiscellaneousTime`) covers the glue code wiring this together. +- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (`RANDAOTime`). +- The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays (the *auction window*). +- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFTRound1Time`; if round 1 faults, `QBFTRound2Time` for the fallback round). Each round has a 2000ms timer, currently measured from round start rather than slot start (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). +- After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigningTime`). +- The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmissionTime`). For an SSV cluster to function reliably, the following must hold: ``` -RANDAOTime + (auction window) + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4000ms +RANDAOTime + (auction window) + QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms ``` -If this doesn't hold, the validator misses its proposal slot (the block must propagate within 4000ms after slot start). +In the typical case where round 1 succeeds, `QBFTRound2Time = 0` and the constraint has comfortable slack. If round 1 times out, round 2 starts; in the worst case where both rounds consume their full timer, the slot deadline is at risk. If the equation doesn't hold, the validator misses its proposal slot (the block must propagate within 4000ms after slot start). -For QBFT to complete in a single round — the common and desirable case — the following tighter constraint also has to hold: -``` -RANDAOTime + (auction window) + QBFTTime + MiscellaneousTime < 2000ms -``` - -Where the "auction window" sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within these deadlines. +Where the auction window sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. ## PBS-side timing games (recommended) @@ -72,7 +66,7 @@ Two scenarios, each shown for both PBSes. The starting numbers below are reasona ### Example A — "block header at SSV by ~1500ms" (recommended safe default) -Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. (This assumes BN and PBS are co-located with SSV; a remote BN adds network RTT and the 50ms allowance should be widened accordingly.) That leaves headroom for QBFT round 1 to complete before the 2000ms round-1 deadline in a healthy cluster, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. +Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. (This assumes BN and PBS are co-located with SSV; a remote BN adds network RTT and the 50ms allowance should be widened accordingly.) That leaves comfortable budget for QBFT consensus, post-consensus signing, and block submission to complete within the 4000ms slot deadline, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 200`) fires polls at 700ms, 900ms, 1100ms, and 1300ms — four chances per relay, with ~150ms RTT margin for the last poll to complete before the cutoff. @@ -159,8 +153,8 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- `QBFT + submission < 4000ms − cutoff`. The block must propagate by 4000ms after slot start. -- Ideally, round 1 completes within the 2000ms round-1 deadline. Cutoffs much beyond ~1500ms push round 1 past its deadline and force round-2 leadership every slot. +- `QBFT + post-consensus signing + submission < 4000ms − cutoff`. The block must propagate by 4000ms after slot start. +- A safety margin for variance in QBFT consensus, signing, and submission latencies. An unlucky combination of slower-than-typical components can add several hundred ms to the budget; cutoffs much beyond ~2000ms tighten the slot enough that occasional spikes risk missing the deadline. Example A's 1500ms is the recommended starting point. Example B's ~1050ms is more conservative — useful while you learn your stack's behavior under timing games. @@ -226,27 +220,23 @@ ProposerDelay: 300ms With `ProposerDelay` active, the slot-budget equation becomes: ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4000ms +RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms ``` -Plugging in realistic numbers: -``` -RANDAOTime ≈ 100ms -MEVBoostRelayTimeout ≈ 200ms -QBFTTime ≈ 350ms -MiscellaneousTime ≈ 150ms -BlockSubmissionTime ≈ 1000ms -ProposerDelay = 4000ms − (sum above) ≈ 2200ms -``` -The 2200ms figure is the absolute slot-deadline budget. The tighter QBFT round-1 deadline matters more in practice: +Plugging in realistic numbers (typical case where round 1 succeeds): ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + MiscellaneousTime < 2000ms +RANDAOTime ≈ 100ms +MEVBoostRelayTimeout ≈ 200ms +QBFTRound1Time ≈ 350ms +QBFTRound2Time ≈ 0ms (typically not needed) +PostConsensusSigningTime ≈ 150ms +BlockSubmissionTime ≈ 1000ms +ProposerDelay = 4000ms − (sum above) ≈ 2200ms ``` -which gives `ProposerDelay ≤ ~1200ms` for round 1 to complete on time. **Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. -We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet. Going beyond risks missed block proposals. +The 2200ms figure is the theoretical maximum assuming median latencies for every component. In practice, QBFT consensus, BN submission, and relay payload-reveal latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~1000ms of headroom is buffer against this variance. Going beyond risks missed block proposals. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 3dd5817e41eb8f2535122b952c71df89ad5831c6 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 18:40:42 +0300 Subject: [PATCH 05/37] docs/MEV_CONSIDERATIONS: restructure Example A/B around bid-sample time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Example B framed itself as "close equivalent of ProposerDelay = 1000ms" using the wrong dimension: it matched header-arrival time at SSV (~1050ms), whereas legacy ProposerDelay=1000ms actually delivers the header at SSV at ~1500-2000ms (after mev-boost's full getHeaderTimeout). The correct equivalence dimension is when relay bids are sampled. Both the previous Example B (last poll at ~1000ms) and legacy ProposerDelay=1000ms (single query at t=1000ms) sample bids at the same moment — PBS-timing-games just returns the result earlier, freeing slot budget. Restructure: - New Example A = previous Example B's numbers (cutoff=1050ms), reframed as the bid-sample equivalent of legacy ProposerDelay=1000ms. Now the recommended starting point. - New Example B = aggressive (cutoff=1800ms). Fully utilizes SSV's proposalSoftTimeout buffer; lands last relay poll at ~1600ms, header at SSV by ~1850ms. Captures more intra-slot bid growth at the cost of less variance margin. - Tuning section: update the "Example A starting point" reference to the new numbers. --- docs/MEV_CONSIDERATIONS.md | 64 ++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index a3886aebc7..bdeac8fd5d 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -64,86 +64,90 @@ Upstream references: Two scenarios, each shown for both PBSes. The starting numbers below are reasonable defaults for a healthy mainnet cluster — operators should validate them against their own measured latencies before adopting (see [Tuning guidance](#tuning-guidance--measurement-methodology)). -### Example A — "block header at SSV by ~1500ms" (recommended safe default) +### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) -Targets a PBS-side cutoff of `1450ms`; with ~50ms overhead between PBS → BN → SSV, the header arrives at SSV by ~1500ms. (This assumes BN and PBS are co-located with SSV; a remote BN adds network RTT and the 50ms allowance should be widened accordingly.) That leaves comfortable budget for QBFT consensus, post-consensus signing, and block submission to complete within the 4000ms slot deadline, while still positioning the auction window late enough to capture a meaningful fraction of intra-slot bid growth. +Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline: the relay bids you'll see are sampled at the same moment in the slot. -The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 200`) fires polls at 700ms, 900ms, 1100ms, and 1300ms — four chances per relay, with ~150ms RTT margin for the last poll to complete before the cutoff. +This is **not** the same as legacy `ProposerDelay = 1000ms` in terms of when the header arrives at SSV — legacy would deliver the header to SSV anywhere from ~1500ms to ~2000ms (after mev-boost's `getHeaderTimeout` runs its course), whereas this configuration delivers it at ~1050ms. PBS-timing-games is strictly better at the same bid-sample time: same bid quality, more slot budget left for QBFT and submission. + +The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms — three chances per relay, with the last poll landing at the target bid-sample time. **commit-boost** (TOML): ```toml [pbs] -late_in_slot_time_ms = 1450 -timeout_get_header_ms = 1430 # must be < late_in_slot_time_ms in commit-boost +late_in_slot_time_ms = 1050 +timeout_get_header_ms = 1030 # must be < late_in_slot_time_ms in commit-boost timeout_get_payload_ms = 4000 [[relays]] url = "https://@relay-1.example" enable_timing_games = true -target_first_request_ms = 700 # polls at 700ms, 900ms, 1100ms, 1300ms -frequency_get_header_ms = 200 +target_first_request_ms = 700 # polls at 700ms, 850ms, 1000ms +frequency_get_header_ms = 150 [[relays]] url = "https://@relay-2.example" enable_timing_games = true target_first_request_ms = 700 -frequency_get_header_ms = 200 +frequency_get_header_ms = 150 ``` **mev-boost** (YAML): ```yaml -timeout_get_header_ms: 1450 -late_in_slot_time_ms: 1450 # mev-boost permits equality +timeout_get_header_ms: 1050 +late_in_slot_time_ms: 1050 # mev-boost permits equality relays: - url: https://@relay-1.example enable_timing_games: true target_first_request_ms: 700 - frequency_get_header_ms: 200 + frequency_get_header_ms: 150 - url: https://@relay-2.example enable_timing_games: true target_first_request_ms: 700 - frequency_get_header_ms: 200 + frequency_get_header_ms: 150 ``` -### Example B — "close equivalent of `ProposerDelay = 1000ms`" +### Example B — aggressive: fully use SSV's ~1800ms header-fetch buffer -`ProposerDelay = 1000ms` on the legacy path causes SSV to wait 1000ms before asking the PBS, which then queries each relay once at t≈1000ms. The PBS-timing-games equivalent below lands the last poll at ~1000ms — same auction-window position — without burning SSV's slot budget. QBFT round 1 starts as soon as the header arrives (around 1050ms) instead of after a 1000ms idle wait. +SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options.go`) sets the wall-clock budget SSV allocates for collecting block-header responses from BNs. This example targets that full budget: PBS-side cutoff at `1800ms`, last relay poll at ~1600ms, header at SSV by ~1850ms. -This is more conservative than Example A; useful for operators migrating from a legacy `ProposerDelay = 1000ms` configuration who want to swap to timing games with minimal behavioral change. +The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. -**commit-boost**: +Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2950ms (Example A) to ~2150ms. Workable for healthy clusters but leaves less buffer for latency variance — use only after baselining your stack's QBFT and submission timings. + +**commit-boost** (TOML): ```toml [pbs] -late_in_slot_time_ms = 1050 -timeout_get_header_ms = 1030 +late_in_slot_time_ms = 1800 +timeout_get_header_ms = 1780 # must be < late_in_slot_time_ms in commit-boost timeout_get_payload_ms = 4000 [[relays]] url = "https://@relay-1.example" enable_timing_games = true -target_first_request_ms = 700 # polls at 700ms, 850ms, 1000ms -frequency_get_header_ms = 150 +target_first_request_ms = 1000 # polls at 1000ms, 1200ms, 1400ms, 1600ms +frequency_get_header_ms = 200 [[relays]] url = "https://@relay-2.example" enable_timing_games = true -target_first_request_ms = 700 -frequency_get_header_ms = 150 +target_first_request_ms = 1000 +frequency_get_header_ms = 200 ``` -**mev-boost**: +**mev-boost** (YAML): ```yaml -timeout_get_header_ms: 1050 -late_in_slot_time_ms: 1050 +timeout_get_header_ms: 1800 +late_in_slot_time_ms: 1800 # mev-boost permits equality relays: - url: https://@relay-1.example enable_timing_games: true - target_first_request_ms: 700 - frequency_get_header_ms: 150 + target_first_request_ms: 1000 + frequency_get_header_ms: 200 - url: https://@relay-2.example enable_timing_games: true - target_first_request_ms: 700 - frequency_get_header_ms: 150 + target_first_request_ms: 1000 + frequency_get_header_ms: 200 ``` ## Tuning guidance & measurement methodology @@ -156,7 +160,7 @@ Bid value grows through the slot: more transaction order flow becomes available, - `QBFT + post-consensus signing + submission < 4000ms − cutoff`. The block must propagate by 4000ms after slot start. - A safety margin for variance in QBFT consensus, signing, and submission latencies. An unlucky combination of slower-than-typical components can add several hundred ms to the budget; cutoffs much beyond ~2000ms tighten the slot enough that occasional spikes risk missing the deadline. -Example A's 1500ms is the recommended starting point. Example B's ~1050ms is more conservative — useful while you learn your stack's behavior under timing games. +Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, at the cost of less variance margin. ### What to measure first From 7183dd4095ea6fcba11a23d1623d24d11a521b87 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 18:55:09 +0300 Subject: [PATCH 06/37] docs/MEV_CONSIDERATIONS: update BlockSubmissionTime estimate to ~200ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original 1000ms figure (inherited from the pre-rewrite doc) was conservative. Updating to ~200ms to better reflect typical observed latency for SSV → BN → relay submission + payload-reveal. Derived numbers in Appendix A propagated: - Theoretical ProposerDelay max: 2200ms → 3000ms. - Headroom buffer at the recommended ~1200ms practical ceiling: ~1000ms → ~1800ms. The recommended ~1200ms ProposerDelay ceiling is preserved. --- docs/MEV_CONSIDERATIONS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index bdeac8fd5d..e34e9cb5a2 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -234,13 +234,13 @@ MEVBoostRelayTimeout ≈ 200ms QBFTRound1Time ≈ 350ms QBFTRound2Time ≈ 0ms (typically not needed) PostConsensusSigningTime ≈ 150ms -BlockSubmissionTime ≈ 1000ms -ProposerDelay = 4000ms − (sum above) ≈ 2200ms +BlockSubmissionTime ≈ 200ms +ProposerDelay = 4000ms − (sum above) ≈ 3000ms ``` **Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. -The 2200ms figure is the theoretical maximum assuming median latencies for every component. In practice, QBFT consensus, BN submission, and relay payload-reveal latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~1000ms of headroom is buffer against this variance. Going beyond risks missed block proposals. +The 3000ms figure is the theoretical maximum assuming median latencies for every component. In practice, QBFT consensus, BN submission, and relay payload-reveal latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~1800ms of headroom is buffer against this variance. Going beyond risks missed block proposals. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From bfd7bc6a6f93d3a7b24124f8738fae879e13af4d Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:00:13 +0300 Subject: [PATCH 07/37] docs/MEV_CONSIDERATIONS: clarify slot-budget math in tuning bullet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Where the auction window should land" bullet used the term "cutoff" without explicit definition and omitted the ~70ms post-PBS, pre-QBFT overhead from the slot-budget arithmetic. - Replace "cutoff" with the explicit config field name `late_in_slot_time_ms`. - Add the ~70ms (BN→SSV transport + pre-QBFT blinding) to the math so the inequality is actually right. --- docs/MEV_CONSIDERATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index e34e9cb5a2..7e03047c63 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -157,7 +157,7 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- `QBFT + post-consensus signing + submission < 4000ms − cutoff`. The block must propagate by 4000ms after slot start. +- `QBFT + post-consensus signing + submission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding, both of which happen after the PBS cutoff and before QBFT can start). The block must propagate by 4000ms after slot start. - A safety margin for variance in QBFT consensus, signing, and submission latencies. An unlucky combination of slower-than-typical components can add several hundred ms to the budget; cutoffs much beyond ~2000ms tighten the slot enough that occasional spikes risk missing the deadline. Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, at the cost of less variance margin. From 4e89418a80894d39986786e3ab988a976daad484 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:01:54 +0300 Subject: [PATCH 08/37] docs/MEV_CONSIDERATIONS: remove "Mainnet ground truth" section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specific relay-data API endpoints and the delivered_value/max_bid_at_T metric aren't really SSV-doc material — operators interested in measuring MEV capture at that level can find this information in upstream relay docs. --- docs/MEV_CONSIDERATIONS.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 7e03047c63..bf3c23442a 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -182,15 +182,6 @@ Relevant logs and metrics already emitted by SSV: For multi-BN setups, per-BN scoring visibility comes from the parallel-fetch path in `beacon/goclient/proposer.go`. -### Mainnet ground truth - -For quantifying MEV capture on mainnet, the relay data APIs are authoritative: - -- `/relay/v1/data/bidtraces/proposer_payload_delivered?proposer_pubkey=` — what bid was delivered to your validator, with timestamps. -- `/relay/v1/data/bidtraces/builder_blocks_received?slot=` — every bid the relay saw for a given slot. - -A useful capture-efficiency metric: `delivered_value / max_bid_at_T`, where T is your auction cutoff time. This lets you compare different configurations on equal footing. - ### Mainnet vs testnet Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic builders, and their bid distributions don't reflect mainnet economics. Use testnet for end-to-end plumbing validation only — proposer reliability, correct config parsing, no missed slots. For MEV-uplift quantification, use mainnet validator data + relay-data APIs. From c33e588e352c557e5d882c174893343e59ca2a7e Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:03:04 +0300 Subject: [PATCH 09/37] docs/MEV_CONSIDERATIONS: fix Example A header-arrival figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example A's late_in_slot_time_ms is 1050ms; with ~50ms BN→SSV transport overhead, the header arrives at SSV at ~1100ms, not ~1050ms. - Example A description: "~1050ms" → "~1100ms (1050ms PBS cutoff + ~50ms BN→SSV transport)". - Example B trade-off math: Example A's remaining slot budget at 4000ms − 1100ms = ~2900ms (was stated as ~2950ms). The "Example A's ~1050ms cutoff" reference in the Tuning section is unchanged — it correctly refers to the late_in_slot_time_ms value, not the header arrival time. --- docs/MEV_CONSIDERATIONS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index bf3c23442a..8a5104245d 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -68,7 +68,7 @@ Two scenarios, each shown for both PBSes. The starting numbers below are reasona Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline: the relay bids you'll see are sampled at the same moment in the slot. -This is **not** the same as legacy `ProposerDelay = 1000ms` in terms of when the header arrives at SSV — legacy would deliver the header to SSV anywhere from ~1500ms to ~2000ms (after mev-boost's `getHeaderTimeout` runs its course), whereas this configuration delivers it at ~1050ms. PBS-timing-games is strictly better at the same bid-sample time: same bid quality, more slot budget left for QBFT and submission. +This is **not** the same as legacy `ProposerDelay = 1000ms` in terms of when the header arrives at SSV — legacy would deliver the header to SSV anywhere from ~1500ms to ~2000ms (after mev-boost's `getHeaderTimeout` runs its course), whereas this configuration delivers it at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV transport). PBS-timing-games is strictly better at the same bid-sample time: same bid quality, more slot budget left for QBFT and submission. The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms — three chances per relay, with the last poll landing at the target bid-sample time. @@ -113,7 +113,7 @@ SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. -Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2950ms (Example A) to ~2150ms. Workable for healthy clusters but leaves less buffer for latency variance — use only after baselining your stack's QBFT and submission timings. +Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. Workable for healthy clusters but leaves less buffer for latency variance — use only after baselining your stack's QBFT and submission timings. **commit-boost** (TOML): ```toml From 81ee7cf0546757795319a41f582507020068d474 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:06:43 +0300 Subject: [PATCH 10/37] minor adjustment --- docs/MEV_CONSIDERATIONS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 8a5104245d..5e3d7fcf4c 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -166,10 +166,10 @@ Example A's ~1050ms cutoff is the recommended starting point — equivalent to l Before changing knobs, baseline these values: -- **RANDAO completion time** — how long pre-consensus takes. Visible via `measurements.PreConsensusTime()`. +- **RANDAO completion time** — how long pre-consensus takes, visible on Grafana charts. - **BN → PBS RTT** — typically same machine, well under 10ms. - **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this. -- **QBFT round-1 completion distribution** — via `measurements.ConsensusTime()`. +- **QBFT round-1 completion distribution** — visible on Grafana charts. - **Submission round-trip** — includes the relay payload-reveal step. ### SSV telemetry From b91ba8f9182fe40f187837afaa2888568b72d815 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:14:41 +0300 Subject: [PATCH 11/37] docs/MEV_CONSIDERATIONS: budget for worst-case 2-round QBFT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Appendix A values block treated QBFTRound2Time as "typically not needed" — implying it's safe to skip in the budget. That's wrong: for the slot deadline to hold in any realistic scenario (round 1 succeeds OR round 1 fails and round 2 runs), the equation must always include the round-2-fallback path. Changes: - §2 narrative: drop the "typical case round 2 = 0" framing; state explicitly that the equation must hold for the worst case where round 1 fails and round 2 runs. - Appendix A values: QBFTRound1Time set to 2000ms (the round-1 timer worst case), QBFTRound2Time set to 350ms (typical round-2 success). Theoretical ProposerDelay max accordingly drops from 3000ms to 1000ms. - Appendix A narrative: practical ProposerDelay ceiling lowered from ~1200ms to ~800ms (~200ms variance buffer below the new 1000ms theoretical max). - Example B trade-off: explicitly note that Example B's 2150ms remaining slot budget falls below the ~2700ms required for the worst-case 2-round scenario, so Example B accepts "round 1 must succeed" as an operational constraint. - Tuning section reference: Example B trade-off rephrased to match. --- docs/MEV_CONSIDERATIONS.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 5e3d7fcf4c..438cfedc45 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -15,11 +15,11 @@ To understand how MEV configuration interacts with SSV, here is the proposer-dut - After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigningTime`). - The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmissionTime`). -For an SSV cluster to function reliably, the following must hold: +For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: ``` RANDAOTime + (auction window) + QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms ``` -In the typical case where round 1 succeeds, `QBFTRound2Time = 0` and the constraint has comfortable slack. If round 1 times out, round 2 starts; in the worst case where both rounds consume their full timer, the slot deadline is at risk. If the equation doesn't hold, the validator misses its proposal slot (the block must propagate within 4000ms after slot start). +`QBFTRound1Time` is the 2000ms round-1 timer (worst case: round 1 doesn't reach consensus and the timer expires); `QBFTRound2Time` is a typical successful round-2 time. You must budget for both — in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). Where the auction window sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. @@ -113,7 +113,7 @@ SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. -Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. Workable for healthy clusters but leaves less buffer for latency variance — use only after baselining your stack's QBFT and submission timings. +Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2700ms required to fit the worst-case 2-round QBFT scenario (2000ms round-1 timer + ~350ms round 2 + ~150ms signing + ~200ms submission). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -160,7 +160,7 @@ Bid value grows through the slot: more transaction order flow becomes available, - `QBFT + post-consensus signing + submission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding, both of which happen after the PBS cutoff and before QBFT can start). The block must propagate by 4000ms after slot start. - A safety margin for variance in QBFT consensus, signing, and submission latencies. An unlucky combination of slower-than-typical components can add several hundred ms to the budget; cutoffs much beyond ~2000ms tighten the slot enough that occasional spikes risk missing the deadline. -Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, at the cost of less variance margin. +Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled, and fits the worst-case 2-round QBFT scenario. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, but accepts that round 1 must succeed (the slot is missed if round 1 fails). ### What to measure first @@ -222,16 +222,16 @@ Plugging in realistic numbers (typical case where round 1 succeeds): ``` RANDAOTime ≈ 100ms MEVBoostRelayTimeout ≈ 200ms -QBFTRound1Time ≈ 350ms -QBFTRound2Time ≈ 0ms (typically not needed) +QBFTRound1Time ≈ 2000ms (worst case: round-1 timer expires) +QBFTRound2Time ≈ 350ms (round 2 succeeds after round 1 failure) PostConsensusSigningTime ≈ 150ms BlockSubmissionTime ≈ 200ms -ProposerDelay = 4000ms − (sum above) ≈ 3000ms +ProposerDelay = 4000ms − (sum above) ≈ 1000ms ``` **Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. -The 3000ms figure is the theoretical maximum assuming median latencies for every component. In practice, QBFT consensus, BN submission, and relay payload-reveal latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~1200ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~1800ms of headroom is buffer against this variance. Going beyond risks missed block proposals. +The 1000ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, round-2 consensus, signing, and submission latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~800ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~200ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 494bdaa5ac4c8d5f2e6ba53babec8b9a4aa2028a Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:17:15 +0300 Subject: [PATCH 12/37] docs/MEV_CONSIDERATIONS: add QBFTRoundChange to slot-budget equation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-change step between round 1 and round 2 was previously absorbed implicitly. Make it explicit as its own term so the equation reflects the actual sequence: round 1 fails (2000ms timer) -> ROUND-CHANGE handshake -> round 2 starts Updates: - §2 bullet, equation, and narrative include the new term and explain what QBFTRoundChange covers. - Appendix A: equation and values block include QBFTRoundChange ~ 150ms. Theoretical ProposerDelay max: 1000ms -> 850ms. Practical ceiling recommendation lowered from ~800ms to ~700ms (~150ms variance headroom against the theoretical max). - Example B trade-off: required post-cutoff budget for worst-case 2-round scenario updated from ~2700ms to ~2850ms (= 2000 R1 + 150 RC + 350 R2 + 150 signing + 200 submission). --- docs/MEV_CONSIDERATIONS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 438cfedc45..8231ac4753 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -11,15 +11,15 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: - SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (`RANDAOTime`). - The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays (the *auction window*). -- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFTRound1Time`; if round 1 faults, `QBFTRound2Time` for the fallback round). Each round has a 2000ms timer, currently measured from round start rather than slot start (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). +- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFTRound1Time`; if round 1 faults, `QBFTRoundChange` for the round-change handshake and `QBFTRound2Time` for the fallback round). Each round has a 2000ms timer, currently measured from round start rather than slot start (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). - After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigningTime`). - The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmissionTime`). For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: ``` -RANDAOTime + (auction window) + QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms +RANDAOTime + (auction window) + QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms ``` -`QBFTRound1Time` is the 2000ms round-1 timer (worst case: round 1 doesn't reach consensus and the timer expires); `QBFTRound2Time` is a typical successful round-2 time. You must budget for both — in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). +`QBFTRound1Time` is the 2000ms round-1 timer (worst case: round 1 doesn't reach consensus and the timer expires); `QBFTRoundChange` is the ROUND-CHANGE handshake (operators exchange round-change messages and elect a new leader); `QBFTRound2Time` is a typical successful round-2 time. You must budget for all three — in the common case round 1 succeeds quickly and round-change/round 2 never run, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). Where the auction window sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. @@ -113,7 +113,7 @@ SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. -Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2700ms required to fit the worst-case 2-round QBFT scenario (2000ms round-1 timer + ~350ms round 2 + ~150ms signing + ~200ms submission). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2850ms required to fit the worst-case 2-round QBFT scenario (2000ms round-1 timer + ~150ms round change + ~350ms round 2 + ~150ms signing + ~200ms submission). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -215,7 +215,7 @@ ProposerDelay: 300ms With `ProposerDelay` active, the slot-budget equation becomes: ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTRound1Time + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms +RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms ``` Plugging in realistic numbers (typical case where round 1 succeeds): @@ -223,15 +223,16 @@ Plugging in realistic numbers (typical case where round 1 succeeds): RANDAOTime ≈ 100ms MEVBoostRelayTimeout ≈ 200ms QBFTRound1Time ≈ 2000ms (worst case: round-1 timer expires) +QBFTRoundChange ≈ 150ms (ROUND-CHANGE handshake after round 1 failure) QBFTRound2Time ≈ 350ms (round 2 succeeds after round 1 failure) PostConsensusSigningTime ≈ 150ms BlockSubmissionTime ≈ 200ms -ProposerDelay = 4000ms − (sum above) ≈ 1000ms +ProposerDelay = 4000ms − (sum above) ≈ 850ms ``` **Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. -The 1000ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, round-2 consensus, signing, and submission latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~800ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~200ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. +The 850ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, round-change handshake, round-2 consensus, signing, and submission latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~150ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 534ae748ed78fa0c3c63bc84bbd3766622b478cf Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:19:43 +0300 Subject: [PATCH 13/37] docs/MEV_CONSIDERATIONS: align tuning bullets with 2-round budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Where the auction window should land" bullets still referenced an older ~2000ms cutoff guideline that was tied to the round-1-only budget framing. With the 2-round-budget framing now used throughout the doc, the meaningful threshold is ~1080ms (above which round-2 fallback no longer fits). Restructured to three bullets: - Round-2 viability inequality, with worst-case values plugged in to derive late_in_slot_time_ms ≲ ~1080ms. - ~1080ms boundary semantics — cutoffs above this accept "round 1 must succeed"; Example B (1800ms) is in this regime. - Higher ~2500ms threshold where even round-1-only path becomes risky due to latency variance. --- docs/MEV_CONSIDERATIONS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 8231ac4753..a7ac32e02c 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -157,8 +157,9 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- `QBFT + post-consensus signing + submission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding, both of which happen after the PBS cutoff and before QBFT can start). The block must propagate by 4000ms after slot start. -- A safety margin for variance in QBFT consensus, signing, and submission latencies. An unlucky combination of slower-than-typical components can add several hundred ms to the budget; cutoffs much beyond ~2000ms tighten the slot enough that occasional spikes risk missing the deadline. +- **Round-2 fallback must fit:** `QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + post-consensus signing + submission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding). Plugging in worst-case values (2000ms R1 timer + 150ms round change + 350ms R2 + 150ms signing + 200ms submission = 2850ms post-cutoff budget required), this resolves to `late_in_slot_time_ms ≲ ~1080ms` — the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. +- **Cutoffs above ~1080ms** accept that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. +- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes in QBFT, signing, or submission risk missing the deadline even when round 1 succeeds. Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled, and fits the worst-case 2-round QBFT scenario. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, but accepts that round 1 must succeed (the slot is missed if round 1 fails). From 6a38859ff2f84d831ecd26caddfd41e89ac0a1f9 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:24:19 +0300 Subject: [PATCH 14/37] docs/MEV_CONSIDERATIONS: collapse QBFT terms, drop Time suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equation variables read more cleanly without the redundant Time suffix, and the three QBFT terms (Round1Time + RoundChange + Round2Time) are easier to reason about as a single QBFT bucket whose value covers the worst-case 2-round scenario (~2500ms). Renames applied in §2, Example B trade-off, Tuning bullets, and Appendix A: - RANDAOTime -> RANDAO - QBFTRound1Time + QBFTRoundChange + QBFTRound2Time -> QBFT - PostConsensusSigningTime -> PostConsensusSigning - BlockSubmissionTime -> BlockSubmission MEVBoostRelayTimeout is unchanged — its suffix is "Timeout", not "Time", and it refers to mev-boost's actual timeout setting. The §2 narrative now states the QBFT breakdown inline (2000ms R1 timer + ~150ms round change + ~350ms R2 ≈ 2500ms) so the worst-case 2-round budget remains visible. No numerical changes. --- docs/MEV_CONSIDERATIONS.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index a7ac32e02c..7b913f46f1 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -9,17 +9,17 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without ## SSV proposer-duty flow background To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: -- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (`RANDAOTime`). +- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (`RANDAO`). - The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays (the *auction window*). -- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFTRound1Time`; if round 1 faults, `QBFTRoundChange` for the round-change handshake and `QBFTRound2Time` for the fallback round). Each round has a 2000ms timer, currently measured from round start rather than slot start (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). -- After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigningTime`). -- The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmissionTime`). +- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFT`). This includes round 1, plus the round-change handshake and round 2 if round 1 fails. Each round has a 2000ms timer, currently round-relative rather than slot-relative (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). +- After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigning`). +- The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmission`). For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: ``` -RANDAOTime + (auction window) + QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms +RANDAO + (auction window) + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` -`QBFTRound1Time` is the 2000ms round-1 timer (worst case: round 1 doesn't reach consensus and the timer expires); `QBFTRoundChange` is the ROUND-CHANGE handshake (operators exchange round-change messages and elect a new leader); `QBFTRound2Time` is a typical successful round-2 time. You must budget for all three — in the common case round 1 succeeds quickly and round-change/round 2 never run, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). +`QBFT` is the worst-case time the cluster spends in QBFT consensus: 2000ms round-1 timer (if round 1 fails) + ~150ms round-change handshake + ~350ms successful round 2 ≈ 2500ms. You must budget for this worst case — in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). Where the auction window sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. @@ -113,7 +113,7 @@ SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. -Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2850ms required to fit the worst-case 2-round QBFT scenario (2000ms round-1 timer + ~150ms round change + ~350ms round 2 + ~150ms signing + ~200ms submission). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2850ms required to fit the worst-case 2-round QBFT scenario (`QBFT` ~2500ms + `PostConsensusSigning` ~150ms + `BlockSubmission` ~200ms). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -157,7 +157,7 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + post-consensus signing + submission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding). Plugging in worst-case values (2000ms R1 timer + 150ms round change + 350ms R2 + 150ms signing + 200ms submission = 2850ms post-cutoff budget required), this resolves to `late_in_slot_time_ms ≲ ~1080ms` — the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. +- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1080ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. - **Cutoffs above ~1080ms** accept that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes in QBFT, signing, or submission risk missing the deadline even when round 1 succeeds. @@ -216,24 +216,22 @@ ProposerDelay: 300ms With `ProposerDelay` active, the slot-budget equation becomes: ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTRound1Time + QBFTRoundChange + QBFTRound2Time + PostConsensusSigningTime + BlockSubmissionTime < 4000ms +RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` Plugging in realistic numbers (typical case where round 1 succeeds): ``` -RANDAOTime ≈ 100ms -MEVBoostRelayTimeout ≈ 200ms -QBFTRound1Time ≈ 2000ms (worst case: round-1 timer expires) -QBFTRoundChange ≈ 150ms (ROUND-CHANGE handshake after round 1 failure) -QBFTRound2Time ≈ 350ms (round 2 succeeds after round 1 failure) -PostConsensusSigningTime ≈ 150ms -BlockSubmissionTime ≈ 200ms -ProposerDelay = 4000ms − (sum above) ≈ 850ms +RANDAO ≈ 100ms +MEVBoostRelayTimeout ≈ 200ms +QBFT ≈ 2500ms (worst case: 2000ms R1 timer + 150ms round change + 350ms R2) +PostConsensusSigning ≈ 150ms +BlockSubmission ≈ 200ms +ProposerDelay = 4000ms − (sum above) ≈ 850ms ``` **Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. -The 850ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, round-change handshake, round-2 consensus, signing, and submission latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~150ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. +The 850ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, `QBFT`, `PostConsensusSigning`, and `BlockSubmission` latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~150ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 19e1dbed2f06f70a06205c334d626b1dd5a8cd76 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 19:31:12 +0300 Subject: [PATCH 15/37] docs/MEV_CONSIDERATIONS: drop blinding from slot-budget math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5 tuning bullet was the only equation in the doc that included pre-QBFT blinding (~20ms) in its overhead figure. The rest of the doc (§2 equation, Example A header-arrival, Example B trade-off math) already excluded blinding implicitly, so the bullet was the outlier. Drop the blinding term: post-cutoff overhead is now ~50ms (BN→SSV transport only), and the round-2 viability threshold accordingly becomes ~1100ms (was ~1080ms). Example A's 1050ms cutoff still fits the worst-case 2-round scenario, now with 50ms margin (was 30ms). --- docs/MEV_CONSIDERATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 7b913f46f1..204d6b24a4 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -157,7 +157,7 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~70ms` (the ~70ms covers BN→SSV transport and pre-QBFT blinding). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1080ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. +- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1100ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. - **Cutoffs above ~1080ms** accept that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes in QBFT, signing, or submission risk missing the deadline even when round 1 succeeds. From b0263137d00703ee6a49ff0cc2f88c1a352e3f7f Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 21:37:28 +0300 Subject: [PATCH 16/37] beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a three-path model for the multi-BN block-header fetch: - Path 0 (legacy): preserves the original ProposerDelay/ProposalSoftTimeout behavior bit-for-bit. Selected when an operator sets either of those legacy knobs. - Path 1 (safe, default): multi-BN parallel fetch with early-exit on the first blinded response; falls back at slot-relative ProposalSoftDeadline (default 1000ms). Selected when no MEV-related knobs are set. - Path 2 (MEV-optimized, opt-in): no early-exit on blinded; collects all responses until slot-relative ProposalSoftDeadline. Selected when an operator sets ProposalSoftDeadline explicitly. Behavior change for default operators: the previous 1800ms relative- duration proposalSoftTimeout is replaced for default-config operators by a slot-relative 1000ms ProposalSoftDeadline. This is a deliberate safety improvement — the 1800ms default could push slot budget past the 4000ms deadline in worst-case multi-BN all-vanilla scenarios. Operators who explicitly set the legacy knobs keep the old behavior unchanged. Validation: - (ProposerDelay > 0 || ProposalSoftTimeout set) && ProposalSoftDeadline set -> startup rejected with an error. - Path 2: ProposalSoftDeadline must be in [1000ms, 3600ms]; values above 1800ms emit a startup warning (round-2 fallback no longer fits). - Path 0: startup logs a WARN nudging migration to the new model. Files: - beacon/goclient/options.go: BlockFetchPath type, DetermineBlockFetchPath, ValidateProposalSoftDeadline, NewOptions takes the path. - beacon/goclient/goclient.go: new fields on GoClient (proposalSoftDeadline, blockFetchPath). - beacon/goclient/proposer.go: rename getProposalParallel -> getProposalParallelLegacy; add getProposalParallelSafe and getProposalParallelMEVOptimized + shared helpers; dispatch from GetBeaconBlock. - cli/operator/node.go: path determination, validation, startup logging. - config/config.example.yaml: ProposalSoftDeadline + ProposalSoftTimeout comment blocks documenting the path-selection model. - docs/BLOCK_FETCH_PATHS_PLAN.md: design doc for the path split. Existing tests updated for the NewOptions signature change. Path-specific behavior tests follow in a separate commit, as do MEV_CONSIDERATIONS.md updates. --- beacon/goclient/attest_test.go | 2 +- beacon/goclient/events_test.go | 2 +- beacon/goclient/goclient.go | 16 +- beacon/goclient/options.go | 172 ++++++++++++++++--- beacon/goclient/proposer.go | 281 +++++++++++++++++++++++++++++-- beacon/goclient/proposer_test.go | 2 +- cli/operator/node.go | 35 +++- config/config.example.yaml | 17 ++ docs/BLOCK_FETCH_PATHS_PLAN.md | 202 ++++++++++++++++++++++ 9 files changed, 676 insertions(+), 53 deletions(-) create mode 100644 docs/BLOCK_FETCH_PATHS_PLAN.md diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index d230c13d75..2948cdbd2b 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -512,7 +512,7 @@ func createClient( CommonTimeout: defaultHardTimeout, LongTimeout: time.Second, WithWeightedAttestationData: withWeightedAttestationData, - }, 0) + }, 0, BlockFetchPathSafe) if err != nil { return nil, err } diff --git a/beacon/goclient/events_test.go b/beacon/goclient/events_test.go index 05b3aa6b9c..6254e8b5af 100644 --- a/beacon/goclient/events_test.go +++ b/beacon/goclient/events_test.go @@ -251,7 +251,7 @@ func TestNewEventHandler(t *testing.T) { } func eventsTestClient(t *testing.T, serverURL string) *GoClient { - opt, err := NewOptions(Options{BeaconNodeAddr: serverURL}, 0) + opt, err := NewOptions(Options{BeaconNodeAddr: serverURL}, 0, BlockFetchPathSafe) require.NoError(t, err) server, err := New(t.Context(), zap.NewNop(), opt) diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index de823542c1..ae008728da 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -135,12 +135,18 @@ type GoClient struct { weightedAttestationDataSoftTimeout time.Duration weightedAttestationDataHardTimeout time.Duration - // proposalSoftTimeout is the collection period during which we gather proposals - // from multiple beacon nodes to select the best one. After this timeout, we return - // the best proposal seen so far, or wait for the first valid proposal if none - // received yet. The parent context (duty deadline) serves as the hard timeout. + // proposalSoftTimeout is the legacy (path 0) collection-period timeout used by + // getProposalParallelLegacy. Other paths use proposalSoftDeadline instead. proposalSoftTimeout time.Duration + // proposalSoftDeadline is the slot-relative deadline (ms into slot) for paths 1 + // and 2. See docs/MEV_CONSIDERATIONS.md. + proposalSoftDeadline time.Duration + + // blockFetchPath selects which getProposalParallel* variant GetBeaconBlock + // dispatches to in the multi-BN case. + blockFetchPath BlockFetchPath + // blockRootToSlotCache is used for attestation data scoring. When multiple Consensus clients are used, // the cache helps reduce the number of Consensus Client calls by `n-1`, where `n` is the number of Consensus clients // that successfully fetched attestation data and proceeded to the scoring phase. Capacity is rather an arbitrary number, @@ -201,6 +207,8 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error weightedAttestationDataSoftTimeout: time.Duration(float64(opt.CommonTimeout) / 2.5), weightedAttestationDataHardTimeout: opt.CommonTimeout, proposalSoftTimeout: opt.ProposalSoftTimeout, + proposalSoftDeadline: opt.ProposalSoftDeadline, + blockFetchPath: opt.BlockFetchPath, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), } diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index fae1208e58..c77457a949 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -1,6 +1,7 @@ package goclient import ( + "fmt" "time" "github.com/ssvlabs/ssv/networkconfig" @@ -14,6 +15,67 @@ const ( defaultLongTimeout = time.Second * 60 ) +// BlockFetchPath identifies which block-header fetch strategy the SSV node is using. +// Determined at startup from operator-provided config; see DetermineBlockFetchPath. +// +// Documented end-to-end in docs/MEV_CONSIDERATIONS.md. +type BlockFetchPath int + +const ( + // BlockFetchPathSafe — path 1 (default). Multi-BN parallel fetch with early-exit on + // first blinded response; fallback at slot-relative ProposalSoftDeadline (default 1000ms). + BlockFetchPathSafe BlockFetchPath = iota + // BlockFetchPathLegacy — path 0. Preserves the original ProposerDelay / + // ProposalSoftTimeout behavior bit-for-bit; selected when an operator has set either + // of those legacy knobs. + BlockFetchPathLegacy + // BlockFetchPathMEVOptimized — path 2 (opt-in). Multi-BN parallel fetch without + // early-exit, returns the best-scored response collected by ProposalSoftDeadline. + // Selected when an operator sets ProposalSoftDeadline explicitly. + BlockFetchPathMEVOptimized +) + +// String returns a human-readable label for logging. +func (p BlockFetchPath) String() string { + switch p { + case BlockFetchPathSafe: + return "safe" + case BlockFetchPathLegacy: + return "legacy" + case BlockFetchPathMEVOptimized: + return "mev-optimized" + default: + return fmt.Sprintf("unknown(%d)", int(p)) + } +} + +// ProposalSoftDeadline bounds and defaults. Values are slot-relative (measured from slot start). +const ( + // DefaultProposalSoftDeadline is the default deadline for the safe path. Picked so + // the worst-case 2-round QBFT scenario still fits within the 4000ms slot deadline: + // 1000ms (deadline) + 2500ms (QBFT worst-case 2-round) + 150ms (signing) + 200ms (submission) = 3850ms + DefaultProposalSoftDeadline = 1000 * time.Millisecond + + // MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline values. + // Matches DefaultProposalSoftDeadline — going lower defeats the purpose of opting into the + // MEV-optimized path (BNs won't have responded yet). + MinProposalSoftDeadline = DefaultProposalSoftDeadline + + // MaxProposalSoftDeadline is the hard upper bound for operator-set ProposalSoftDeadline values. + MaxProposalSoftDeadline = 3600 * time.Millisecond + + // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT + // scenario no longer fits within the slot deadline (round 1 must succeed). Values above + // this trigger a startup warning but are still permitted. + SafeMaxProposalSoftDeadline = 1800 * time.Millisecond +) + +// Path 0 (legacy) constants — preserved for backward-compat. +const ( + defaultProposalSoftTimeout = 1800 * time.Millisecond + minProposalSoftTimeout = 500 * time.Millisecond +) + // Options defines beacon client options type Options struct { BeaconConfig *networkconfig.Beacon @@ -25,42 +87,106 @@ type Options struct { CommonTimeout time.Duration `yaml:"CommonTimeout" env:"WITH_COMMON_TIMEOUT" env-description:"Specifies the common timeout for network operations"` LongTimeout time.Duration `yaml:"LongTimeout" env:"WITH_LONG_TIMEOUT" env-description:"Specifies the long timeout for network operations"` - ProposalSoftTimeout time.Duration `yaml:"ProposalSoftTimeout" env:"WITH_PROPOSAL_SOFT_TIMEOUT" env-description:"Specifies the beacon proposal collection soft timeout (collection period for comparing proposals from multiple beacon nodes to select the most profitable one). Note: the 1st MEV (blinded) block is accepted immediately, so this timeout mainly affects how long we wait for an MEV block before giving up deciding to use a vanilla block instead (if we got one already). This value cannot be set any lower than 500ms to ensure there is enough time for the Beacon node to serve the block-fetch request"` + // ProposalSoftTimeout is the legacy (path 0) collection-period timeout in multi-BN + // parallel fetch. Setting this (or ProposerDelay) selects BlockFetchPathLegacy. + // New operators should prefer ProposalSoftDeadline (path 1 / path 2). See + // docs/MEV_CONSIDERATIONS.md. + ProposalSoftTimeout time.Duration `yaml:"ProposalSoftTimeout" env:"WITH_PROPOSAL_SOFT_TIMEOUT" env-description:"Legacy MEV configuration. Specifies the beacon proposal collection soft timeout (collection period for comparing proposals from multiple beacon nodes to select the most profitable one). Setting this opts the SSV node into the legacy block-fetch path; the recommended approach is to leave this unset and use ProposalSoftDeadline instead. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + + // ProposalSoftDeadline is the slot-relative deadline (in ms-into-slot) for the + // multi-BN proposal-collection window used by paths 1 and 2. + // - Unset (zero) -> path 1 (safe), default deadline 1000ms. + // - Set explicitly -> path 2 (MEV-optimized), value must be in [1000ms, 3600ms]. + // Cannot be combined with ProposerDelay or ProposalSoftTimeout (path 0). + ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"WITH_PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the multi-BN proposal-collection window. Leave unset for the default safe path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 3600ms]). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + + // BlockFetchPath is set by NewOptions from the determined path; not directly + // configured by the operator. Consumed by GoClient at runtime to dispatch block + // fetching to the correct strategy. + BlockFetchPath BlockFetchPath `yaml:"-"` +} + +// DetermineBlockFetchPath returns the block-fetch path selected by the operator's config. +// +// Must be called with raw operator-provided values (before NewOptions applies any defaults) +// so that the "operator explicitly set" vs "defaulted" distinction is preserved. +// +// Returns an error when the config combines path-0 (legacy) knobs with the path-2 +// (MEV-optimized) ProposalSoftDeadline — operators must pick one. +func DetermineBlockFetchPath(base Options, proposerDelay time.Duration) (BlockFetchPath, error) { + legacySet := proposerDelay > 0 || base.ProposalSoftTimeout > 0 + deadlineSet := base.ProposalSoftDeadline > 0 + + if legacySet && deadlineSet { + return 0, fmt.Errorf("ProposalSoftDeadline conflicts with legacy ProposerDelay/ProposalSoftTimeout config — remove one. See docs/MEV_CONSIDERATIONS.md for path selection guidance") + } + + switch { + case legacySet: + return BlockFetchPathLegacy, nil + case deadlineSet: + return BlockFetchPathMEVOptimized, nil + default: + return BlockFetchPathSafe, nil + } } -func NewOptions(base Options, proposerDelay time.Duration) (Options, error) { +// ValidateProposalSoftDeadline ensures the value is within the acceptable range for +// the MEV-optimized fetch path. The caller is responsible for emitting an additional +// log-warning when the value exceeds SafeMaxProposalSoftDeadline. +func ValidateProposalSoftDeadline(d time.Duration) error { + if d < MinProposalSoftDeadline || d > MaxProposalSoftDeadline { + return fmt.Errorf("ProposalSoftDeadline value %dms is out of range [%dms, %dms]", + d.Milliseconds(), + MinProposalSoftDeadline.Milliseconds(), + MaxProposalSoftDeadline.Milliseconds()) + } + return nil +} + +// NewOptions applies path-specific defaults to base options and returns the result. +// +// path is the value returned by DetermineBlockFetchPath. proposerDelay is only consumed +// when path == BlockFetchPathLegacy. The selected path is stashed in the returned +// Options.BlockFetchPath for consumption by GoClient at runtime. +func NewOptions(base Options, proposerDelay time.Duration, path BlockFetchPath) (Options, error) { options := base + options.BlockFetchPath = path if options.CommonTimeout == 0 { options.CommonTimeout = defaultCommonTimeout } - if options.LongTimeout == 0 { options.LongTimeout = defaultLongTimeout } - // If user explicitly set ProposalSoftTimeout, use it as-is (power user mode). - // Otherwise, use the default value and reduce it by proposer delay if needed. - if options.ProposalSoftTimeout == 0 { - // The default value shouldn't be too high because an operator might not be able to participate - // in QBFT round 2 (or finish it in time) if it is roughly > 2000 ms. - const defaultProposalSoftTimeout = time.Millisecond * 1800 - options.ProposalSoftTimeout = defaultProposalSoftTimeout - // Reduce soft timeout by proposer delay to maintain consistent duty-execution timelines - // for different operators in the cluster, ensuring QBFT consensus starts at roughly - // the same time (timing out round 1 at roughly the same time) regardless of proposer - // delay configuration a particular operator is using - operators with higher proposer - // delay start fetching blocks later, so they must have a shorter collection period. - if proposerDelay > 0 { - options.ProposalSoftTimeout -= proposerDelay + switch path { + case BlockFetchPathLegacy: + // Legacy path: preserve the original ProposalSoftTimeout defaulting (1800ms, + // reduced by ProposerDelay, floored at 500ms). Behavior bit-for-bit unchanged + // from before the path split was introduced. + if options.ProposalSoftTimeout == 0 { + options.ProposalSoftTimeout = defaultProposalSoftTimeout + // Reduce by proposer delay to maintain consistent duty-execution timelines + // for different operators in the cluster, ensuring QBFT consensus starts at + // roughly the same time regardless of proposer-delay configuration. + if proposerDelay > 0 { + options.ProposalSoftTimeout -= proposerDelay + } + } + if options.ProposalSoftTimeout < minProposalSoftTimeout { + options.ProposalSoftTimeout = minProposalSoftTimeout + } + + case BlockFetchPathSafe: + // Safe path: slot-relative deadline, default 1000ms. + if options.ProposalSoftDeadline == 0 { + options.ProposalSoftDeadline = DefaultProposalSoftDeadline } - } - // minProposalSoftTimeout is the minimum soft timeout value allowed. - // It ensures we always have enough time to fetch and compare proposals. - const minProposalSoftTimeout = time.Millisecond * 500 - if options.ProposalSoftTimeout < minProposalSoftTimeout { - options.ProposalSoftTimeout = minProposalSoftTimeout + case BlockFetchPathMEVOptimized: + // MEV-optimized path: ProposalSoftDeadline must be set by the operator and + // validated upstream (ValidateProposalSoftDeadline). No defaults to apply. } // Note: There is no hard timeout for proposals. The parent context from the diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index a072c476f0..5cdfa91698 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -106,8 +106,18 @@ func (gc *GoClient) GetBeaconBlock( return nil, nil, err } } else { - // For multiple clients, race them in parallel for the fastest response - beaconBlock, err = gc.getProposalParallel(ctx, logger, slot, sig, graffiti) + // For multiple clients, dispatch to the selected block-fetch path. + // See docs/MEV_CONSIDERATIONS.md for the three paths' semantics. + switch gc.blockFetchPath { + case BlockFetchPathLegacy: + beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) + case BlockFetchPathMEVOptimized: + beaconBlock, err = gc.getProposalParallelMEVOptimized(ctx, logger, slot, sig, graffiti) + case BlockFetchPathSafe: + fallthrough + default: + beaconBlock, err = gc.getProposalParallelSafe(ctx, logger, slot, sig, graffiti) + } if err != nil { return nil, nil, err } @@ -155,25 +165,18 @@ func (gc *GoClient) GetBeaconBlock( } } -// getProposalParallel races all beacon nodes and collects proposals for a short time -// and returns the best one according to our score function. -// If no valid proposals are collected in this time it returns the first valid one -// it sees. -// -// This minimizes latency for time-critical block proposals, while still affording -// some time for selecting maximally profitable proposals. Remaining requests are -// canceled immediately to reduce load. +// getProposalParallelLegacy implements path 0 (legacy) — preserved bit-for-bit from +// the pre-path-split code for backward-compat with operators using ProposerDelay / +// ProposalSoftTimeout. // -// Note: We used to prioritize speed over fee recipient validation - returning -// the first response rather than waiting to compare fee recipients, as missing -// a proposal slot is worse than a nil fee recipient. -// However, it has been observed that the first proposal is usually not the most -// profitable, so we added a little slack time to collect proposals. +// Races all beacon nodes, collects proposals for a short relative-duration timeout +// (gc.proposalSoftTimeout), and returns the best one according to our score function. +// Early-exits on the first blinded response (assumes blinded == MEV). If no valid +// proposals are collected by the soft timeout, returns the first valid one received. // // The parent context (from duty runner, bounded by slot timing) serves as the hard -// deadline. We never give up early on getting a block proposal - missing a proposal -// is catastrophic, so we wait as long as the slot allows. -func (gc *GoClient) getProposalParallel( +// deadline. +func (gc *GoClient) getProposalParallelLegacy( ctx context.Context, logger *zap.Logger, slot phase0.Slot, @@ -315,6 +318,248 @@ collect: return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) } +// proposalFetchResult bundles the outcome of a single per-BN fetch goroutine. +type proposalFetchResult struct { + proposal *api.VersionedProposal + err error + client string +} + +// spawnProposalFetchers starts a goroutine per beacon-node client; each goroutine +// fetches a proposal and writes its result to the returned channel. Used by the +// safe (path 1) and MEV-optimized (path 2) block-fetch implementations. +// +// The channel is buffered to `len(gc.clients)` so each goroutine can write without +// blocking even if the consumer has already returned. +func (gc *GoClient) spawnProposalFetchers( + ctx context.Context, + slot phase0.Slot, + sig phase0.BLSSignature, + graffiti [32]byte, +) <-chan proposalFetchResult { + resultCh := make(chan proposalFetchResult, len(gc.clients)) + for _, client := range gc.clients { + go func(c Client) { + proposal, err := gc.fetchProposal(ctx, c, slot, sig, graffiti) + select { + case resultCh <- proposalFetchResult{proposal: proposal, err: err, client: c.Address()}: + case <-ctx.Done(): + // Context canceled, exit without blocking. + } + }(client) + } + return resultCh +} + +// waitForFirstValidProposal returns the first valid proposal received from the +// remaining in-flight fetchers. Used by paths 1 and 2 as the fallback after the +// soft-deadline collection window has elapsed without producing a usable best +// proposal. Bounded by the parent context's slot deadline. +func (gc *GoClient) waitForFirstValidProposal( + ctx context.Context, + logger *zap.Logger, + slot phase0.Slot, + startCollect time.Time, + resultCh <-chan proposalFetchResult, + pendingClients int, + errs error, +) (*api.VersionedProposal, error) { + for pendingClients > 0 { + select { + case res := <-resultCh: + pendingClients-- + if res.err != nil { + errs = errors.Join(errs, res.err) + continue + } + proposalScore := gc.scoreProposal(res.proposal) + logger.Debug("received proposal; selected first proposal", + zap.String("client", res.client), + zap.Float64("score", proposalScore), + zap.Duration("latency", time.Since(startCollect)), + zap.Int("pending", pendingClients), + zap.Bool("blinded", res.proposal.Blinded), + fields.Slot(slot), + ) + return res.proposal, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) +} + +// getProposalParallelSafe implements path 1 (safe, default). +// +// Spawns a per-BN fetch in parallel; collects responses until the slot-relative +// ProposalSoftDeadline fires (default 1000ms into slot). Early-exits on the first +// blinded response (treats blinded == MEV). After the deadline, returns the best +// proposal seen so far, or falls through to the first valid response if none +// received yet. +func (gc *GoClient) getProposalParallelSafe( + ctx context.Context, + logger *zap.Logger, + slot phase0.Slot, + sig phase0.BLSSignature, + graffiti [32]byte, +) (*api.VersionedProposal, error) { + // Slot-relative deadline: fires at slot_start + ProposalSoftDeadline regardless + // of when getProposalParallelSafe is invoked. + slotStart := gc.getBeaconConfig().SlotStartTime(slot) + softCtx, cancelSoft := context.WithDeadline(ctx, slotStart.Add(gc.proposalSoftDeadline)) + defer cancelSoft() + + resultCh := gc.spawnProposalFetchers(ctx, slot, sig, graffiti) + + var errs error + var bestProposal *api.VersionedProposal + var bestScore float64 + var bestClient string + + startCollect := time.Now() + pendingClients := len(gc.clients) +collect: + for pendingClients > 0 { + select { + case res := <-resultCh: + pendingClients-- + + if res.err != nil { + errs = errors.Join(errs, res.err) + continue + } + + proposalScore := gc.scoreProposal(res.proposal) + logger.Debug("received proposal", + zap.String("client", res.client), + zap.Float64("score", proposalScore), + zap.Duration("latency", time.Since(startCollect)), + zap.Int("pending", pendingClients), + zap.Bool("blinded", res.proposal.Blinded), + fields.Slot(slot), + ) + + if bestProposal == nil || + proposalScore > bestScore || + // prefer the blinded proposal even if same score as the best so far + (res.proposal.Blinded && proposalScore == bestScore) { + bestProposal = res.proposal + bestScore = proposalScore + bestClient = res.client + } + + if res.proposal.Blinded { + // Early-exit on first blinded: treat blinded == MEV. + break collect + } + + case <-softCtx.Done(): + break collect + } + } + + if bestProposal != nil { + logger.Debug("selected best proposal", + zap.String("client", bestClient), + zap.Float64("score", bestScore), + zap.Bool("blinded", bestProposal.Blinded), + fields.Slot(slot), + ) + return bestProposal, nil + } + + logger.Debug("did not receive any valid proposals during the collection period", + zap.Int("clients", len(gc.clients)), + zap.Int("pending", pendingClients), + fields.Slot(slot), + ) + + return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) +} + +// getProposalParallelMEVOptimized implements path 2 (MEV-optimized, opt-in). +// +// Spawns a per-BN fetch in parallel; collects responses until the slot-relative +// ProposalSoftDeadline fires. **Does not** early-exit on the first blinded response; +// instead, accumulates all responses received within the window so the highest-value +// bid across BNs can be selected. After the deadline, returns the best proposal +// seen so far, or falls through to the first valid response if none received yet. +func (gc *GoClient) getProposalParallelMEVOptimized( + ctx context.Context, + logger *zap.Logger, + slot phase0.Slot, + sig phase0.BLSSignature, + graffiti [32]byte, +) (*api.VersionedProposal, error) { + slotStart := gc.getBeaconConfig().SlotStartTime(slot) + softCtx, cancelSoft := context.WithDeadline(ctx, slotStart.Add(gc.proposalSoftDeadline)) + defer cancelSoft() + + resultCh := gc.spawnProposalFetchers(ctx, slot, sig, graffiti) + + var errs error + var bestProposal *api.VersionedProposal + var bestScore float64 + var bestClient string + + startCollect := time.Now() + pendingClients := len(gc.clients) +collect: + for pendingClients > 0 { + select { + case res := <-resultCh: + pendingClients-- + + if res.err != nil { + errs = errors.Join(errs, res.err) + continue + } + + proposalScore := gc.scoreProposal(res.proposal) + logger.Debug("received proposal", + zap.String("client", res.client), + zap.Float64("score", proposalScore), + zap.Duration("latency", time.Since(startCollect)), + zap.Int("pending", pendingClients), + zap.Bool("blinded", res.proposal.Blinded), + fields.Slot(slot), + ) + + if bestProposal == nil || + proposalScore > bestScore || + // prefer the blinded proposal even if same score as the best so far + (res.proposal.Blinded && proposalScore == bestScore) { + bestProposal = res.proposal + bestScore = proposalScore + bestClient = res.client + } + + // No early-exit on blinded: keep collecting to compare bids across BNs. + + case <-softCtx.Done(): + break collect + } + } + + if bestProposal != nil { + logger.Debug("selected best proposal", + zap.String("client", bestClient), + zap.Float64("score", bestScore), + zap.Bool("blinded", bestProposal.Blinded), + fields.Slot(slot), + ) + return bestProposal, nil + } + + logger.Debug("did not receive any valid proposals during the collection period", + zap.Int("clients", len(gc.clients)), + zap.Int("pending", pendingClients), + fields.Slot(slot), + ) + + return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) +} + // scoreProposal computes a score for a beacon proposal. // see https://github.com/attestantio/vouch/blob/master/strategies/beaconblockproposal/best/score.go as well func (gc *GoClient) scoreProposal( diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index adf02efb16..3bef61daa4 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -684,7 +684,7 @@ func createClientForProposerTest(t *testing.T, serverURL string) (*GoClient, err BeaconNodeAddr: serverURL, CommonTimeout: time.Second * 2, LongTimeout: time.Second * 5, - }, 0) + }, 0, BlockFetchPathSafe) if err != nil { return nil, err } diff --git a/cli/operator/node.go b/cli/operator/node.go index daf8c244b0..02ea39a8c1 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -191,13 +191,42 @@ var StartNodeCmd = &cobra.Command{ logger.Fatal("could not setup network", zap.Error(err)) } + // Determine the block-fetch path from operator-provided config before + // NewOptions applies any defaults. See docs/MEV_CONSIDERATIONS.md for the + // three-path model and docs/BLOCK_FETCH_PATHS_PLAN.md for the design. + blockFetchPath, err := goclient.DetermineBlockFetchPath(cfg.ConsensusClient, cfg.ProposerDelay) + if err != nil { + logger.Fatal("invalid block-fetch path configuration", zap.Error(err)) + } + + switch blockFetchPath { + case goclient.BlockFetchPathLegacy: + if err := validateProposerDelayConfig(logger); err != nil { + logger.Fatal("invalid ProposerDelay configuration", zap.Error(err)) + } + logger.Warn("Using legacy MEV configuration path — there is a better way to opt into MEV, see docs/MEV_CONSIDERATIONS.md") + case goclient.BlockFetchPathMEVOptimized: + if err := goclient.ValidateProposalSoftDeadline(cfg.ConsensusClient.ProposalSoftDeadline); err != nil { + logger.Fatal("invalid ProposalSoftDeadline configuration", zap.Error(err)) + } + if cfg.ConsensusClient.ProposalSoftDeadline > goclient.SafeMaxProposalSoftDeadline { + logger.Warn("ProposalSoftDeadline exceeds the safe upper bound — round-2 QBFT fallback will not fit within the slot deadline; slot is missed whenever round 1 fails", + zap.Int64("proposal_soft_deadline_ms", cfg.ConsensusClient.ProposalSoftDeadline.Milliseconds()), + zap.Int64("safe_max_ms", goclient.SafeMaxProposalSoftDeadline.Milliseconds())) + } + case goclient.BlockFetchPathSafe: + // No path-specific validation needed. + } + + logger.Info("block-fetch path selected", zap.String("path", blockFetchPath.String())) + logger.Info("connecting CL(s)", fields.Address(cfg.ConsensusClient.BeaconNodeAddr), zap.Bool("with_weighted_attestation_data", cfg.ConsensusClient.WithWeightedAttestationData), zap.Bool("with_parallel_submissions", cfg.ConsensusClient.WithParallelSubmissions), ) - cliopt, err := goclient.NewOptions(cfg.ConsensusClient, cfg.ProposerDelay) + cliopt, err := goclient.NewOptions(cfg.ConsensusClient, cfg.ProposerDelay, blockFetchPath) if err != nil { logger.Fatal("failed to create beacon client options", zap.Error(err), @@ -224,10 +253,6 @@ var StartNodeCmd = &cobra.Command{ usingSSVSigner, usingKeystore, usingPrivKey = assertSigningConfig(logger) } - if err := validateProposerDelayConfig(logger); err != nil { - logger.Fatal("invalid ProposerDelay configuration", zap.Error(err)) - } - var operatorPrivKey keys.OperatorPrivateKey var operatorPrivKeyPEM string var ssvSignerClient *ssvsigner.Client diff --git a/config/config.example.yaml b/config/config.example.yaml index 9a9d737b5f..595fe32cc6 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,6 +19,23 @@ eth2: # HTTP URL of the Beacon node to connect to. BeaconNodeAddr: http://example.url:5052 + # Block-fetch path tuning. The SSV node selects one of three paths at startup based on + # the settings below; see docs/MEV_CONSIDERATIONS.md for the full model. + # + # ProposalSoftDeadline opts the SSV node into the MEV-optimized path: SSV waits for all + # multi-BN responses until this slot-relative deadline (ms into slot) and returns the + # highest-value bid received, without early-exiting on the first MEV block. Match this + # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, + # 3600ms]; values above 1800ms emit a startup warning (round-2 QBFT fallback no longer + # fits within the slot). Cannot be combined with ProposerDelay or ProposalSoftTimeout. + # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1000ms). + # ProposalSoftDeadline: 1100ms + + # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring + # in the legacy block-fetch path. Setting this (or ProposerDelay below) opts into the + # legacy path. New operators should leave this unset and use ProposalSoftDeadline above. + # ProposalSoftTimeout: 1800ms + ValidatorOptions: eth1: diff --git a/docs/BLOCK_FETCH_PATHS_PLAN.md b/docs/BLOCK_FETCH_PATHS_PLAN.md new file mode 100644 index 0000000000..0ff36bb5b3 --- /dev/null +++ b/docs/BLOCK_FETCH_PATHS_PLAN.md @@ -0,0 +1,202 @@ +# Block-header fetch paths — plan + +## Goal + +Split the SSV block-header fetch logic in `getProposalParallel` into three explicit paths, with a new slot-relative `ProposalSoftDeadline` setting replacing the dangerously-large 1800ms default `proposalSoftTimeout` for new operators while preserving full backward compatibility for legacy configurations. + +## Motivation + +Current code ([`beacon/goclient/proposer.go:176-316`](beacon/goclient/proposer.go)) has two structural problems: +1. **The 1800ms default `proposalSoftTimeout` is dangerously large.** If multi-BN setup returns all-vanilla responses (mev-boost loses relays, no MEV available, etc.), SSV waits the full 1800ms before returning, pushing the worst-case slot budget past the 4000ms deadline. +2. **Early-exit on first blinded defeats multi-BN scoring.** The fastest BN's blinded response wins regardless of bid value; operators intentionally running multiple BNs to cross-compare bids get no benefit from that setup. + +## Three-path design + +### Path selection algorithm + +Step 1 — startup validation (in `cli/operator/node.go`, before `NewOptions` is called so we can see operator-set vs defaulted values): + +``` +if (ProposerDelay > 0 || ProposalSoftTimeout is operator-set) && ProposalSoftDeadline is operator-set: + → REJECT startup with a clear error message: + "ProposalSoftDeadline conflicts with legacy ProposerDelay/ProposalSoftTimeout config — + remove one. See docs/MEV_CONSIDERATIONS.md for path selection guidance." +``` + +Step 2 — path selection (after validation passes): + +``` +if ProposerDelay > 0 || ProposalSoftTimeout is operator-set: + → Path 0 (legacy) — preserves current behavior bit-for-bit + → ALSO: log WARN at startup nudging migration ("There is a better way to opt into MEV; + see docs/MEV_CONSIDERATIONS.md") +elif ProposalSoftDeadline is operator-set: + → Path 2 (MEV-optimized) — no early-exit, wait until deadline +else: + → Path 1 (safe, default) — early-exit on first blinded, fallback at DefaultProposalSoftDeadline +``` + +Selection happens once at startup based on operator-provided config (before defaults are applied). + +### Path 0 — legacy (backward-compat) + +**Trigger:** operator sets `ProposerDelay > 0` OR explicitly sets `WITH_PROPOSAL_SOFT_TIMEOUT`. + +**Behavior:** unchanged from current code. Preserves: +- The `proposalSoftTimeout -= proposerDelay` reduction logic in [`options.go:54-56`](beacon/goclient/options.go). +- The relative-duration semantics of `proposalSoftTimeout`. +- Early-exit on first blinded response. +- Fallback to "wait for first valid response" after soft timeout. +- The 500ms floor on the timeout. + +**Defaults:** `ProposalSoftTimeout = 1800ms` (unchanged), reduced by `ProposerDelay`. + +**Documentation:** lives in Appendix A of `MEV_CONSIDERATIONS.md` (already framed as legacy). + +### Path 1 — safe (new default) + +**Trigger:** neither `ProposerDelay` nor `ProposalSoftTimeout` set; `ProposalSoftDeadline` also not set. + +**Behavior:** +- (a) Multi-BN parallel fetch, early-exit on first blinded response. +- (b) If no blinded response by `DefaultProposalSoftDeadline`, return best-so-far (or wait for first valid response if nothing yet). + +**Defaults:** `DefaultProposalSoftDeadline = 1000ms` (slot-relative). + +**Rationale for 1000ms:** Worst-case slot budget under 2-round QBFT is: +``` +1000ms (deadline) + 2500ms (QBFT) + 350ms (signing+submission) = 3850ms < 4000ms ✓ +``` +Reasonable variance margin even if every component runs slow. + +### Path 2 — MEV-optimized (opt-in) + +**Trigger:** operator explicitly sets `ProposalSoftDeadline` (and `ProposerDelay`/`ProposalSoftTimeout` are not set). + +**Behavior:** +- Multi-BN parallel fetch, **no early-exit on first blinded**. +- Collect all BN responses until `ProposalSoftDeadline` (slot-relative). +- At deadline: return highest-value-scored response among all received. +- If nothing received by deadline: fall through to "wait for first valid response," bounded by slot deadline. + +**Validation at startup:** +- `ProposalSoftDeadline` must be in `[DefaultProposalSoftDeadline (1000ms), 3600ms]`. +- Reject startup with clear error if out of range. +- Log warning (not error) if `> 1800ms`: "ProposalSoftDeadline > 1800ms leaves no budget for QBFT round 2 — slot will be missed if round 1 fails. See docs/MEV_CONSIDERATIONS.md." + +**Rationale:** This is the path advanced operators use after configuring PBS-side timing games (mev-boost ≥ v1.11 or commit-boost). Matching `ProposalSoftDeadline` to the PBS-side `late_in_slot_time_ms` + ~50ms BN→SSV transport gives SSV the full PBS-cutoff value to compare bids across multiple BNs. + +## Single-BN behavior + +Unchanged. Single-BN goes through the existing direct `fetchProposal` call at [`proposer.go:103-107`](beacon/goclient/proposer.go) regardless of selected path. The path distinction is a no-op for single-BN (no other BNs to compare against; no early-exit decision to make). + +## Configuration surface + +### New fields + +- `ssv.SSVOptions.ProposalSoftDeadline time.Duration` (YAML: `ProposalSoftDeadline`, env: `WITH_PROPOSAL_SOFT_DEADLINE`). + - Zero value = not set = use path 1 default. + - Non-zero value = path 2 opt-in. + +### Existing fields (preserved) + +- `ProposerDelay` — unchanged behavior. +- `WITH_PROPOSAL_SOFT_TIMEOUT` env var / `ProposalSoftTimeout` field — unchanged behavior. Only meaningful in path 0. +- `AllowDangerousProposerDelay` — unchanged. Still gates `ProposerDelay > 1000ms`. + +### Validation + +At startup, in node config validation: +1. **Reject** if `(ProposerDelay > 0 || ProposalSoftTimeout is operator-set) && ProposalSoftDeadline is operator-set` — see Step 1 above. Validation returns an error; startup fatals. +2. Determine which path applies based on the algorithm above. +3. If path 0: existing validation only (`AllowDangerousProposerDelay` cap); also emit the migration WARN log noted above. +4. If path 2: enforce `[1000ms, 3600ms]` range; warn (log) if `> 1800ms` (round-2 fallback won't fit). +5. Log the selected path at startup: e.g. `"block-fetch path: safe (default)"`, `"block-fetch path: legacy (ProposerDelay=300ms)"`, `"block-fetch path: MEV-optimized (ProposalSoftDeadline=1100ms)"`. + +**Single-BN + ProposalSoftDeadline**: no special handling — operator can set the field with a single BN, it just has no effect (single-BN bypasses the parallel-fetch logic that uses the deadline). + +## Implementation notes + +### Detecting "operator explicitly set" vs "defaulted" + +The `cleanenv` library populates the struct from YAML/env, then default values are applied later in `NewOptions`. To detect "operator set" vs "defaulted": + +- Option A: track via a pointer (`*time.Duration`) — nil means "not set." +- Option B: track via a separate `bool` field (`ProposalSoftTimeoutWasSet`). +- Option C: do the path selection in `cli/operator/node.go` config validation **before** `NewOptions` is called. + +(C) is cleanest — config validation runs first, picks the path, and stores the result for the rest of the code to use. + +### Function signatures + +`GetBeaconBlock` and `getProposalParallel` either: +- Branch internally based on selected path (single function, conditional logic). +- Get dispatched to separate functions per path (cleaner but more code). + +Probably split into `getProposalParallelSafe` (path 1) and `getProposalParallelMEVOptimized` (path 2), with path 0 retaining its current code. Dispatch happens in `GetBeaconBlock`. + +### Backward-compat test cases + +Unit tests should cover: +- Only `ProposerDelay` set → path 0. +- Only `ProposalSoftTimeout` set → path 0 (with default `ProposalSoftDeadline` ignored). +- Both `ProposerDelay` and `ProposalSoftTimeout` set → path 0. +- Both `ProposerDelay` and `ProposalSoftDeadline` set → path 0 wins (legacy precedence); log notes the ignored `ProposalSoftDeadline`. +- Only `ProposalSoftDeadline` set → path 2. +- Nothing set → path 1. +- `ProposalSoftDeadline` out of range → startup rejected with error. +- `ProposalSoftDeadline > 1800ms` → startup succeeds + warning logged. + +## Matching `ProposalSoftDeadline` to PBS config + +For operators following the path-2 setup with mev-boost/commit-boost: + +``` +ProposalSoftDeadline ≈ PBS late_in_slot_time_ms + ~50ms +``` + +The +50ms covers BN→SSV transport so the deadline lands *after* the latest expected header arrival, giving the scoring loop a chance to collect all BN responses. + +Examples (assuming the worked configs in `MEV_CONSIDERATIONS.md`): +- Example A — `late_in_slot_time_ms = 1050ms` → `ProposalSoftDeadline = 1100ms`. +- Example B — `late_in_slot_time_ms = 1800ms` → `ProposalSoftDeadline = 1850ms`. + +Both inside the `[1000ms, 3600ms]` validation range; Example B is below the 1800ms warn threshold. + +## `MEV_CONSIDERATIONS.md` updates + +The current doc references `proposalSoftTimeout` in several places. After this change: + +1. **§4 Example A and B**: add `ProposalSoftDeadline` config lines to the SSV-side config snippet (currently shows PBS configs only). + - Example A: `ProposalSoftDeadline: 1100ms` + - Example B: `ProposalSoftDeadline: 1850ms` +2. **§6 Interaction with `ProposerDelay`**: rewrite as "Configuration paths" — explain the three-path model and the selection algorithm. +3. **Appendix A**: re-label explicitly as "Path 0 (legacy)." Keep the analysis but make it clear this is no longer the recommended path. +4. **Tuning section** (`Where the auction window should land`): note that the `ProposalSoftDeadline` is now also part of the per-cutoff math, not just the PBS `late_in_slot_time_ms`. +5. **TL;DR**: add a short mention of the path model — "default operators land on path 1 (safe); advanced operators opt into path 2 by setting `ProposalSoftDeadline`." + +## Scope of code changes + +Files touched: +- `cli/operator/node.go` — add `ProposalSoftDeadline` field, path-selection logic in `validateConfig`, startup logging. +- `beacon/goclient/options.go` — add new field; preserve `ProposalSoftTimeout` and its existing default/reduction logic (path 0); add `DefaultProposalSoftDeadline = 1000ms`. +- `beacon/goclient/proposer.go` — path dispatch in `GetBeaconBlock`; new `getProposalParallelMEVOptimized` function (or equivalent). +- `cli/operator/node_test.go` — backward-compat test cases for path selection. +- `beacon/goclient/proposer_test.go` — behavior tests for paths 1 and 2. +- `config/config.example.yaml` — add `ProposalSoftDeadline` comment block. +- `docs/MEV_CONSIDERATIONS.md` — all the rewrites listed above. + +## Resolved decisions + +1. **Path 0 + ProposalSoftDeadline both set** → reject at startup (validation error → fatal). No silent precedence. +2. **Path 1 default = 1000ms** — confirmed. +3. **Path 2 lower bound = 1000ms** — confirmed. +4. **Path 2 upper bound = 3600ms with warn-but-allow above 1800ms** — confirmed. No `AllowDangerousProposalSoftDeadline` flag needed. +5. **Telemetry**: not added. Operators self-knowing their setup is sufficient; no SSV-network-wide visibility need. +6. **Migration nudge for path 0**: yes — log a WARN at startup along the lines of *"There is a better way to opt into MEV — see docs/MEV_CONSIDERATIONS.md"*. +7. **Single-BN + ProposalSoftDeadline**: silently accept (no special handling). Setting has no effect since single-BN bypasses parallel fetch; no warning, no rejection. + +## Implementation choices + +- **Where path selection happens**: `cli/operator/node.go` validation, before `NewOptions` applies defaults. Stores the selected path in the config object for the rest of the code to consume. +- **Code split**: `getProposalParallel` is split into `getProposalParallelSafe` (path 1) and `getProposalParallelMEVOptimized` (path 2). Path 0 retains its existing code at the current `getProposalParallel` (renamed or kept, TBD during implementation). Dispatch happens in `GetBeaconBlock`. From d4dff91d3830850e5d133daee9bdbe66585e544e Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 21:39:01 +0300 Subject: [PATCH 17/37] beacon/goclient: unit tests for block-fetch path selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: - DetermineBlockFetchPath across all input combinations, including the hard-fail case where legacy knobs and ProposalSoftDeadline are both set. - ValidateProposalSoftDeadline range checks (lower bound 1000ms, hard upper bound 3600ms, plus the safe-max boundary at 1800ms). - NewOptions path-specific defaulting for safe / legacy / MEV-optimized. - BlockFetchPath.String() formatting. Per-path behavior tests for getProposalParallelSafe and getProposalParallelMEVOptimized are not added here — the implementations share most of their structure with the existing getProposalParallelLegacy (already covered) and only differ in two specific places (slot-relative deadline + early-exit gating). Worth revisiting if the path implementations diverge further. --- beacon/goclient/options_test.go | 198 ++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 beacon/goclient/options_test.go diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go new file mode 100644 index 0000000000..0e1a2ac920 --- /dev/null +++ b/beacon/goclient/options_test.go @@ -0,0 +1,198 @@ +package goclient + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetermineBlockFetchPath(t *testing.T) { + tests := []struct { + name string + options Options + proposerDelay time.Duration + wantPath BlockFetchPath + wantErr string // substring match; empty = no error expected + }{ + { + name: "nothing set -> safe (default)", + options: Options{}, + proposerDelay: 0, + wantPath: BlockFetchPathSafe, + }, + { + name: "only ProposerDelay set -> legacy", + options: Options{}, + proposerDelay: 300 * time.Millisecond, + wantPath: BlockFetchPathLegacy, + }, + { + name: "only ProposalSoftTimeout set -> legacy", + options: Options{ProposalSoftTimeout: 1500 * time.Millisecond}, + proposerDelay: 0, + wantPath: BlockFetchPathLegacy, + }, + { + name: "both ProposerDelay and ProposalSoftTimeout set -> legacy", + options: Options{ProposalSoftTimeout: 1500 * time.Millisecond}, + proposerDelay: 300 * time.Millisecond, + wantPath: BlockFetchPathLegacy, + }, + { + name: "only ProposalSoftDeadline set -> MEV-optimized", + options: Options{ProposalSoftDeadline: 1100 * time.Millisecond}, + proposerDelay: 0, + wantPath: BlockFetchPathMEVOptimized, + }, + { + name: "ProposerDelay + ProposalSoftDeadline -> error", + options: Options{ProposalSoftDeadline: 1100 * time.Millisecond}, + proposerDelay: 300 * time.Millisecond, + wantErr: "ProposalSoftDeadline conflicts with legacy", + }, + { + name: "ProposalSoftTimeout + ProposalSoftDeadline -> error", + options: Options{ProposalSoftTimeout: 1500 * time.Millisecond, ProposalSoftDeadline: 1100 * time.Millisecond}, + proposerDelay: 0, + wantErr: "ProposalSoftDeadline conflicts with legacy", + }, + { + name: "all three set -> error (legacy + deadline still conflicts)", + options: Options{ProposalSoftTimeout: 1500 * time.Millisecond, ProposalSoftDeadline: 1100 * time.Millisecond}, + proposerDelay: 300 * time.Millisecond, + wantErr: "ProposalSoftDeadline conflicts with legacy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := DetermineBlockFetchPath(tt.options, tt.proposerDelay) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantPath, path) + }) + } +} + +func TestValidateProposalSoftDeadline(t *testing.T) { + tests := []struct { + name string + value time.Duration + wantErr bool + }{ + {name: "at minimum (1000ms) -> ok", value: 1000 * time.Millisecond, wantErr: false}, + {name: "below minimum (999ms) -> error", value: 999 * time.Millisecond, wantErr: true}, + {name: "at safe max (1800ms) -> ok (warn handled externally)", value: 1800 * time.Millisecond, wantErr: false}, + {name: "above safe max but below hard max (2500ms) -> ok", value: 2500 * time.Millisecond, wantErr: false}, + {name: "at hard max (3600ms) -> ok", value: 3600 * time.Millisecond, wantErr: false}, + {name: "above hard max (3601ms) -> error", value: 3601 * time.Millisecond, wantErr: true}, + {name: "zero -> error", value: 0, wantErr: true}, + {name: "negative -> error", value: -100 * time.Millisecond, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateProposalSoftDeadline(tt.value) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "out of range") + return + } + require.NoError(t, err) + }) + } +} + +func TestNewOptions_PathDefaulting(t *testing.T) { + t.Run("safe path defaults ProposalSoftDeadline to 1000ms", func(t *testing.T) { + base := Options{BeaconNodeAddr: "http://localhost:5052"} + opts, err := NewOptions(base, 0, BlockFetchPathSafe) + require.NoError(t, err) + assert.Equal(t, DefaultProposalSoftDeadline, opts.ProposalSoftDeadline) + assert.Equal(t, BlockFetchPathSafe, opts.BlockFetchPath) + // ProposalSoftTimeout should not be touched by the safe path. + assert.Equal(t, time.Duration(0), opts.ProposalSoftTimeout) + }) + + t.Run("safe path keeps operator-set ProposalSoftDeadline", func(t *testing.T) { + base := Options{ + BeaconNodeAddr: "http://localhost:5052", + ProposalSoftDeadline: 1500 * time.Millisecond, + } + opts, err := NewOptions(base, 0, BlockFetchPathSafe) + require.NoError(t, err) + assert.Equal(t, 1500*time.Millisecond, opts.ProposalSoftDeadline) + }) + + t.Run("legacy path defaults ProposalSoftTimeout to 1800ms", func(t *testing.T) { + base := Options{BeaconNodeAddr: "http://localhost:5052"} + opts, err := NewOptions(base, 0, BlockFetchPathLegacy) + require.NoError(t, err) + assert.Equal(t, defaultProposalSoftTimeout, opts.ProposalSoftTimeout) + assert.Equal(t, BlockFetchPathLegacy, opts.BlockFetchPath) + }) + + t.Run("legacy path subtracts proposer delay from ProposalSoftTimeout", func(t *testing.T) { + base := Options{BeaconNodeAddr: "http://localhost:5052"} + opts, err := NewOptions(base, 300*time.Millisecond, BlockFetchPathLegacy) + require.NoError(t, err) + assert.Equal(t, defaultProposalSoftTimeout-300*time.Millisecond, opts.ProposalSoftTimeout) + }) + + t.Run("legacy path floors ProposalSoftTimeout at 500ms", func(t *testing.T) { + // With ProposerDelay = 1500ms, the natural ProposalSoftTimeout would be + // 1800ms - 1500ms = 300ms, which is below the 500ms floor. + base := Options{BeaconNodeAddr: "http://localhost:5052"} + opts, err := NewOptions(base, 1500*time.Millisecond, BlockFetchPathLegacy) + require.NoError(t, err) + assert.Equal(t, minProposalSoftTimeout, opts.ProposalSoftTimeout) + }) + + t.Run("legacy path keeps operator-set ProposalSoftTimeout (no reduction)", func(t *testing.T) { + // When the operator explicitly sets ProposalSoftTimeout, the legacy path + // uses it as-is without subtracting ProposerDelay (power-user mode). + base := Options{ + BeaconNodeAddr: "http://localhost:5052", + ProposalSoftTimeout: 1200 * time.Millisecond, + } + opts, err := NewOptions(base, 300*time.Millisecond, BlockFetchPathLegacy) + require.NoError(t, err) + assert.Equal(t, 1200*time.Millisecond, opts.ProposalSoftTimeout) + }) + + t.Run("MEV-optimized path keeps operator-set ProposalSoftDeadline", func(t *testing.T) { + base := Options{ + BeaconNodeAddr: "http://localhost:5052", + ProposalSoftDeadline: 1850 * time.Millisecond, + } + opts, err := NewOptions(base, 0, BlockFetchPathMEVOptimized) + require.NoError(t, err) + assert.Equal(t, 1850*time.Millisecond, opts.ProposalSoftDeadline) + assert.Equal(t, BlockFetchPathMEVOptimized, opts.BlockFetchPath) + // ProposalSoftTimeout should not be touched. + assert.Equal(t, time.Duration(0), opts.ProposalSoftTimeout) + }) +} + +func TestBlockFetchPath_String(t *testing.T) { + tests := []struct { + path BlockFetchPath + want string + }{ + {BlockFetchPathSafe, "safe"}, + {BlockFetchPathLegacy, "legacy"}, + {BlockFetchPathMEVOptimized, "mev-optimized"}, + {BlockFetchPath(99), "unknown(99)"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.path.String()) + }) + } +} From f211ebe22c62129b8cf7bd193209c8f959dfa9d9 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 21:44:44 +0300 Subject: [PATCH 18/37] docs/MEV_CONSIDERATIONS: document the three-path SSV block-fetch model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc previously referenced a single proposalSoftTimeout model. Update to reflect the path-selection model introduced by the beacon/goclient/options.go split (safe / legacy / MEV-optimized). Changes: - TL;DR: add a brief paragraph noting the three paths, linking to the new Configuration paths section. - Example A + B: add SSV-side YAML snippets with ProposalSoftDeadline, tied to each example's PBS-side late_in_slot_time_ms + ~50ms. - Example B: drop the "fully use SSV's ~1800ms header-fetch buffer" framing (which assumed the legacy proposalSoftTimeout default). Reframe as "PBS-side cutoff at 1800ms; round 1 must succeed." - Tuning bullet: add a note about matching ProposalSoftDeadline to late_in_slot_time_ms + ~50ms for Path-2 operators. - §6 "Interaction with ProposerDelay": replaced wholesale by a new "Configuration paths" section that explains the path-selection algorithm, the three paths individually, and how to opt into Path 2 for multi-BN cross-bid scoring. - Appendix A header: re-labeled "Path 0 (ProposerDelay, legacy approach)" for consistency with the path-selection terminology. --- docs/MEV_CONSIDERATIONS.md | 57 +++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 204d6b24a4..936ddcf356 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -6,6 +6,8 @@ To get the most out of MEV opportunities, configure **timing games on the PBS la If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config`, or any other PBS lacking the feature), the SSV-side `ProposerDelay` configuration is still available — see Appendix A below. PBS-side timing games are the preferred path because they don't consume SSV's slot budget for the auction wait. +On the SSV side, the node chooses one of three block-fetch paths at startup depending on your config — **safe** (default), **legacy** (when `ProposerDelay` or `ProposalSoftTimeout` is set), or **MEV-optimized** (advanced operators opt into for multi-BN cross-bid scoring by setting `ProposalSoftDeadline`). See [Configuration paths](#configuration-paths) below. + ## SSV proposer-duty flow background To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: @@ -107,9 +109,15 @@ relays: frequency_get_header_ms: 150 ``` -### Example B — aggressive: fully use SSV's ~1800ms header-fetch buffer +**SSV-side** (optional; recommended for multi-BN setups to enable cross-BN bid scoring via Path 2 — see [Configuration paths](#configuration-paths)): +```yaml +eth2: + ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport +``` -SSV's `proposalSoftTimeout` (default 1800ms, defined in `beacon/goclient/options.go`) sets the wall-clock budget SSV allocates for collecting block-header responses from BNs. This example targets that full budget: PBS-side cutoff at `1800ms`, last relay poll at ~1600ms, header at SSV by ~1850ms. +### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) + +This example pushes the PBS-side cutoff to `1800ms` — the latest practical value before the worst-case 2-round QBFT scenario stops fitting within the 4000ms slot deadline (see [Configuration paths](#configuration-paths)). Last relay poll lands at ~1600ms; header at SSV by ~1850ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. @@ -150,6 +158,12 @@ relays: frequency_get_header_ms: 200 ``` +**SSV-side** (recommended for multi-BN setups; note that 1850ms triggers a startup warning since it exceeds 1800ms — see [Configuration paths](#configuration-paths)): +```yaml +eth2: + ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport +``` + ## Tuning guidance & measurement methodology The example configs are starting points. Tuning these knobs in production requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value won't be optimal for everyone. @@ -157,11 +171,11 @@ The example configs are starting points. Tuning these knobs in production requir ### Where the auction window should land Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1100ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. +- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1100ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. For Path-2 operators (SSV-side multi-BN scoring), match `ProposalSoftDeadline` to `late_in_slot_time_ms + ~50ms` so SSV's deadline lands right after the PBS response arrives. - **Cutoffs above ~1080ms** accept that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes in QBFT, signing, or submission risk missing the deadline even when round 1 succeeds. -Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled, and fits the worst-case 2-round QBFT scenario. Example B's 1800ms cutoff is the aggressive upper end — fully uses SSV's allocated header-fetch budget for maximum MEV capture, but accepts that round 1 must succeed (the slot is missed if round 1 fails). +Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled, and fits the worst-case 2-round QBFT scenario. Example B's 1800ms cutoff is the aggressive upper end — pushes the auction window as late as possible while still keeping QBFT round 1 + signing + submission within the slot deadline, but accepts that round 1 must succeed (the slot is missed if round 1 fails). ### What to measure first @@ -198,11 +212,40 @@ Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic bu The parallel-fetch logic in `beacon/goclient/proposer.go` exits as soon as one BN returns a blinded block, even if a slower BN would have returned a higher-scoring bid. With timing-games-capable PBSes on multiple BNs, the fastest BN's bid effectively wins regardless of score. Worth knowing if you're running redundant BN setups and expecting cross-BN bid scoring to matter. -## Interaction with `ProposerDelay` +## Configuration paths + +SSV chooses one of three multi-BN block-header fetch strategies at startup based on your config. The choice doesn't affect single-BN setups — single-BN bypasses the parallel-fetch logic entirely. + +### Path selection algorithm + +``` +if ProposerDelay > 0 || ProposalSoftTimeout is set: + -> Path 0 (legacy) +elif ProposalSoftDeadline is set: + -> Path 2 (MEV-optimized) +else: + -> Path 1 (safe, default) +``` + +Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one. + +### Path 1 — Safe (default) + +Multi-BN parallel fetch with **early-exit on the first blinded response** (treats blinded == MEV). If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), returns the best non-blinded response collected so far, or falls through to waiting for the first valid response. Suitable for operators who are not actively cross-comparing bids across multiple BNs. + +### Path 0 — Legacy + +Preserves the original `ProposerDelay` / `ProposalSoftTimeout` behavior bit-for-bit. Selected automatically for operators who have either knob set. See [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach) for the legacy analysis. SSV logs a startup warning suggesting migration to the new model. + +### Path 2 — MEV-optimized (opt-in) + +Same as Path 1 but **without** the early-exit on the first blinded response. SSV waits for all multi-BN responses until the slot-relative `ProposalSoftDeadline`, then returns the highest-scored bid across all BNs. + +To enable, set `ProposalSoftDeadline` in your SSV config (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms` + ~50ms BN→SSV transport. The value must be in `[1000ms, 3600ms]`; values above 1800ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit within the slot deadline. -When PBS-side timing games are configured, set `ProposerDelay = 0` (the default). Stacking is redundant — both mechanisms position the auction window in the slot, but only one should do so. Setting both also triggers SSV's `proposalSoftTimeout -= proposerDelay` reduction in `beacon/goclient/options.go`, which can shrink the multi-BN scoring window unnecessarily. +Useful only for multi-BN setups where the bids returned from each BN may differ enough to be worth cross-comparing. With a single BN, Path 2 has no behavioral effect (single-BN bypasses parallel fetch entirely). -## Appendix A — `ProposerDelay` (legacy approach) +## Appendix A — Path 0 (`ProposerDelay`, legacy approach) `ProposerDelay` remains supported. It is the right tool when: - Your PBS does not support timing games (mev-boost < v1.11, or any PBS without the feature). From 7ab723ac7b0a2232821ad32f8de9cff7cdcd4634 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 21:51:09 +0300 Subject: [PATCH 19/37] docs/MEV_CONSIDERATIONS: clarify Multi-BN caveat applies to safe/legacy paths The "Multi-BN caveat" bullet under the Tuning section described the early-exit-on-first-blinded behavior as if it always applied. After the three-path split, it only applies to Path 0 (legacy) and Path 1 (safe). Path 2 (MEV-optimized) explicitly disables that early-exit to enable cross-BN bid scoring. Update the bullet to make this scope-of-applicability explicit and direct operators wanting true cross-BN scoring to opt into Path 2 via ProposalSoftDeadline. --- docs/MEV_CONSIDERATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 936ddcf356..b0fa40ab80 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -210,7 +210,7 @@ Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic bu ### Multi-BN caveat -The parallel-fetch logic in `beacon/goclient/proposer.go` exits as soon as one BN returns a blinded block, even if a slower BN would have returned a higher-scoring bid. With timing-games-capable PBSes on multiple BNs, the fastest BN's bid effectively wins regardless of score. Worth knowing if you're running redundant BN setups and expecting cross-BN bid scoring to matter. +Under the default safe path (and the legacy path), the parallel-fetch logic in `beacon/goclient/proposer.go` exits as soon as one BN returns a blinded block, even if a slower BN would have returned a higher-scoring bid. With timing-games-capable PBSes on multiple BNs, the fastest BN's bid effectively wins regardless of score. To get true cross-BN bid scoring, opt into the MEV-optimized path by setting `ProposalSoftDeadline` — see [Configuration paths](#configuration-paths). ## Configuration paths From 94ef2b2753d29e8f35d762a7dd366d3991462d2f Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 22:01:45 +0300 Subject: [PATCH 20/37] beacon/goclient: per-path behavior tests; remove plan-doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-path behavior tests covering the dispatch and the key behavioral difference between paths 1 and 2: - TestNew_StoresBlockFetchPath: verifies the selected path and its associated timing field (proposalSoftDeadline / proposalSoftTimeout) propagate from Options into the resulting GoClient. - TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded: with one fast and one slow BN both returning blinded proposals, asserts the safe path returns in <250ms (early-exit on first blinded). - TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit: same setup; asserts the MEV-optimized path waits for the slow BN (>=400ms) before returning. The multi-BN tests use semicolon-separated BN URLs and a slot two slots in the future to ensure the slot-relative ProposalSoftDeadline lands after both BN responses — so the early-exit behavior, not the deadline firing, is what's being observed. Also delete docs/BLOCK_FETCH_PATHS_PLAN.md — the plan-doc is no longer needed now that the implementation has landed and the public-facing content lives in docs/MEV_CONSIDERATIONS.md. --- beacon/goclient/proposer_paths_test.go | 164 ++++++++++++++++++++ docs/BLOCK_FETCH_PATHS_PLAN.md | 202 ------------------------- 2 files changed, 164 insertions(+), 202 deletions(-) create mode 100644 beacon/goclient/proposer_paths_test.go delete mode 100644 docs/BLOCK_FETCH_PATHS_PLAN.md diff --git a/beacon/goclient/proposer_paths_test.go b/beacon/goclient/proposer_paths_test.go new file mode 100644 index 0000000000..a64cdbb8ff --- /dev/null +++ b/beacon/goclient/proposer_paths_test.go @@ -0,0 +1,164 @@ +package goclient + +import ( + "context" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/observability/log" +) + +// Tests for the block-fetch path dispatch (BlockFetchPathSafe / Legacy / MEVOptimized). +// See docs/MEV_CONSIDERATIONS.md for the three-path model. + +// TestNew_StoresBlockFetchPath verifies that the selected path and its associated +// timing field (proposalSoftDeadline / proposalSoftTimeout) get propagated from +// Options into the resulting GoClient. +func TestNew_StoresBlockFetchPath(t *testing.T) { + for _, path := range []BlockFetchPath{BlockFetchPathSafe, BlockFetchPathLegacy, BlockFetchPathMEVOptimized} { + t.Run(path.String(), func(t *testing.T) { + server, _ := createProposalBeaconServer(t, beaconProposalServerOptions{}) + defer server.Close() + + base := Options{ + BeaconNodeAddr: server.URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + } + if path == BlockFetchPathMEVOptimized { + base.ProposalSoftDeadline = 1100 * time.Millisecond + } + opt, err := NewOptions(base, 0, path) + require.NoError(t, err) + + client, err := New(t.Context(), log.TestLogger(t), opt) + require.NoError(t, err) + + assert.Equal(t, path, client.blockFetchPath, "GoClient.blockFetchPath should reflect opt.BlockFetchPath") + + switch path { + case BlockFetchPathSafe: + assert.Equal(t, DefaultProposalSoftDeadline, client.proposalSoftDeadline, + "safe path should default proposalSoftDeadline to %v", DefaultProposalSoftDeadline) + case BlockFetchPathMEVOptimized: + assert.Equal(t, 1100*time.Millisecond, client.proposalSoftDeadline, + "MEV-optimized path should propagate operator-set ProposalSoftDeadline") + case BlockFetchPathLegacy: + assert.NotZero(t, client.proposalSoftTimeout, + "legacy path should have non-zero proposalSoftTimeout") + } + }) + } +} + +// TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded verifies the safe path's +// early-exit-on-first-blinded behavior. With one fast and one slow BN both returning +// blinded proposals, the safe path should return quickly after the fast BN responds, +// without waiting for the slow one. +func TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1500*time.Millisecond) + + // Use a slot starting in the near future so the slot-relative deadline lands + // well after both BN responses (we want to observe the early-exit on blinded, + // not the deadline firing). + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + // Path 1 should early-exit on BN1's blinded response (~10ms) and NOT wait for + // BN2 (~500ms). A generous 250ms ceiling tolerates HTTP / goroutine overhead. + assert.Less(t, elapsed, 250*time.Millisecond, + "Path 1 should early-exit on first blinded; took %v", elapsed) +} + +// TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit verifies that the MEV-optimized +// path does NOT early-exit on the first blinded response — it keeps collecting +// until all BNs respond (or the soft deadline fires). With the same setup as the +// safe-path test, path 2 should wait for the slow BN. +func TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + // Path 2 should NOT early-exit; it waits for BN2's response at ~500ms before + // returning the best-scored proposal. The 400ms floor tolerates clock jitter. + assert.GreaterOrEqual(t, elapsed, 400*time.Millisecond, + "Path 2 should wait for the slower BN; took %v", elapsed) +} + +// setupMultiBNClient builds a GoClient connected to two test BN servers via +// semicolon-separated URLs, with the given block-fetch path and deadline. Used by +// the per-path behavior tests. +func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, path BlockFetchPath, deadline time.Duration) *GoClient { + t.Helper() + + base := Options{ + BeaconNodeAddr: bn1URL + ";" + bn2URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalSoftDeadline: deadline, + } + opt, err := NewOptions(base, 0, path) + require.NoError(t, err) + + client, err := New(t.Context(), log.TestLogger(t), opt) + require.NoError(t, err) + return client +} + +// feeRecipientAllOnes returns a fee-recipient address filled with 0x01 bytes, +// distinguishable from feeRecipientAllTwos in test assertions. +func feeRecipientAllOnes() bellatrix.ExecutionAddress { + var addr bellatrix.ExecutionAddress + for i := range addr { + addr[i] = 1 + } + return addr +} + +// feeRecipientAllTwos returns a fee-recipient address filled with 0x02 bytes. +func feeRecipientAllTwos() bellatrix.ExecutionAddress { + var addr bellatrix.ExecutionAddress + for i := range addr { + addr[i] = 2 + } + return addr +} diff --git a/docs/BLOCK_FETCH_PATHS_PLAN.md b/docs/BLOCK_FETCH_PATHS_PLAN.md deleted file mode 100644 index 0ff36bb5b3..0000000000 --- a/docs/BLOCK_FETCH_PATHS_PLAN.md +++ /dev/null @@ -1,202 +0,0 @@ -# Block-header fetch paths — plan - -## Goal - -Split the SSV block-header fetch logic in `getProposalParallel` into three explicit paths, with a new slot-relative `ProposalSoftDeadline` setting replacing the dangerously-large 1800ms default `proposalSoftTimeout` for new operators while preserving full backward compatibility for legacy configurations. - -## Motivation - -Current code ([`beacon/goclient/proposer.go:176-316`](beacon/goclient/proposer.go)) has two structural problems: -1. **The 1800ms default `proposalSoftTimeout` is dangerously large.** If multi-BN setup returns all-vanilla responses (mev-boost loses relays, no MEV available, etc.), SSV waits the full 1800ms before returning, pushing the worst-case slot budget past the 4000ms deadline. -2. **Early-exit on first blinded defeats multi-BN scoring.** The fastest BN's blinded response wins regardless of bid value; operators intentionally running multiple BNs to cross-compare bids get no benefit from that setup. - -## Three-path design - -### Path selection algorithm - -Step 1 — startup validation (in `cli/operator/node.go`, before `NewOptions` is called so we can see operator-set vs defaulted values): - -``` -if (ProposerDelay > 0 || ProposalSoftTimeout is operator-set) && ProposalSoftDeadline is operator-set: - → REJECT startup with a clear error message: - "ProposalSoftDeadline conflicts with legacy ProposerDelay/ProposalSoftTimeout config — - remove one. See docs/MEV_CONSIDERATIONS.md for path selection guidance." -``` - -Step 2 — path selection (after validation passes): - -``` -if ProposerDelay > 0 || ProposalSoftTimeout is operator-set: - → Path 0 (legacy) — preserves current behavior bit-for-bit - → ALSO: log WARN at startup nudging migration ("There is a better way to opt into MEV; - see docs/MEV_CONSIDERATIONS.md") -elif ProposalSoftDeadline is operator-set: - → Path 2 (MEV-optimized) — no early-exit, wait until deadline -else: - → Path 1 (safe, default) — early-exit on first blinded, fallback at DefaultProposalSoftDeadline -``` - -Selection happens once at startup based on operator-provided config (before defaults are applied). - -### Path 0 — legacy (backward-compat) - -**Trigger:** operator sets `ProposerDelay > 0` OR explicitly sets `WITH_PROPOSAL_SOFT_TIMEOUT`. - -**Behavior:** unchanged from current code. Preserves: -- The `proposalSoftTimeout -= proposerDelay` reduction logic in [`options.go:54-56`](beacon/goclient/options.go). -- The relative-duration semantics of `proposalSoftTimeout`. -- Early-exit on first blinded response. -- Fallback to "wait for first valid response" after soft timeout. -- The 500ms floor on the timeout. - -**Defaults:** `ProposalSoftTimeout = 1800ms` (unchanged), reduced by `ProposerDelay`. - -**Documentation:** lives in Appendix A of `MEV_CONSIDERATIONS.md` (already framed as legacy). - -### Path 1 — safe (new default) - -**Trigger:** neither `ProposerDelay` nor `ProposalSoftTimeout` set; `ProposalSoftDeadline` also not set. - -**Behavior:** -- (a) Multi-BN parallel fetch, early-exit on first blinded response. -- (b) If no blinded response by `DefaultProposalSoftDeadline`, return best-so-far (or wait for first valid response if nothing yet). - -**Defaults:** `DefaultProposalSoftDeadline = 1000ms` (slot-relative). - -**Rationale for 1000ms:** Worst-case slot budget under 2-round QBFT is: -``` -1000ms (deadline) + 2500ms (QBFT) + 350ms (signing+submission) = 3850ms < 4000ms ✓ -``` -Reasonable variance margin even if every component runs slow. - -### Path 2 — MEV-optimized (opt-in) - -**Trigger:** operator explicitly sets `ProposalSoftDeadline` (and `ProposerDelay`/`ProposalSoftTimeout` are not set). - -**Behavior:** -- Multi-BN parallel fetch, **no early-exit on first blinded**. -- Collect all BN responses until `ProposalSoftDeadline` (slot-relative). -- At deadline: return highest-value-scored response among all received. -- If nothing received by deadline: fall through to "wait for first valid response," bounded by slot deadline. - -**Validation at startup:** -- `ProposalSoftDeadline` must be in `[DefaultProposalSoftDeadline (1000ms), 3600ms]`. -- Reject startup with clear error if out of range. -- Log warning (not error) if `> 1800ms`: "ProposalSoftDeadline > 1800ms leaves no budget for QBFT round 2 — slot will be missed if round 1 fails. See docs/MEV_CONSIDERATIONS.md." - -**Rationale:** This is the path advanced operators use after configuring PBS-side timing games (mev-boost ≥ v1.11 or commit-boost). Matching `ProposalSoftDeadline` to the PBS-side `late_in_slot_time_ms` + ~50ms BN→SSV transport gives SSV the full PBS-cutoff value to compare bids across multiple BNs. - -## Single-BN behavior - -Unchanged. Single-BN goes through the existing direct `fetchProposal` call at [`proposer.go:103-107`](beacon/goclient/proposer.go) regardless of selected path. The path distinction is a no-op for single-BN (no other BNs to compare against; no early-exit decision to make). - -## Configuration surface - -### New fields - -- `ssv.SSVOptions.ProposalSoftDeadline time.Duration` (YAML: `ProposalSoftDeadline`, env: `WITH_PROPOSAL_SOFT_DEADLINE`). - - Zero value = not set = use path 1 default. - - Non-zero value = path 2 opt-in. - -### Existing fields (preserved) - -- `ProposerDelay` — unchanged behavior. -- `WITH_PROPOSAL_SOFT_TIMEOUT` env var / `ProposalSoftTimeout` field — unchanged behavior. Only meaningful in path 0. -- `AllowDangerousProposerDelay` — unchanged. Still gates `ProposerDelay > 1000ms`. - -### Validation - -At startup, in node config validation: -1. **Reject** if `(ProposerDelay > 0 || ProposalSoftTimeout is operator-set) && ProposalSoftDeadline is operator-set` — see Step 1 above. Validation returns an error; startup fatals. -2. Determine which path applies based on the algorithm above. -3. If path 0: existing validation only (`AllowDangerousProposerDelay` cap); also emit the migration WARN log noted above. -4. If path 2: enforce `[1000ms, 3600ms]` range; warn (log) if `> 1800ms` (round-2 fallback won't fit). -5. Log the selected path at startup: e.g. `"block-fetch path: safe (default)"`, `"block-fetch path: legacy (ProposerDelay=300ms)"`, `"block-fetch path: MEV-optimized (ProposalSoftDeadline=1100ms)"`. - -**Single-BN + ProposalSoftDeadline**: no special handling — operator can set the field with a single BN, it just has no effect (single-BN bypasses the parallel-fetch logic that uses the deadline). - -## Implementation notes - -### Detecting "operator explicitly set" vs "defaulted" - -The `cleanenv` library populates the struct from YAML/env, then default values are applied later in `NewOptions`. To detect "operator set" vs "defaulted": - -- Option A: track via a pointer (`*time.Duration`) — nil means "not set." -- Option B: track via a separate `bool` field (`ProposalSoftTimeoutWasSet`). -- Option C: do the path selection in `cli/operator/node.go` config validation **before** `NewOptions` is called. - -(C) is cleanest — config validation runs first, picks the path, and stores the result for the rest of the code to use. - -### Function signatures - -`GetBeaconBlock` and `getProposalParallel` either: -- Branch internally based on selected path (single function, conditional logic). -- Get dispatched to separate functions per path (cleaner but more code). - -Probably split into `getProposalParallelSafe` (path 1) and `getProposalParallelMEVOptimized` (path 2), with path 0 retaining its current code. Dispatch happens in `GetBeaconBlock`. - -### Backward-compat test cases - -Unit tests should cover: -- Only `ProposerDelay` set → path 0. -- Only `ProposalSoftTimeout` set → path 0 (with default `ProposalSoftDeadline` ignored). -- Both `ProposerDelay` and `ProposalSoftTimeout` set → path 0. -- Both `ProposerDelay` and `ProposalSoftDeadline` set → path 0 wins (legacy precedence); log notes the ignored `ProposalSoftDeadline`. -- Only `ProposalSoftDeadline` set → path 2. -- Nothing set → path 1. -- `ProposalSoftDeadline` out of range → startup rejected with error. -- `ProposalSoftDeadline > 1800ms` → startup succeeds + warning logged. - -## Matching `ProposalSoftDeadline` to PBS config - -For operators following the path-2 setup with mev-boost/commit-boost: - -``` -ProposalSoftDeadline ≈ PBS late_in_slot_time_ms + ~50ms -``` - -The +50ms covers BN→SSV transport so the deadline lands *after* the latest expected header arrival, giving the scoring loop a chance to collect all BN responses. - -Examples (assuming the worked configs in `MEV_CONSIDERATIONS.md`): -- Example A — `late_in_slot_time_ms = 1050ms` → `ProposalSoftDeadline = 1100ms`. -- Example B — `late_in_slot_time_ms = 1800ms` → `ProposalSoftDeadline = 1850ms`. - -Both inside the `[1000ms, 3600ms]` validation range; Example B is below the 1800ms warn threshold. - -## `MEV_CONSIDERATIONS.md` updates - -The current doc references `proposalSoftTimeout` in several places. After this change: - -1. **§4 Example A and B**: add `ProposalSoftDeadline` config lines to the SSV-side config snippet (currently shows PBS configs only). - - Example A: `ProposalSoftDeadline: 1100ms` - - Example B: `ProposalSoftDeadline: 1850ms` -2. **§6 Interaction with `ProposerDelay`**: rewrite as "Configuration paths" — explain the three-path model and the selection algorithm. -3. **Appendix A**: re-label explicitly as "Path 0 (legacy)." Keep the analysis but make it clear this is no longer the recommended path. -4. **Tuning section** (`Where the auction window should land`): note that the `ProposalSoftDeadline` is now also part of the per-cutoff math, not just the PBS `late_in_slot_time_ms`. -5. **TL;DR**: add a short mention of the path model — "default operators land on path 1 (safe); advanced operators opt into path 2 by setting `ProposalSoftDeadline`." - -## Scope of code changes - -Files touched: -- `cli/operator/node.go` — add `ProposalSoftDeadline` field, path-selection logic in `validateConfig`, startup logging. -- `beacon/goclient/options.go` — add new field; preserve `ProposalSoftTimeout` and its existing default/reduction logic (path 0); add `DefaultProposalSoftDeadline = 1000ms`. -- `beacon/goclient/proposer.go` — path dispatch in `GetBeaconBlock`; new `getProposalParallelMEVOptimized` function (or equivalent). -- `cli/operator/node_test.go` — backward-compat test cases for path selection. -- `beacon/goclient/proposer_test.go` — behavior tests for paths 1 and 2. -- `config/config.example.yaml` — add `ProposalSoftDeadline` comment block. -- `docs/MEV_CONSIDERATIONS.md` — all the rewrites listed above. - -## Resolved decisions - -1. **Path 0 + ProposalSoftDeadline both set** → reject at startup (validation error → fatal). No silent precedence. -2. **Path 1 default = 1000ms** — confirmed. -3. **Path 2 lower bound = 1000ms** — confirmed. -4. **Path 2 upper bound = 3600ms with warn-but-allow above 1800ms** — confirmed. No `AllowDangerousProposalSoftDeadline` flag needed. -5. **Telemetry**: not added. Operators self-knowing their setup is sufficient; no SSV-network-wide visibility need. -6. **Migration nudge for path 0**: yes — log a WARN at startup along the lines of *"There is a better way to opt into MEV — see docs/MEV_CONSIDERATIONS.md"*. -7. **Single-BN + ProposalSoftDeadline**: silently accept (no special handling). Setting has no effect since single-BN bypasses parallel fetch; no warning, no rejection. - -## Implementation choices - -- **Where path selection happens**: `cli/operator/node.go` validation, before `NewOptions` applies defaults. Stores the selected path in the config object for the rest of the code to consume. -- **Code split**: `getProposalParallel` is split into `getProposalParallelSafe` (path 1) and `getProposalParallelMEVOptimized` (path 2). Path 0 retains its existing code at the current `getProposalParallel` (renamed or kept, TBD during implementation). Dispatch happens in `GetBeaconBlock`. From 2edbbbd6753390c4d3390681731826446108eda8 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 22:02:22 +0300 Subject: [PATCH 21/37] cli/operator: drop dangling reference to deleted plan-doc The block-fetch path-determination comment referenced docs/BLOCK_FETCH_PATHS_PLAN.md which was deleted in the previous commit. Keep the docs/MEV_CONSIDERATIONS.md reference, which now covers all the operator-relevant content. --- cli/operator/node.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index 02ea39a8c1..820200affc 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -193,7 +193,7 @@ var StartNodeCmd = &cobra.Command{ // Determine the block-fetch path from operator-provided config before // NewOptions applies any defaults. See docs/MEV_CONSIDERATIONS.md for the - // three-path model and docs/BLOCK_FETCH_PATHS_PLAN.md for the design. + // three-path model. blockFetchPath, err := goclient.DetermineBlockFetchPath(cfg.ConsensusClient, cfg.ProposerDelay) if err != nil { logger.Fatal("invalid block-fetch path configuration", zap.Error(err)) From f672d243807e07d7c14a30d01d2b41317e309aa1 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 18 May 2026 22:17:02 +0300 Subject: [PATCH 22/37] docs/MEV_CONSIDERATIONS: add upfront definitions, slim duplications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the doc to open with a "Definitions and typical values" section: a table defining each stage of the proposer-duty timeline (RANDAO, auction window, MEVBoostRelayTimeout, QBFT, PostConsensusSigning, BlockSubmission) with typical values for a healthy mainnet SSV cluster, explicitly framed as illustrative rather than authoritative. Slim duplications throughout: - §2 background no longer redefines variables — references the table. - The QBFT worst-case decomposition (R1 timer + round change + R2) lives only in the QBFT row of the table; previous duplications in §2 narrative, Example B trade-off, and Appendix A values block are removed. - Appendix A drops the realistic-numbers values block in favor of a one-line derivation using the typical values from the table. - Example A's "not equivalent in header-arrival time" paragraph collapsed to one sentence. Remove the "Multi-BN caveat" subsection from the Tuning section entirely — its content was an explanatory note about the early-exit-on-blinded behavior that the Configuration paths section already covers. Net: 297 -> 280 lines, with significantly less repetition. --- docs/MEV_CONSIDERATIONS.md | 127 ++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 72 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index b0fa40ab80..1a0d007ab3 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -4,75 +4,81 @@ To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. -If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config`, or any other PBS lacking the feature), the SSV-side `ProposerDelay` configuration is still available — see Appendix A below. PBS-side timing games are the preferred path because they don't consume SSV's slot budget for the auction wait. +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config`, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. -On the SSV side, the node chooses one of three block-fetch paths at startup depending on your config — **safe** (default), **legacy** (when `ProposerDelay` or `ProposalSoftTimeout` is set), or **MEV-optimized** (advanced operators opt into for multi-BN cross-bid scoring by setting `ProposalSoftDeadline`). See [Configuration paths](#configuration-paths) below. +## Definitions and typical values + +The variables below name the stages of the SSV proposer-duty timeline. The values are typical/average for a healthy mainnet SSV cluster — real-world variance is significant, and operators should baseline their own latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)) before treating them as hard numbers. + +| Variable | Typical | Description | +|---|---|---| +| `RANDAO` | ~100ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | +| `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-timing-games-recommended). | +| `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | +| `QBFT` | ~2500ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~150ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~350ms successful round 2). The round timer is currently round-relative rather than slot-relative ([#2429](https://github.com/ssvlabs/ssv/issues/2429)); round-2 budget must be reserved even when round 1 typically succeeds. | +| `PostConsensusSigning` | ~150ms | Operators reconstruct the validator BLS signature from partial signatures. | +| `BlockSubmission` | ~200ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | + +The Ethereum slot-propagation deadline is **4000ms** after slot start. ## SSV proposer-duty flow background -To understand how MEV configuration interacts with SSV, here is the proposer-duty flow: -- SSV nodes participate in the pre-consensus phase to build a RANDAO signature that will be used when requesting the block from the Beacon node (`RANDAO`). -- The current round Leader requests the blinded block header from the Beacon node, which proxies the request to the PBS layer (mev-boost or commit-boost). The PBS in turn queries one or more relays (the *auction window*). -- The PBS returns the chosen block header, and the SSV cluster runs QBFT consensus to sign it (`QBFT`). This includes round 1, plus the round-change handshake and round 2 if round 1 fails. Each round has a 2000ms timer, currently round-relative rather than slot-relative (see [#2429](https://github.com/ssvlabs/ssv/issues/2429)). -- After consensus, operators reconstruct the validator BLS signature from partial signatures (`PostConsensusSigning`). -- The leader submits the signed blinded block to the Beacon node; the relay reveals the actual execution payload, which propagates through the network (`BlockSubmission`). +The proposer-duty flow runs the stages above in sequence. For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: -For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: ``` RANDAO + (auction window) + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` -`QBFT` is the worst-case time the cluster spends in QBFT consensus: 2000ms round-1 timer (if round 1 fails) + ~150ms round-change handshake + ~350ms successful round 2 ≈ 2500ms. You must budget for this worst case — in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails (the block must propagate within 4000ms after slot start). -Where the auction window sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. +You must budget for the worst case: in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails. + +Where `(auction window)` sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. ## PBS-side timing games (recommended) The PBS layer implements "timing games" — proactively polling relays at intervals defined in its own config, decoupling *when* the auction happens from *when* SSV asks for the block. SSV asks once and receives whatever bid the PBS has selected by its slot-relative cutoff. -This is preferred over `ProposerDelay` because: +Preferred over `ProposerDelay` because: - The SSV node doesn't sit idle during the auction wait — its slot clock doesn't advance, so QBFT round 1 isn't squeezed. -- The PBS layer handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. -- Configuration is concentrated in the PBS, rather than coordinated across SSV-side and PBS-side knobs. +- The PBS handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. +- Configuration is concentrated in the PBS rather than coordinated across SSV-side and PBS-side knobs. ### Configuration knobs -Both mev-boost and commit-boost expose the same five knobs with identical names: +Both mev-boost and commit-boost expose the same five knobs: - `timeout_get_header_ms` — per-relay-request timeout for a single `getHeader` call. - `late_in_slot_time_ms` — slot-relative hard cutoff. The PBS returns to the caller no later than this point in the slot. -- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. Defaults to `false` in both PBSes; must be set per-relay. +- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. Defaults to `false`; must be set per-relay. - `target_first_request_ms` — when the first poll for this relay fires, measured from slot start. -- `frequency_get_header_ms` — interval between subsequent polls for this relay. +- `frequency_get_header_ms` — interval between subsequent polls. The effective per-request deadline is: ``` max_timeout_ms = min(timeout_get_header_ms, late_in_slot_time_ms - ms_into_slot) slot-relative cutoff = ms_into_slot + max_timeout_ms ``` -When the PBS receives the request early in the slot, the per-request `timeout_get_header_ms` tends to bind. When asked later, `late_in_slot_time_ms - ms_into_slot` binds, and the slot-relative cutoff equals `late_in_slot_time_ms`. +When the PBS receives the request early in the slot, `timeout_get_header_ms` tends to bind; when asked later, `late_in_slot_time_ms - ms_into_slot` binds. ### PBS-specific notes -- **commit-boost** validates `timeout_get_header_ms < late_in_slot_time_ms` at config load — refuses to start otherwise. Set `timeout_get_header_ms` just below `late_in_slot_time_ms` so the slot-relative cutoff binds for any realistic ask time. -- **mev-boost (v1.11+)** has the same knobs and the same budget math, but does not enforce that strict inequality — values may be equal. mev-boost requires `-config ` to enable the YAML-based timing-games config; `-watch-config` enables hot reload. -- Default `late_in_slot_time_ms`: mev-boost `2000ms`, commit-boost `3000ms`. Both are aggressive defaults assuming a well-tuned QBFT and BN setup. +- **commit-boost** validates `timeout_get_header_ms < late_in_slot_time_ms` at config load — refuses to start otherwise. Set `timeout_get_header_ms` just below `late_in_slot_time_ms`. +- **mev-boost (v1.11+)** has the same knobs and budget math but does not enforce that inequality — values may be equal. Requires `-config ` to enable the YAML-based timing-games config; `-watch-config` enables hot reload. +- Default `late_in_slot_time_ms`: mev-boost `2000ms`, commit-boost `3000ms`. Both are aggressive. - mev-boost selects the most-recently-received bid per relay, then compares across relays for the highest value. Upstream references: -- mev-boost timing-games doc: [github.com/flashbots/mev-boost/blob/main/docs/timing-games.md](https://github.com/flashbots/mev-boost/blob/main/docs/timing-games.md) -- commit-boost docs: [commit-boost.github.io/commit-boost-client](https://commit-boost.github.io/commit-boost-client/) +- mev-boost: [github.com/flashbots/mev-boost/blob/main/docs/timing-games.md](https://github.com/flashbots/mev-boost/blob/main/docs/timing-games.md) +- commit-boost: [commit-boost.github.io/commit-boost-client](https://commit-boost.github.io/commit-boost-client/) ## Configuration examples -Two scenarios, each shown for both PBSes. The starting numbers below are reasonable defaults for a healthy mainnet cluster — operators should validate them against their own measured latencies before adopting (see [Tuning guidance](#tuning-guidance--measurement-methodology)). +Two scenarios shown for both PBSes. The numbers are starting points for a healthy mainnet cluster — operators should validate against their own measured latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)). ### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) -Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline: the relay bids you'll see are sampled at the same moment in the slot. +Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV) instead of legacy's ~1500–2000ms, leaving more slot budget for QBFT and submission. -This is **not** the same as legacy `ProposerDelay = 1000ms` in terms of when the header arrives at SSV — legacy would deliver the header to SSV anywhere from ~1500ms to ~2000ms (after mev-boost's `getHeaderTimeout` runs its course), whereas this configuration delivers it at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV transport). PBS-timing-games is strictly better at the same bid-sample time: same bid quality, more slot budget left for QBFT and submission. - -The relay polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms — three chances per relay, with the last poll landing at the target bid-sample time. +The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms. **commit-boost** (TOML): ```toml @@ -117,11 +123,11 @@ eth2: ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -This example pushes the PBS-side cutoff to `1800ms` — the latest practical value before the worst-case 2-round QBFT scenario stops fitting within the 4000ms slot deadline (see [Configuration paths](#configuration-paths)). Last relay poll lands at ~1600ms; header at SSV by ~1850ms. +Pushes the PBS-side cutoff to `1800ms` — the latest practical value before the worst-case 2-round QBFT scenario stops fitting within the 4000ms slot deadline. Last relay poll at ~1600ms; header at SSV by ~1850ms. -The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, and 1600ms — four chances per relay, with ~200ms RTT margin to the cutoff. +The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. -Trade-off vs Example A: bid-sample time shifts ~600ms later in the slot, capturing meaningfully more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms (Example A) to ~2150ms. The ~2150ms budget is below the ~2850ms required to fit the worst-case 2-round QBFT scenario (`QBFT` ~2500ms + `PostConsensusSigning` ~150ms + `BlockSubmission` ~200ms). Example B accepts that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2850ms required for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -158,7 +164,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (recommended for multi-BN setups; note that 1850ms triggers a startup warning since it exceeds 1800ms — see [Configuration paths](#configuration-paths)): +**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds 1800ms — see [Configuration paths](#configuration-paths)): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -166,22 +172,19 @@ eth2: ## Tuning guidance & measurement methodology -The example configs are starting points. Tuning these knobs in production requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value won't be optimal for everyone. +The example configs are starting points. Production tuning requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value isn't optimal for everyone. ### Where the auction window should land -Bid value grows through the slot: more transaction order flow becomes available, more arbitrage opportunities resolve, and builders accumulate higher-quality bundles. So the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). With `QBFT` at worst-case ~2500ms (2000ms R1 timer + 150ms round change + 350ms R2), `PostConsensusSigning` ~150ms, and `BlockSubmission` ~200ms — total ~2850ms post-cutoff budget required — this resolves to `late_in_slot_time_ms ≲ ~1100ms`, the threshold above which a round-2 fallback can no longer complete within the 4000ms slot deadline. For Path-2 operators (SSV-side multi-BN scoring), match `ProposalSoftDeadline` to `late_in_slot_time_ms + ~50ms` so SSV's deadline lands right after the PBS response arrives. -- **Cutoffs above ~1080ms** accept that round 1 must succeed for the slot — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. -- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes in QBFT, signing, or submission risk missing the deadline even when round 1 succeeds. +Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -Example A's ~1050ms cutoff is the recommended starting point — equivalent to legacy `ProposerDelay = 1000ms` in terms of when relay bids are sampled, and fits the worst-case 2-round QBFT scenario. Example B's 1800ms cutoff is the aggressive upper end — pushes the auction window as late as possible while still keeping QBFT round 1 + signing + submission within the slot deadline, but accepts that round 1 must succeed (the slot is missed if round 1 fails). +- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget required is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback can no longer complete within the slot deadline. For Path-2 operators, match `ProposalSoftDeadline` to `late_in_slot_time_ms + ~50ms`. +- **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. +- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. ### What to measure first -Before changing knobs, baseline these values: - -- **RANDAO completion time** — how long pre-consensus takes, visible on Grafana charts. +- **RANDAO completion time** — visible on Grafana charts. - **BN → PBS RTT** — typically same machine, well under 10ms. - **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this. - **QBFT round-1 completion distribution** — visible on Grafana charts. @@ -189,17 +192,15 @@ Before changing knobs, baseline these values: ### SSV telemetry -Relevant logs and metrics already emitted by SSV: +Relevant logs and metrics: - `"got beacon block proposal"` log with `took` duration, in `protocol/v2/ssv/runner/proposer.go`. -- `"received proposal"` debug log with `score`, `latency`, `blinded`, and `pending`, in `beacon/goclient/proposer.go`. Emitted per BN response in multi-BN setups. +- `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending`, in `beacon/goclient/proposer.go`. Emitted per BN response in multi-BN setups. - `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. -For multi-BN setups, per-BN scoring visibility comes from the parallel-fetch path in `beacon/goclient/proposer.go`. - ### Mainnet vs testnet -Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic builders, and their bid distributions don't reflect mainnet economics. Use testnet for end-to-end plumbing validation only — proposer reliability, correct config parsing, no missed slots. For MEV-uplift quantification, use mainnet validator data + relay-data APIs. +Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic builders; their bid distributions don't reflect mainnet economics. Use testnet for plumbing validation (reliability, config parsing, no missed slots). For MEV-uplift quantification, use mainnet validator data + relay-data APIs. ### Iteration discipline @@ -208,13 +209,9 @@ Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic bu - Monitor miss rate alongside bid-value distribution; back off if miss rate degrades. - Allow enough observation time — proposals are sparse (roughly one per validator per month on mainnet), so small validator sets need long windows for statistical signal. -### Multi-BN caveat - -Under the default safe path (and the legacy path), the parallel-fetch logic in `beacon/goclient/proposer.go` exits as soon as one BN returns a blinded block, even if a slower BN would have returned a higher-scoring bid. With timing-games-capable PBSes on multiple BNs, the fastest BN's bid effectively wins regardless of score. To get true cross-BN bid scoring, opt into the MEV-optimized path by setting `ProposalSoftDeadline` — see [Configuration paths](#configuration-paths). - ## Configuration paths -SSV chooses one of three multi-BN block-header fetch strategies at startup based on your config. The choice doesn't affect single-BN setups — single-BN bypasses the parallel-fetch logic entirely. +SSV chooses one of three multi-BN block-header fetch strategies at startup based on your config. Single-BN setups bypass the parallel-fetch logic entirely and aren't affected. ### Path selection algorithm @@ -227,23 +224,21 @@ else: -> Path 1 (safe, default) ``` -Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one. +Setting `ProposalSoftDeadline` together with either legacy knob is rejected at startup — pick one. ### Path 1 — Safe (default) -Multi-BN parallel fetch with **early-exit on the first blinded response** (treats blinded == MEV). If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), returns the best non-blinded response collected so far, or falls through to waiting for the first valid response. Suitable for operators who are not actively cross-comparing bids across multiple BNs. +Multi-BN parallel fetch with **early-exit on the first blinded response**. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), returns the best non-blinded response collected so far, or falls through to waiting for the first valid response. ### Path 0 — Legacy -Preserves the original `ProposerDelay` / `ProposalSoftTimeout` behavior bit-for-bit. Selected automatically for operators who have either knob set. See [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach) for the legacy analysis. SSV logs a startup warning suggesting migration to the new model. +Preserves the original `ProposerDelay` / `ProposalSoftTimeout` behavior bit-for-bit. Selected automatically when either legacy knob is set. SSV logs a startup warning suggesting migration to the new model. See [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). ### Path 2 — MEV-optimized (opt-in) -Same as Path 1 but **without** the early-exit on the first blinded response. SSV waits for all multi-BN responses until the slot-relative `ProposalSoftDeadline`, then returns the highest-scored bid across all BNs. +Same as Path 1 but **without** the early-exit on the first blinded response — SSV waits for all multi-BN responses until the slot-relative `ProposalSoftDeadline`, then returns the highest-scored bid across all BNs. Useful only when multiple BNs may produce meaningfully different bids worth cross-comparing. -To enable, set `ProposalSoftDeadline` in your SSV config (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms` + ~50ms BN→SSV transport. The value must be in `[1000ms, 3600ms]`; values above 1800ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit within the slot deadline. - -Useful only for multi-BN setups where the bids returned from each BN may differ enough to be worth cross-comparing. With a single BN, Path 2 has no behavioral effect (single-BN bypasses parallel fetch entirely). +To enable, set `ProposalSoftDeadline` (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above 1800ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit. ## Appendix A — Path 0 (`ProposerDelay`, legacy approach) @@ -262,19 +257,7 @@ With `ProposerDelay` active, the slot-budget equation becomes: RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` -Plugging in realistic numbers (typical case where round 1 succeeds): -``` -RANDAO ≈ 100ms -MEVBoostRelayTimeout ≈ 200ms -QBFT ≈ 2500ms (worst case: 2000ms R1 timer + 150ms round change + 350ms R2) -PostConsensusSigning ≈ 150ms -BlockSubmission ≈ 200ms -ProposerDelay = 4000ms − (sum above) ≈ 850ms -``` - -**Note:** the `MEVBoostRelayTimeout ≈ 200ms` figure above assumes the legacy single-shot PBS behavior, where mev-boost queries each relay once at the moment SSV asks. A timing-games-capable PBS uses a much larger budget here, in which case the SSV-side `ProposerDelay` lever isn't useful — see the PBS-side timing games section above. - -The 850ms figure is the theoretical maximum assuming median latencies for every component and the worst-case 2-round QBFT scenario. In practice, `QBFT`, `PostConsensusSigning`, and `BlockSubmission` latencies all have meaningful variance — an unlucky combination can easily add several hundred ms. We consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet; the ~150ms of headroom is buffer against this variance. Going beyond risks missed block proposals whenever round 1 fails. +Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (100 + 200 + 2500 + 150 + 200) = 850ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~150ms of headroom for variance. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. @@ -282,7 +265,7 @@ We recommend starting with a small value such as 300ms and increasing gradually **The SSV node refuses to start if `ProposerDelay` is set higher than 1000ms without explicit confirmation.** -If you attempt to use a `ProposerDelay` value higher than 1000ms, the node exits with an error message. If you understand the risks and want to proceed anyway, you must also set the `AllowDangerousProposerDelay` flag: +If you attempt to use a `ProposerDelay` value higher than 1000ms, the node exits with an error message. If you understand the risks and want to proceed anyway, set the `AllowDangerousProposerDelay` flag: ```yaml ProposerDelay: 2000ms From 1026a8648e202c7ba9ac61155aa9019b75d44ac2 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 00:13:27 +0300 Subject: [PATCH 23/37] address review: fix SafeMax math; small fixes; doc clarifications #1 (math bug): SafeMaxProposalSoftDeadline 1800ms -> 1100ms. The previous 1800ms claimed "round-2 QBFT fits" but the math doesn't: with QBFT worst-case 2-round = 2500ms, signing 150ms, submission 200ms, plus 50ms BN->SSV transport, deadline must be <= 1100ms for round-2 to fit within the 4000ms slot deadline. Updated the comment in options.go, the warning text in node.go, and references in MEV_CONSIDERATIONS.md (Path 2 section, Example B narrative + SSV-side note). options_test.go updated for the new safe-max boundary. #5: waitForFirstValidProposal now joins accumulated BN failure errors with ctx.Err() on slot deadline, preserving diagnostic context. #6 (doc): clarify that 700-1000ms ProposerDelay is permitted by the safety guard but is risky and not recommended. Doc-only change; the 1000ms cap stays as the hard safety threshold. #7: re-add concrete measurement function references (measurements.PreConsensusTime / ConsensusTime in protocol/v2/ssv/runner/) and the SubmitBeaconBlock entry-point to the "What to measure first" section. Operators without Grafana exports need these to instrument their stack. #8 partial: add TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid verifying that paths 1/2 fall through to waitForFirstValidProposal when the slot-relative soft deadline has already fired. Uses a slot in the past so softCtx is done immediately at collection-loop entry. #9 (doc): tighten Example A's legacy-arrival range (~1300-2000ms, not ~1500-2000ms), and rewrite Example B's "latest practical value" framing to match the corrected 1100ms threshold from #1. #2 + #3: add a multi-BN pointer to TL;DR directing operators with multiple Beacon nodes to opt into Path 2 by setting ProposalSoftDeadline. Pushed back on (with rationale): - Reviewer claim that single-BN sees the "did not receive any valid proposals" log: incorrect. Single-BN goes through the direct fetchProposal path in GetBeaconBlock (proposer.go:103) and never enters getProposalParallel*; the log is multi-BN only. - Lowering MinProposalSoftDeadline below 1000ms: marginal value. Operators with low PBS cutoffs can still opt into Path 2 with the 1000ms floor (just wastes some wait time, not broken). - Lowering the ProposerDelay safety cap from 1000ms to 700ms: behavior change affecting existing operators using 700-1000ms. Clarified the gap in doc instead. - Startup-fatal end-to-end test for ProposerDelay+ProposalSoftDeadline combo: more infra for marginal return; unit test on DetermineBlockFetchPath already covers the logic. - Code-duplication parameterization for getProposalParallelSafe vs getProposalParallelMEVOptimized: the explicit split was deliberate per design discussion; merging would add control-flow complexity. --- beacon/goclient/options.go | 11 +++++-- beacon/goclient/options_test.go | 2 +- beacon/goclient/proposer.go | 4 ++- beacon/goclient/proposer_paths_test.go | 40 ++++++++++++++++++++++++++ cli/operator/node.go | 2 +- docs/MEV_CONSIDERATIONS.md | 24 +++++++++------- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index c77457a949..feaa165b03 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -65,9 +65,14 @@ const ( MaxProposalSoftDeadline = 3600 * time.Millisecond // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT - // scenario no longer fits within the slot deadline (round 1 must succeed). Values above - // this trigger a startup warning but are still permitted. - SafeMaxProposalSoftDeadline = 1800 * time.Millisecond + // scenario no longer fits within the slot deadline (round 1 must succeed for the slot). + // Derived from the typical values in docs/MEV_CONSIDERATIONS.md: + // deadline + 50ms (BN→SSV transport) + 2500ms (QBFT worst-case 2-round) + + // 150ms (PostConsensusSigning) + 200ms (BlockSubmission) <= 4000ms + // => deadline <= 1100ms + // Values above this trigger a startup warning but are still permitted (the operator + // is explicitly accepting "round 1 must succeed" — Example B is such a setup). + SafeMaxProposalSoftDeadline = 1100 * time.Millisecond ) // Path 0 (legacy) constants — preserved for backward-compat. diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go index 0e1a2ac920..336688d036 100644 --- a/beacon/goclient/options_test.go +++ b/beacon/goclient/options_test.go @@ -88,7 +88,7 @@ func TestValidateProposalSoftDeadline(t *testing.T) { }{ {name: "at minimum (1000ms) -> ok", value: 1000 * time.Millisecond, wantErr: false}, {name: "below minimum (999ms) -> error", value: 999 * time.Millisecond, wantErr: true}, - {name: "at safe max (1800ms) -> ok (warn handled externally)", value: 1800 * time.Millisecond, wantErr: false}, + {name: "at safe max (1100ms) -> ok (warn handled externally)", value: 1100 * time.Millisecond, wantErr: false}, {name: "above safe max but below hard max (2500ms) -> ok", value: 2500 * time.Millisecond, wantErr: false}, {name: "at hard max (3600ms) -> ok", value: 3600 * time.Millisecond, wantErr: false}, {name: "above hard max (3601ms) -> error", value: 3601 * time.Millisecond, wantErr: true}, diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index 5cdfa91698..4b99e6a480 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -383,7 +383,9 @@ func (gc *GoClient) waitForFirstValidProposal( ) return res.proposal, nil case <-ctx.Done(): - return nil, ctx.Err() + // Preserve any accumulated BN failure context alongside the parent + // deadline error — operators need both to diagnose missed slots. + return nil, errors.Join(ctx.Err(), errs) } } return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) diff --git a/beacon/goclient/proposer_paths_test.go b/beacon/goclient/proposer_paths_test.go index a64cdbb8ff..8610585f4b 100644 --- a/beacon/goclient/proposer_paths_test.go +++ b/beacon/goclient/proposer_paths_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,6 +125,45 @@ func TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit(t *testing.T) { "Path 2 should wait for the slower BN; took %v", elapsed) } +// TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid verifies that +// when the slot-relative soft deadline has already fired before any BN responds, +// the parallel-fetch path falls through to waitForFirstValidProposal and returns +// the first valid BN response. Uses a slot in the past so the deadline is past. +func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 200 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1000*time.Millisecond) + + // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative + // deadline = slotStart + 1000ms is also in the past, so softCtx is already + // done when the collection loop starts. + pastSlot := phase0.Slot(1) + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), pastSlot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err, "fallback to first-valid should return successfully") + + // Should return after BN1 responds (~200ms), not wait for BN2 (~500ms). This + // confirms waitForFirstValidProposal is invoked (returning the first valid + // response, bounded by the parent context's slot deadline). + assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, + "should have waited for first BN response (~200ms); took %v", elapsed) + assert.Less(t, elapsed, 400*time.Millisecond, + "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) +} + // setupMultiBNClient builds a GoClient connected to two test BN servers via // semicolon-separated URLs, with the given block-fetch path and deadline. Used by // the per-path behavior tests. diff --git a/cli/operator/node.go b/cli/operator/node.go index 820200affc..43f376889f 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -210,7 +210,7 @@ var StartNodeCmd = &cobra.Command{ logger.Fatal("invalid ProposalSoftDeadline configuration", zap.Error(err)) } if cfg.ConsensusClient.ProposalSoftDeadline > goclient.SafeMaxProposalSoftDeadline { - logger.Warn("ProposalSoftDeadline exceeds the safe upper bound — round-2 QBFT fallback will not fit within the slot deadline; slot is missed whenever round 1 fails", + logger.Warn("ProposalSoftDeadline exceeds the safe-max threshold for the worst-case 2-round QBFT scenario — round-2 fallback will not fit within the slot deadline. The slot will be missed whenever round 1 fails. This is an explicit 'round 1 must succeed' configuration.", zap.Int64("proposal_soft_deadline_ms", cfg.ConsensusClient.ProposalSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", goclient.SafeMaxProposalSoftDeadline.Milliseconds())) } diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 1a0d007ab3..aa1f27cf4d 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -4,7 +4,9 @@ To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. -If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config`, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. + +If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, opt into the MEV-optimized fetch path by setting `ProposalSoftDeadline` on the SSV side (see [Configuration paths](#configuration-paths)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. ## Definitions and typical values @@ -76,7 +78,7 @@ Two scenarios shown for both PBSes. The numbers are starting points for a health ### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) -Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV) instead of legacy's ~1500–2000ms, leaving more slot budget for QBFT and submission. +Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV) instead of legacy's ~1300–2000ms (depending on relay response speed), leaving more slot budget for QBFT and submission. The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms. @@ -123,7 +125,7 @@ eth2: ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -Pushes the PBS-side cutoff to `1800ms` — the latest practical value before the worst-case 2-round QBFT scenario stops fitting within the 4000ms slot deadline. Last relay poll at ~1600ms; header at SSV by ~1850ms. +Pushes the PBS-side cutoff to `1800ms` — well past the ~1100ms threshold where round-2 QBFT fallback stops fitting within the slot. This explicitly accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth. Last relay poll at ~1600ms; header at SSV by ~1850ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. @@ -164,7 +166,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds 1800ms — see [Configuration paths](#configuration-paths)): +**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds the ~1100ms safe-max for the worst-case 2-round QBFT scenario — see [Configuration paths](#configuration-paths)): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -184,11 +186,11 @@ Bid value grows through the slot, so the auction cutoff should be as late as pos ### What to measure first -- **RANDAO completion time** — visible on Grafana charts. -- **BN → PBS RTT** — typically same machine, well under 10ms. -- **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this. -- **QBFT round-1 completion distribution** — visible on Grafana charts. -- **Submission round-trip** — includes the relay payload-reveal step. +- **RANDAO completion time** — `measurements.PreConsensusTime()` in `protocol/v2/ssv/runner/`. Visible on Grafana charts if exported. +- **BN → PBS RTT** — typically same machine, well under 10ms. Visible in PBS logs. +- **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this on every `getHeader` call. +- **QBFT round-1 completion distribution** — `measurements.ConsensusTime()` in `protocol/v2/ssv/runner/`. Visible on Grafana charts if exported. +- **Submission round-trip** — from `SubmitBeaconBlock` in `beacon/goclient/proposer.go` through the relay payload-reveal step. ### SSV telemetry @@ -238,7 +240,7 @@ Preserves the original `ProposerDelay` / `ProposalSoftTimeout` behavior bit-for- Same as Path 1 but **without** the early-exit on the first blinded response — SSV waits for all multi-BN responses until the slot-relative `ProposalSoftDeadline`, then returns the highest-scored bid across all BNs. Useful only when multiple BNs may produce meaningfully different bids worth cross-comparing. -To enable, set `ProposalSoftDeadline` (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above 1800ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit. +To enable, set `ProposalSoftDeadline` (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit within the slot (round 1 must succeed for the slot). ## Appendix A — Path 0 (`ProposerDelay`, legacy approach) @@ -259,6 +261,8 @@ RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + Bl Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (100 + 200 + 2500 + 150 + 200) = 850ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~150ms of headroom for variance. +The safety guard at startup is the looser 1000ms cap ([Appendix B](#appendix-b--safety-limits)): values between ~700ms and 1000ms are permitted without `AllowDangerousProposerDelay` but should be considered risky and not recommended. + We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. ## Appendix B — Safety limits From d656e5e0d31057d3897e99030a863e4db6fd4427 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 00:30:45 +0300 Subject: [PATCH 24/37] address review: fix race in test fixture; reject negative configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: createProposalResponseSafe mutates pointer-shared ssv-spec fixture blocks (TestingBlindedBeaconBlockV / TestingBeaconBlockV return wrappers that point at cached structs). Single-BN tests never hit this because only one server-handler goroutine runs at a time. The new multi-BN tests trigger two server goroutines concurrently, racing on block.Slot and block.Body.*.FeeRecipient. Confirmed locally with `go test -race`. Fix: package-level sync.Mutex around createProposalResponseSafe. Serializes the mutation+marshal so each call's bytes capture its own intended state. Verified: `go test -race ./beacon/goclient` now passes. P2: DetermineBlockFetchPath used `> 0` checks for path selection, which silently treated negative values as "unset". A negative ProposalSoftDeadline (-100ms for instance) would route through the safe path with proposalSoftDeadline preserved as-is, then slotStart.Add(negative) produces a deadline in the past — softCtx fires immediately, multi-BN scoring is skipped, the operator gets no warning. Fix: reject negative values for ProposerDelay, ProposalSoftTimeout, and ProposalSoftDeadline upfront in DetermineBlockFetchPath with a clear "must be non-negative" error. Operators get a startup fatal instead of confusing silent behavior. Three new test cases cover each variable. Cleanup note (reviewer): the Safe vs MEV-optimized collectors are nearly identical except for the blinded early-exit. Keeping them split is intentional per design discussion; revisit if they drift. No action needed now. --- beacon/goclient/options.go | 20 ++++++++++++++++++-- beacon/goclient/options_test.go | 18 ++++++++++++++++++ beacon/goclient/proposer_test.go | 13 ++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index feaa165b03..25484ee0b5 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -116,9 +116,25 @@ type Options struct { // Must be called with raw operator-provided values (before NewOptions applies any defaults) // so that the "operator explicitly set" vs "defaulted" distinction is preserved. // -// Returns an error when the config combines path-0 (legacy) knobs with the path-2 -// (MEV-optimized) ProposalSoftDeadline — operators must pick one. +// Returns an error when: +// - any of the MEV-related duration knobs is negative; or +// - the config combines path-0 (legacy) knobs with the path-2 (MEV-optimized) +// ProposalSoftDeadline — operators must pick one. func DetermineBlockFetchPath(base Options, proposerDelay time.Duration) (BlockFetchPath, error) { + // Negative values are nonsensical for any of these and would silently be + // treated as "unset" by the `> 0` checks below — reject them upfront so the + // operator gets a clear startup error instead of a confusing late-firing + // soft-deadline or skipped path-0 selection. + if proposerDelay < 0 { + return 0, fmt.Errorf("ProposerDelay must be non-negative, got %v", proposerDelay) + } + if base.ProposalSoftTimeout < 0 { + return 0, fmt.Errorf("ProposalSoftTimeout must be non-negative, got %v", base.ProposalSoftTimeout) + } + if base.ProposalSoftDeadline < 0 { + return 0, fmt.Errorf("ProposalSoftDeadline must be non-negative, got %v", base.ProposalSoftDeadline) + } + legacySet := proposerDelay > 0 || base.ProposalSoftTimeout > 0 deadlineSet := base.ProposalSoftDeadline > 0 diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go index 336688d036..1e1e19a6b5 100644 --- a/beacon/goclient/options_test.go +++ b/beacon/goclient/options_test.go @@ -64,6 +64,24 @@ func TestDetermineBlockFetchPath(t *testing.T) { proposerDelay: 300 * time.Millisecond, wantErr: "ProposalSoftDeadline conflicts with legacy", }, + { + name: "negative ProposerDelay -> error", + options: Options{}, + proposerDelay: -100 * time.Millisecond, + wantErr: "ProposerDelay must be non-negative", + }, + { + name: "negative ProposalSoftTimeout -> error", + options: Options{ProposalSoftTimeout: -100 * time.Millisecond}, + proposerDelay: 0, + wantErr: "ProposalSoftTimeout must be non-negative", + }, + { + name: "negative ProposalSoftDeadline -> error (would otherwise silently fall to safe path)", + options: Options{ProposalSoftDeadline: -100 * time.Millisecond}, + proposerDelay: 0, + wantErr: "ProposalSoftDeadline must be non-negative", + }, } for _, tt := range tests { diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index 3bef61daa4..6dce0029c2 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -128,8 +128,19 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption return server, serverGotRequests } -// Create a safe proposal response using ssv-spec utilities (called once during server setup) +// spectestingMu serializes mutations on the shared ssv-spec testing fixtures. +// TestingBlindedBeaconBlockV / TestingBeaconBlockV return wrappers that point at +// cached block structs; without this lock, concurrent test-server goroutines +// (e.g., the multi-BN tests) racially mutate the same struct on `block.Slot` and +// `block.Body.*.FeeRecipient`. The JSON marshaling happens inside the lock so +// each call captures its own intended state before the next mutation begins. +var spectestingMu sync.Mutex + +// Create a safe proposal response using ssv-spec utilities. func createProposalResponseSafe(slot phase0.Slot, feeRecipient bellatrix.ExecutionAddress, blinded bool) []byte { + spectestingMu.Lock() + defer spectestingMu.Unlock() + if blinded { // Get a blinded block from ssv-spec testing utilities versionedBlinded := spectestingutils.TestingBlindedBeaconBlockV(spec.DataVersionElectra) From df94a2c63d75f7c2356ae182035857c488c3a3e5 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 09:58:36 +0300 Subject: [PATCH 25/37] docs/MEV_CONSIDERATIONS: drop Path 0/1/2 terminology; reframe as new vs legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader-facing model now is just two approaches and the interaction between them — no internal path numbers. - Rename "Configuration paths" section to "SSV-side block-fetch configuration". Restructure into "New approach (recommended)" / "Legacy approach" / "Interaction" subsections. - Drop "Path 1 — Safe (default)", "Path 0 — Legacy", "Path 2 — MEV-optimized (opt-in)" headings. The default-vs-cross-BN-scoring variants of the new approach are now described in prose within the "New approach" subsection. - Rename Appendix A header from "Path 0 (ProposerDelay, legacy approach)" to "Legacy ProposerDelay approach". - TL;DR drops the "MEV-optimized fetch path" framing for the multi-BN pointer; just says "set ProposalSoftDeadline to opt into cross-BN bid scoring." - Example A / Example B SSV-side notes and the Tuning section bullet rephrased to avoid "Path 2" / "Path-2 operators" labels. - All anchor links updated to the new header slugs. Internal code names (BlockFetchPathSafe / Legacy / MEVOptimized) are unchanged — this is a doc-terminology cleanup only. --- docs/MEV_CONSIDERATIONS.md | 52 ++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index aa1f27cf4d..a767d4f5c2 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -4,9 +4,9 @@ To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. -If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. -If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, opt into the MEV-optimized fetch path by setting `ProposalSoftDeadline` on the SSV side (see [Configuration paths](#configuration-paths)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. +If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, set `ProposalSoftDeadline` on the SSV side to opt into cross-BN bid scoring (see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. ## Definitions and typical values @@ -117,7 +117,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (optional; recommended for multi-BN setups to enable cross-BN bid scoring via Path 2 — see [Configuration paths](#configuration-paths)): +**SSV-side** (optional; recommended for multi-BN setups to enable cross-BN bid scoring — see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -166,7 +166,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds the ~1100ms safe-max for the worst-case 2-round QBFT scenario — see [Configuration paths](#configuration-paths)): +**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds the ~1100ms safe-max for the worst-case 2-round QBFT scenario — see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -180,7 +180,7 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget required is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback can no longer complete within the slot deadline. For Path-2 operators, match `ProposalSoftDeadline` to `late_in_slot_time_ms + ~50ms`. +- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget required is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback can no longer complete within the slot deadline. For operators who set `ProposalSoftDeadline` (opting into cross-BN bid scoring), match it to `late_in_slot_time_ms + ~50ms`. - **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. @@ -211,38 +211,40 @@ Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic bu - Monitor miss rate alongside bid-value distribution; back off if miss rate degrades. - Allow enough observation time — proposals are sparse (roughly one per validator per month on mainnet), so small validator sets need long windows for statistical signal. -## Configuration paths +## SSV-side block-fetch configuration -SSV chooses one of three multi-BN block-header fetch strategies at startup based on your config. Single-BN setups bypass the parallel-fetch logic entirely and aren't affected. +SSV supports two mutually-exclusive approaches to multi-BN block-header fetch, selected at startup from your config. Single-BN setups bypass this entirely and use a direct BN call regardless of which knobs are set. -### Path selection algorithm +### New approach (recommended) -``` -if ProposerDelay > 0 || ProposalSoftTimeout is set: - -> Path 0 (legacy) -elif ProposalSoftDeadline is set: - -> Path 2 (MEV-optimized) -else: - -> Path 1 (safe, default) -``` +By default, the multi-BN fetch returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), SSV returns the best non-blinded response collected so far, falling through to wait for the first valid response if nothing usable arrived. -Setting `ProposalSoftDeadline` together with either legacy knob is rejected at startup — pick one. +Operators running multiple BNs who want SSV to compare bid *values* across BNs — instead of taking the first BN to respond with blinded — should set `ProposalSoftDeadline` explicitly. Setting it: +- Disables the early-exit on first blinded; SSV waits for all multi-BN responses up to the deadline. +- Returns the highest-scored bid across all BNs. -### Path 1 — Safe (default) +Match the value to your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — the worst-case 2-round QBFT scenario can no longer fit within the slot (round 1 must succeed for the slot). -Multi-BN parallel fetch with **early-exit on the first blinded response**. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), returns the best non-blinded response collected so far, or falls through to waiting for the first valid response. +### Legacy approach -### Path 0 — Legacy +Setting `ProposerDelay` or `ProposalSoftTimeout` selects the legacy block-fetch behavior — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). The legacy behavior is preserved bit-for-bit; SSV logs a startup warning suggesting migration to the new approach. -Preserves the original `ProposerDelay` / `ProposalSoftTimeout` behavior bit-for-bit. Selected automatically when either legacy knob is set. SSV logs a startup warning suggesting migration to the new model. See [Appendix A](#appendix-a--path-0-proposerdelay-legacy-approach). +### Interaction -### Path 2 — MEV-optimized (opt-in) +The two approaches are mutually exclusive. Selection at startup: -Same as Path 1 but **without** the early-exit on the first blinded response — SSV waits for all multi-BN responses until the slot-relative `ProposalSoftDeadline`, then returns the highest-scored bid across all BNs. Useful only when multiple BNs may produce meaningfully different bids worth cross-comparing. +``` +if ProposerDelay > 0 || ProposalSoftTimeout is set: + -> legacy approach (see Appendix A) +elif ProposalSoftDeadline is set: + -> new approach with cross-BN bid scoring +else: + -> new approach (default behavior) +``` -To enable, set `ProposalSoftDeadline` (`eth2:` block in YAML, or `WITH_PROPOSAL_SOFT_DEADLINE` env var) to match your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning because the worst-case 2-round QBFT scenario can no longer fit within the slot (round 1 must succeed for the slot). +Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one approach. -## Appendix A — Path 0 (`ProposerDelay`, legacy approach) +## Appendix A — Legacy `ProposerDelay` approach `ProposerDelay` remains supported. It is the right tool when: - Your PBS does not support timing games (mev-boost < v1.11, or any PBS without the feature). From 4ebcbd8c04d087e69bc92cd6cab1709a77d5ec0b Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 11:13:16 +0300 Subject: [PATCH 26/37] address review: drop Path 0/1/2 labels; tighten dispatch; add scoring test Drop "path 0/1/2" terminology throughout code/comments/tests to match the docs framing in MEV_CONSIDERATIONS.md. Rename proposer_paths_test.go to proposer_path_dispatch_test.go. config.example.yaml: warn threshold 1800ms -> 1100ms (matches SafeMax); note that ProposalSoftDeadline=1000ms is not a no-op. options.go: document MaxProposalSoftDeadline=3600ms rationale. Block-fetch dispatcher in GetBeaconBlock: drop case+fallthrough; explicit case for safe, default returns an error for unknown paths. cli/operator: scope-of-validation comment on the legacy switch arm; split the SafeMax startup warning into clauses. Existing 'races multiple clients' test now exercises the multi-BN logic again - switch to dynamic response generation with a future slot so the safe path's slot-relative deadline doesn't fire before the collection loop starts. Add TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins covering the MEV-optimized path's defining behavior. Extract writeProposalHeaders helper and plumb optional ExecutionValue header through the test server. --- beacon/goclient/goclient.go | 6 +- beacon/goclient/options.go | 39 +++++++----- beacon/goclient/proposer.go | 26 ++++---- ...test.go => proposer_path_dispatch_test.go} | 63 +++++++++++++++---- beacon/goclient/proposer_test.go | 54 ++++++++++------ cli/operator/node.go | 14 +++-- config/config.example.yaml | 9 ++- 7 files changed, 139 insertions(+), 72 deletions(-) rename beacon/goclient/{proposer_paths_test.go => proposer_path_dispatch_test.go} (73%) diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index ae008728da..8a1003fb50 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -135,12 +135,12 @@ type GoClient struct { weightedAttestationDataSoftTimeout time.Duration weightedAttestationDataHardTimeout time.Duration - // proposalSoftTimeout is the legacy (path 0) collection-period timeout used by + // proposalSoftTimeout is the legacy collection-period timeout used by // getProposalParallelLegacy. Other paths use proposalSoftDeadline instead. proposalSoftTimeout time.Duration - // proposalSoftDeadline is the slot-relative deadline (ms into slot) for paths 1 - // and 2. See docs/MEV_CONSIDERATIONS.md. + // proposalSoftDeadline is the slot-relative deadline (ms into slot) for the safe + // and MEV-optimized paths. See docs/MEV_CONSIDERATIONS.md. proposalSoftDeadline time.Duration // blockFetchPath selects which getProposalParallel* variant GetBeaconBlock diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 25484ee0b5..58923bf2f5 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -22,16 +22,15 @@ const ( type BlockFetchPath int const ( - // BlockFetchPathSafe — path 1 (default). Multi-BN parallel fetch with early-exit on + // BlockFetchPathSafe is the default. Multi-BN parallel fetch with early-exit on // first blinded response; fallback at slot-relative ProposalSoftDeadline (default 1000ms). BlockFetchPathSafe BlockFetchPath = iota - // BlockFetchPathLegacy — path 0. Preserves the original ProposerDelay / - // ProposalSoftTimeout behavior bit-for-bit; selected when an operator has set either - // of those legacy knobs. + // BlockFetchPathLegacy preserves the original ProposerDelay / ProposalSoftTimeout + // behavior bit-for-bit; selected when an operator has set either of those legacy knobs. BlockFetchPathLegacy - // BlockFetchPathMEVOptimized — path 2 (opt-in). Multi-BN parallel fetch without - // early-exit, returns the best-scored response collected by ProposalSoftDeadline. - // Selected when an operator sets ProposalSoftDeadline explicitly. + // BlockFetchPathMEVOptimized is opt-in. Multi-BN parallel fetch without early-exit, + // returns the best-scored response collected by ProposalSoftDeadline. Selected when an + // operator sets ProposalSoftDeadline explicitly. BlockFetchPathMEVOptimized ) @@ -61,7 +60,13 @@ const ( // MEV-optimized path (BNs won't have responded yet). MinProposalSoftDeadline = DefaultProposalSoftDeadline - // MaxProposalSoftDeadline is the hard upper bound for operator-set ProposalSoftDeadline values. + // MaxProposalSoftDeadline is the hard upper bound for operator-set ProposalSoftDeadline + // values. Intentionally loose — past the SafeMax warning threshold, the operator has + // already opted into "round 1 must succeed". This cap exists to accommodate exceptionally + // performant clusters that can complete the entire post-header pipeline (QBFT round 1 + + // signing + submission) in well under 350ms and want to capture as much of the slot's + // bid growth as possible. Operators in this regime should baseline their own latencies + // (see docs/MEV_CONSIDERATIONS.md "Tuning guidance") before going anywhere near the cap. MaxProposalSoftDeadline = 3600 * time.Millisecond // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT @@ -75,7 +80,7 @@ const ( SafeMaxProposalSoftDeadline = 1100 * time.Millisecond ) -// Path 0 (legacy) constants — preserved for backward-compat. +// Legacy-path constants — preserved for backward-compat. const ( defaultProposalSoftTimeout = 1800 * time.Millisecond minProposalSoftTimeout = 500 * time.Millisecond @@ -92,17 +97,17 @@ type Options struct { CommonTimeout time.Duration `yaml:"CommonTimeout" env:"WITH_COMMON_TIMEOUT" env-description:"Specifies the common timeout for network operations"` LongTimeout time.Duration `yaml:"LongTimeout" env:"WITH_LONG_TIMEOUT" env-description:"Specifies the long timeout for network operations"` - // ProposalSoftTimeout is the legacy (path 0) collection-period timeout in multi-BN - // parallel fetch. Setting this (or ProposerDelay) selects BlockFetchPathLegacy. - // New operators should prefer ProposalSoftDeadline (path 1 / path 2). See - // docs/MEV_CONSIDERATIONS.md. + // ProposalSoftTimeout is the legacy collection-period timeout in multi-BN parallel + // fetch. Setting this (or ProposerDelay) selects BlockFetchPathLegacy. New operators + // should prefer ProposalSoftDeadline. See docs/MEV_CONSIDERATIONS.md. ProposalSoftTimeout time.Duration `yaml:"ProposalSoftTimeout" env:"WITH_PROPOSAL_SOFT_TIMEOUT" env-description:"Legacy MEV configuration. Specifies the beacon proposal collection soft timeout (collection period for comparing proposals from multiple beacon nodes to select the most profitable one). Setting this opts the SSV node into the legacy block-fetch path; the recommended approach is to leave this unset and use ProposalSoftDeadline instead. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` // ProposalSoftDeadline is the slot-relative deadline (in ms-into-slot) for the - // multi-BN proposal-collection window used by paths 1 and 2. - // - Unset (zero) -> path 1 (safe), default deadline 1000ms. - // - Set explicitly -> path 2 (MEV-optimized), value must be in [1000ms, 3600ms]. - // Cannot be combined with ProposerDelay or ProposalSoftTimeout (path 0). + // multi-BN proposal-collection window used by the safe and MEV-optimized paths. + // - Unset (zero) -> safe path, default deadline 1000ms. + // - Set explicitly -> MEV-optimized path, value must be in [1000ms, 3600ms]. + // Cannot be combined with ProposerDelay or ProposalSoftTimeout (which select the + // legacy path). ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"WITH_PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the multi-BN proposal-collection window. Leave unset for the default safe path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 3600ms]). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` // BlockFetchPath is set by NewOptions from the determined path; not directly diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index 4b99e6a480..67dd2addb9 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -107,16 +107,16 @@ func (gc *GoClient) GetBeaconBlock( } } else { // For multiple clients, dispatch to the selected block-fetch path. - // See docs/MEV_CONSIDERATIONS.md for the three paths' semantics. + // See docs/MEV_CONSIDERATIONS.md for path semantics. switch gc.blockFetchPath { + case BlockFetchPathSafe: + beaconBlock, err = gc.getProposalParallelSafe(ctx, logger, slot, sig, graffiti) case BlockFetchPathLegacy: beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) case BlockFetchPathMEVOptimized: beaconBlock, err = gc.getProposalParallelMEVOptimized(ctx, logger, slot, sig, graffiti) - case BlockFetchPathSafe: - fallthrough default: - beaconBlock, err = gc.getProposalParallelSafe(ctx, logger, slot, sig, graffiti) + return nil, nil, fmt.Errorf("unknown block-fetch path %d", gc.blockFetchPath) } if err != nil { return nil, nil, err @@ -165,9 +165,9 @@ func (gc *GoClient) GetBeaconBlock( } } -// getProposalParallelLegacy implements path 0 (legacy) — preserved bit-for-bit from -// the pre-path-split code for backward-compat with operators using ProposerDelay / -// ProposalSoftTimeout. +// getProposalParallelLegacy implements the legacy block-fetch path — preserved +// bit-for-bit from the pre-path-split code for backward-compat with operators using +// ProposerDelay / ProposalSoftTimeout. // // Races all beacon nodes, collects proposals for a short relative-duration timeout // (gc.proposalSoftTimeout), and returns the best one according to our score function. @@ -327,7 +327,7 @@ type proposalFetchResult struct { // spawnProposalFetchers starts a goroutine per beacon-node client; each goroutine // fetches a proposal and writes its result to the returned channel. Used by the -// safe (path 1) and MEV-optimized (path 2) block-fetch implementations. +// safe and MEV-optimized block-fetch implementations. // // The channel is buffered to `len(gc.clients)` so each goroutine can write without // blocking even if the consumer has already returned. @@ -352,9 +352,9 @@ func (gc *GoClient) spawnProposalFetchers( } // waitForFirstValidProposal returns the first valid proposal received from the -// remaining in-flight fetchers. Used by paths 1 and 2 as the fallback after the -// soft-deadline collection window has elapsed without producing a usable best -// proposal. Bounded by the parent context's slot deadline. +// remaining in-flight fetchers. Used by the safe and MEV-optimized paths as the +// fallback after the soft-deadline collection window has elapsed without producing +// a usable best proposal. Bounded by the parent context's slot deadline. func (gc *GoClient) waitForFirstValidProposal( ctx context.Context, logger *zap.Logger, @@ -391,7 +391,7 @@ func (gc *GoClient) waitForFirstValidProposal( return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) } -// getProposalParallelSafe implements path 1 (safe, default). +// getProposalParallelSafe implements the safe (default) block-fetch path. // // Spawns a per-BN fetch in parallel; collects responses until the slot-relative // ProposalSoftDeadline fires (default 1000ms into slot). Early-exits on the first @@ -479,7 +479,7 @@ collect: return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) } -// getProposalParallelMEVOptimized implements path 2 (MEV-optimized, opt-in). +// getProposalParallelMEVOptimized implements the MEV-optimized (opt-in) block-fetch path. // // Spawns a per-BN fetch in parallel; collects responses until the slot-relative // ProposalSoftDeadline fires. **Does not** early-exit on the first blinded response; diff --git a/beacon/goclient/proposer_paths_test.go b/beacon/goclient/proposer_path_dispatch_test.go similarity index 73% rename from beacon/goclient/proposer_paths_test.go rename to beacon/goclient/proposer_path_dispatch_test.go index 8610585f4b..2069381eee 100644 --- a/beacon/goclient/proposer_paths_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -2,6 +2,7 @@ package goclient import ( "context" + "math/big" "testing" "time" @@ -14,7 +15,7 @@ import ( ) // Tests for the block-fetch path dispatch (BlockFetchPathSafe / Legacy / MEVOptimized). -// See docs/MEV_CONSIDERATIONS.md for the three-path model. +// See docs/MEV_CONSIDERATIONS.md for path semantics. // TestNew_StoresBlockFetchPath verifies that the selected path and its associated // timing field (proposalSoftDeadline / proposalSoftTimeout) get propagated from @@ -56,11 +57,11 @@ func TestNew_StoresBlockFetchPath(t *testing.T) { } } -// TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded verifies the safe path's +// TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded verifies the safe path's // early-exit-on-first-blinded behavior. With one fast and one slow BN both returning // blinded proposals, the safe path should return quickly after the fast BN responds, // without waiting for the slow one. -func TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded(t *testing.T) { +func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 10 * time.Millisecond, BlindedProposal: true, @@ -86,17 +87,17 @@ func TestGetBeaconBlock_MultiBN_Path1_EarlyExitOnBlinded(t *testing.T) { elapsed := time.Since(start) require.NoError(t, err) - // Path 1 should early-exit on BN1's blinded response (~10ms) and NOT wait for + // Safe path should early-exit on BN1's blinded response (~10ms) and NOT wait for // BN2 (~500ms). A generous 250ms ceiling tolerates HTTP / goroutine overhead. assert.Less(t, elapsed, 250*time.Millisecond, - "Path 1 should early-exit on first blinded; took %v", elapsed) + "safe path should early-exit on first blinded; took %v", elapsed) } -// TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit verifies that the MEV-optimized -// path does NOT early-exit on the first blinded response — it keeps collecting -// until all BNs respond (or the soft deadline fires). With the same setup as the -// safe-path test, path 2 should wait for the slow BN. -func TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit(t *testing.T) { +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit verifies that the MEV-optimized +// path does NOT early-exit on the first blinded response — it keeps collecting until all +// BNs respond (or the soft deadline fires). With the same setup as the safe-path test, +// the MEV-optimized path should wait for the slow BN. +func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 10 * time.Millisecond, BlindedProposal: true, @@ -119,10 +120,46 @@ func TestGetBeaconBlock_MultiBN_Path2_NoEarlyExit(t *testing.T) { elapsed := time.Since(start) require.NoError(t, err) - // Path 2 should NOT early-exit; it waits for BN2's response at ~500ms before - // returning the best-scored proposal. The 400ms floor tolerates clock jitter. + // MEV-optimized path should NOT early-exit; it waits for BN2's response at ~500ms + // before returning the best-scored proposal. The 400ms floor tolerates clock jitter. assert.GreaterOrEqual(t, elapsed, 400*time.Millisecond, - "Path 2 should wait for the slower BN; took %v", elapsed) + "MEV-optimized path should wait for the slower BN; took %v", elapsed) +} + +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins verifies that +// when multiple BNs return blinded proposals within the collection window, the +// MEV-optimized path selects the one with the highest scoreProposal value (sum of +// ConsensusValue and ExecutionValue) rather than the first-arriving one. BN1 returns +// a fast low-value blinded; BN2 returns a slow high-value blinded — the function must +// return BN2's proposal. +func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + ExecutionValue: big.NewInt(1_000_000), // low bid + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 300 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + ExecutionValue: big.NewInt(5_000_000), // high bid (must win) + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + require.NoError(t, err) + require.NotNil(t, versionedProposal) + + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllTwos(), actualFeeRecipient, + "MEV-optimized path should select the higher-value blinded (BN2's), not the first-arriving (BN1's)") } // TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid verifies that diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index 6dce0029c2..98047f509f 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "net/http" "net/http/httptest" "strconv" @@ -53,6 +54,10 @@ type beaconProposalServerOptions struct { FeeRecipient bellatrix.ExecutionAddress // Use blinded proposal BlindedProposal bool + // Optional Eth-Execution-Payload-Value header value; left nil ⇒ header not set + // (go-eth2-client defaults ExecutionValue to 0). Used by tests that need to + // influence scoreProposal across multiple BN responses. + ExecutionValue *big.Int } // Creates a mock beacon server for proposal testing @@ -90,11 +95,7 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption // Return custom response if provided if len(options.ProposalResponse) > 0 { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Eth-Consensus-Version", "electra") - if options.BlindedProposal { - w.Header().Set("Eth-Execution-Payload-Blinded", "true") - } + writeProposalHeaders(w, options) if _, err := w.Write(options.ProposalResponse); err != nil { w.WriteHeader(http.StatusInternalServerError) } @@ -103,11 +104,7 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption // Generate response dynamically (this should not cause races since each server has its own goroutine) proposalResp := createProposalResponseSafe(phase0.Slot(slot), options.FeeRecipient, options.BlindedProposal) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Eth-Consensus-Version", "electra") - if options.BlindedProposal { - w.Header().Set("Eth-Execution-Payload-Blinded", "true") - } + writeProposalHeaders(w, options) if _, err := w.Write(proposalResp); err != nil { w.WriteHeader(http.StatusInternalServerError) } @@ -128,6 +125,21 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption return server, serverGotRequests } +// writeProposalHeaders sets the response headers that go-eth2-client parses to +// reconstruct VersionedProposal — Eth-Consensus-Version, the blinded flag, and +// optionally Eth-Execution-Payload-Value when the test wants to influence +// scoreProposal across multiple BN responses. +func writeProposalHeaders(w http.ResponseWriter, options beaconProposalServerOptions) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "electra") + if options.BlindedProposal { + w.Header().Set("Eth-Execution-Payload-Blinded", "true") + } + if options.ExecutionValue != nil { + w.Header().Set("Eth-Execution-Payload-Value", options.ExecutionValue.String()) + } +} + // spectestingMu serializes mutations on the shared ssv-spec testing fixtures. // TestingBlindedBeaconBlockV / TestingBeaconBlockV return wrappers that point at // cached block structs; without this lock, concurrent test-server goroutines @@ -399,26 +411,25 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { feeRecipient2 := bellatrix.ExecutionAddress{0x22} feeRecipient3 := bellatrix.ExecutionAddress{0x33} - // Pre-generate block responses to avoid race conditions - blockResponse1 := createProposalResponseSafe(testSlot, feeRecipient1, false) - blockResponse2 := createProposalResponseSafe(testSlot, feeRecipient2, false) - blockResponse3 := createProposalResponseSafe(testSlot, feeRecipient3, false) - + // Responses are generated per-request from the URL slot (rather than + // pre-generated) so the safe path's slot-relative deadline can be set against + // a future slot below — pre-baking a fixed slot would trip go-eth2-client's + // "expected slot N" response check. server1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 500 * time.Millisecond, - ProposalResponse: blockResponse1, + FeeRecipient: feeRecipient1, }) defer server1.Close() server2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 50 * time.Millisecond, // Fastest - ProposalResponse: blockResponse2, + FeeRecipient: feeRecipient2, }) defer server2.Close() server3, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 1000 * time.Millisecond, - ProposalResponse: blockResponse3, + FeeRecipient: feeRecipient3, }) defer server3.Close() @@ -429,8 +440,13 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { graffiti := []byte(testGraffiti) randao := getTestRANDAO() + // Use a future slot so the safe path's slot-relative ProposalSoftDeadline doesn't + // fire before the collection loop starts — otherwise this test would exercise + // waitForFirstValidProposal instead of the multi-BN scoring/racing logic. + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + startTime := time.Now() - versionedProposal, marshaledBlk, err := client.GetBeaconBlock(t.Context(), testSlot, graffiti, randao) + versionedProposal, marshaledBlk, err := client.GetBeaconBlock(t.Context(), slot, graffiti, randao) elapsed := time.Since(startTime) require.NoError(t, err) diff --git a/cli/operator/node.go b/cli/operator/node.go index 43f376889f..b563ddf0aa 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -191,9 +191,8 @@ var StartNodeCmd = &cobra.Command{ logger.Fatal("could not setup network", zap.Error(err)) } - // Determine the block-fetch path from operator-provided config before - // NewOptions applies any defaults. See docs/MEV_CONSIDERATIONS.md for the - // three-path model. + // Determine the block-fetch path from operator-provided config before NewOptions + // applies any defaults. See docs/MEV_CONSIDERATIONS.md. blockFetchPath, err := goclient.DetermineBlockFetchPath(cfg.ConsensusClient, cfg.ProposerDelay) if err != nil { logger.Fatal("invalid block-fetch path configuration", zap.Error(err)) @@ -201,6 +200,9 @@ var StartNodeCmd = &cobra.Command{ switch blockFetchPath { case goclient.BlockFetchPathLegacy: + // validateProposerDelayConfig is scoped to the legacy path because the + // dangerous-delay check is only meaningful when ProposerDelay > 0, and a + // non-zero ProposerDelay is precisely what selects this path. if err := validateProposerDelayConfig(logger); err != nil { logger.Fatal("invalid ProposerDelay configuration", zap.Error(err)) } @@ -210,7 +212,11 @@ var StartNodeCmd = &cobra.Command{ logger.Fatal("invalid ProposalSoftDeadline configuration", zap.Error(err)) } if cfg.ConsensusClient.ProposalSoftDeadline > goclient.SafeMaxProposalSoftDeadline { - logger.Warn("ProposalSoftDeadline exceeds the safe-max threshold for the worst-case 2-round QBFT scenario — round-2 fallback will not fit within the slot deadline. The slot will be missed whenever round 1 fails. This is an explicit 'round 1 must succeed' configuration.", + logger.Warn( + "ProposalSoftDeadline exceeds the safe-max threshold: "+ + "round-2 QBFT fallback will not fit within the slot deadline, "+ + "so the slot is missed whenever round 1 fails "+ + "(this is an explicit 'round 1 must succeed' configuration).", zap.Int64("proposal_soft_deadline_ms", cfg.ConsensusClient.ProposalSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", goclient.SafeMaxProposalSoftDeadline.Milliseconds())) } diff --git a/config/config.example.yaml b/config/config.example.yaml index 595fe32cc6..4baa71b6e2 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,16 +19,19 @@ eth2: # HTTP URL of the Beacon node to connect to. BeaconNodeAddr: http://example.url:5052 - # Block-fetch path tuning. The SSV node selects one of three paths at startup based on - # the settings below; see docs/MEV_CONSIDERATIONS.md for the full model. + # Block-fetch tuning. The SSV node selects between the safe (default), MEV-optimized, + # and legacy paths at startup based on the settings below; see + # docs/MEV_CONSIDERATIONS.md for the full model. # # ProposalSoftDeadline opts the SSV node into the MEV-optimized path: SSV waits for all # multi-BN responses until this slot-relative deadline (ms into slot) and returns the # highest-value bid received, without early-exiting on the first MEV block. Match this # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, - # 3600ms]; values above 1800ms emit a startup warning (round-2 QBFT fallback no longer + # 3600ms]; values above 1100ms emit a startup warning (round-2 QBFT fallback no longer # fits within the slot). Cannot be combined with ProposerDelay or ProposalSoftTimeout. # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1000ms). + # Note: setting ProposalSoftDeadline = 1000ms is *not* a no-op — it opts into the + # MEV-optimized path at the same numeric deadline the safe path uses by default. # ProposalSoftDeadline: 1100ms # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring From e169ea1baaf09ab20ea748530c30a88fbf2cd6e9 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 11:37:40 +0300 Subject: [PATCH 27/37] docs/MEV_CONSIDERATIONS: clarify PBS preference; drop tuning subsections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TL;DR: rephrase "doesn't consume slot budget" — the auction wait happens either way; the real PBS advantage is multi-polling per relay within a slot-relative window. Add note that ProposalSoftDeadline and the legacy ProposerDelay / ProposalSoftTimeout are mutually exclusive (pointer to Interaction section). PBS-side timing games section: replace the same misleading bullet with two accurate ones (multi-poll within the window; slot-relative cutoff → predictable QBFT start). Drop the round-relative-vs-slot-relative #2429 aside from the QBFT row. Drop the 'Mainnet vs testnet' and 'Iteration discipline' subsections. --- docs/MEV_CONSIDERATIONS.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index a767d4f5c2..203fa42296 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -4,10 +4,12 @@ To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. -If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because they don't consume SSV's slot budget for the auction wait. +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, set `ProposalSoftDeadline` on the SSV side to opt into cross-BN bid scoring (see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. +`ProposalSoftDeadline` and the legacy `ProposerDelay` / `ProposalSoftTimeout` select mutually-exclusive SSV-side block-fetch paths — combining them is rejected at startup. Operators relying on `ProposerDelay` can't also opt into cross-BN bid scoring; see [Interaction](#interaction). + ## Definitions and typical values The variables below name the stages of the SSV proposer-duty timeline. The values are typical/average for a healthy mainnet SSV cluster — real-world variance is significant, and operators should baseline their own latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)) before treating them as hard numbers. @@ -17,7 +19,7 @@ The variables below name the stages of the SSV proposer-duty timeline. The value | `RANDAO` | ~100ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | | `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-timing-games-recommended). | | `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | -| `QBFT` | ~2500ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~150ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~350ms successful round 2). The round timer is currently round-relative rather than slot-relative ([#2429](https://github.com/ssvlabs/ssv/issues/2429)); round-2 budget must be reserved even when round 1 typically succeeds. | +| `QBFT` | ~2500ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~150ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~350ms successful round 2). | | `PostConsensusSigning` | ~150ms | Operators reconstruct the validator BLS signature from partial signatures. | | `BlockSubmission` | ~200ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | @@ -40,9 +42,10 @@ Where `(auction window)` sits in the slot is what determines MEV capture — bid The PBS layer implements "timing games" — proactively polling relays at intervals defined in its own config, decoupling *when* the auction happens from *when* SSV asks for the block. SSV asks once and receives whatever bid the PBS has selected by its slot-relative cutoff. Preferred over `ProposerDelay` because: -- The SSV node doesn't sit idle during the auction wait — its slot clock doesn't advance, so QBFT round 1 isn't squeezed. +- The PBS polls each relay multiple times within the auction window (`target_first_request_ms` + `frequency_get_header_ms`), capturing higher-value bids than a single `getHeader` per relay. `ProposerDelay` + single `getHeader` gets one bid per relay at one point in time; PBS-side timing games sample several and keep the best. +- The auction cutoff is slot-relative (`late_in_slot_time_ms`), so QBFT round 1 starts at a predictable point in the slot. With `ProposerDelay`, QBFT starts at `ProposerDelay + variable relay-response time`, which makes the post-auction budget harder to size. - The PBS handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. -- Configuration is concentrated in the PBS rather than coordinated across SSV-side and PBS-side knobs. +- Configuration is concentrated in the PBS rather than split across SSV-side and PBS-side knobs. ### Configuration knobs @@ -200,17 +203,6 @@ Relevant logs and metrics: - `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending`, in `beacon/goclient/proposer.go`. Emitted per BN response in multi-BN setups. - `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. -### Mainnet vs testnet - -Testnet relays (Hoodi, Holesky, Sepolia) typically run reference or synthetic builders; their bid distributions don't reflect mainnet economics. Use testnet for plumbing validation (reliability, config parsing, no missed slots). For MEV-uplift quantification, use mainnet validator data + relay-data APIs. - -### Iteration discipline - -- Start with PBS defaults; tighten `late_in_slot_time_ms` toward later values gradually. -- Change one knob per iteration window. -- Monitor miss rate alongside bid-value distribution; back off if miss rate degrades. -- Allow enough observation time — proposals are sparse (roughly one per validator per month on mainnet), so small validator sets need long windows for statistical signal. - ## SSV-side block-fetch configuration SSV supports two mutually-exclusive approaches to multi-BN block-header fetch, selected at startup from your config. Single-BN setups bypass this entirely and use a direct BN call regardless of which knobs are set. From d0c5c0e15826e51ca099d021ccbd14949d3e5697 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 11:44:09 +0300 Subject: [PATCH 28/37] minor adjustments --- docs/MEV_CONSIDERATIONS.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 203fa42296..1fff838ac3 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -209,7 +209,7 @@ SSV supports two mutually-exclusive approaches to multi-BN block-header fetch, s ### New approach (recommended) -By default, the multi-BN fetch returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), SSV returns the best non-blinded response collected so far, falling through to wait for the first valid response if nothing usable arrived. +By default, the multi-BN fetch returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. Operators running multiple BNs who want SSV to compare bid *values* across BNs — instead of taking the first BN to respond with blinded — should set `ProposalSoftDeadline` explicitly. Setting it: - Disables the early-exit on first blinded; SSV waits for all multi-BN responses up to the deadline. @@ -255,12 +255,8 @@ RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + Bl Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (100 + 200 + 2500 + 150 + 200) = 850ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~150ms of headroom for variance. -The safety guard at startup is the looser 1000ms cap ([Appendix B](#appendix-b--safety-limits)): values between ~700ms and 1000ms are permitted without `AllowDangerousProposerDelay` but should be considered risky and not recommended. - We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. -## Appendix B — Safety limits - **The SSV node refuses to start if `ProposerDelay` is set higher than 1000ms without explicit confirmation.** If you attempt to use a `ProposerDelay` value higher than 1000ms, the node exits with an error message. If you understand the risks and want to proceed anyway, set the `AllowDangerousProposerDelay` flag: From 5e441cce82ee8e042cd6eaed8f6f771c15e32b23 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 11:51:02 +0300 Subject: [PATCH 29/37] more adjustments --- docs/MEV_CONSIDERATIONS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 1fff838ac3..0ef1058783 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -2,13 +2,13 @@ ## TL;DR -To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0`. +To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` - operators relying on `ProposerDelay` can't also opt into cross-BN bid scoring; see [Interaction](#interaction). If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, set `ProposalSoftDeadline` on the SSV side to opt into cross-BN bid scoring (see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. -`ProposalSoftDeadline` and the legacy `ProposerDelay` / `ProposalSoftTimeout` select mutually-exclusive SSV-side block-fetch paths — combining them is rejected at startup. Operators relying on `ProposerDelay` can't also opt into cross-BN bid scoring; see [Interaction](#interaction). +`ProposalSoftDeadline` and the legacy `ProposerDelay` / `ProposalSoftTimeout` select mutually-exclusive SSV-side block-fetch paths — combining them is rejected at startup. ## Definitions and typical values From 5376117980a1a0d153540b562ca2061427abc27e Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 11:56:38 +0300 Subject: [PATCH 30/37] address review: soften absolute "can/will not fit" phrasings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3600ms upper bound on ProposalSoftDeadline exists precisely so hyper-tuned clusters can still benefit from late auction windows. Several spots in the docs, options.go, the cli warning, and config.example.yaml were stating the round-2 fallback "can no longer fit" / "will not fit" / "is missed" as if it were universal; reality is "for typical clusters, may not fit / may be missed — depends on your QBFT and submission speed". Edits use "may not fit" / "may be missed" / "for typical clusters", adding an explicit note in a couple of places that clusters with measurably faster QBFT + submission can still leave room for round 2. The "round 1 must succeed" phrase is kept where it frames the operator's acknowledged trade-off (Example B, the cli warning's parenthetical, the options.go comment), since there it describes the configuration intent rather than asserting a hard fact about timing. --- beacon/goclient/options.go | 10 ++++++---- cli/operator/node.go | 5 +++-- config/config.example.yaml | 5 +++-- docs/MEV_CONSIDERATIONS.md | 10 +++++----- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 58923bf2f5..4ba77618b4 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -70,13 +70,15 @@ const ( MaxProposalSoftDeadline = 3600 * time.Millisecond // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT - // scenario no longer fits within the slot deadline (round 1 must succeed for the slot). - // Derived from the typical values in docs/MEV_CONSIDERATIONS.md: + // scenario may no longer fit within the slot deadline for clusters with typical + // latencies (round 1 effectively has to succeed). Derived from the typical values in + // docs/MEV_CONSIDERATIONS.md: // deadline + 50ms (BN→SSV transport) + 2500ms (QBFT worst-case 2-round) + // 150ms (PostConsensusSigning) + 200ms (BlockSubmission) <= 4000ms // => deadline <= 1100ms - // Values above this trigger a startup warning but are still permitted (the operator - // is explicitly accepting "round 1 must succeed" — Example B is such a setup). + // Values above this trigger a startup warning but are still permitted — the operator + // is accepting that round 1 must succeed (Example B is such a setup). Clusters with + // measurably faster QBFT + submission may still leave room for round 2. SafeMaxProposalSoftDeadline = 1100 * time.Millisecond ) diff --git a/cli/operator/node.go b/cli/operator/node.go index b563ddf0aa..9435c9ac2d 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -214,8 +214,9 @@ var StartNodeCmd = &cobra.Command{ if cfg.ConsensusClient.ProposalSoftDeadline > goclient.SafeMaxProposalSoftDeadline { logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ - "round-2 QBFT fallback will not fit within the slot deadline, "+ - "so the slot is missed whenever round 1 fails "+ + "round-2 QBFT fallback may not fit within the slot deadline "+ + "for clusters with typical latencies, "+ + "so the slot may be missed when round 1 fails "+ "(this is an explicit 'round 1 must succeed' configuration).", zap.Int64("proposal_soft_deadline_ms", cfg.ConsensusClient.ProposalSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", goclient.SafeMaxProposalSoftDeadline.Milliseconds())) diff --git a/config/config.example.yaml b/config/config.example.yaml index 4baa71b6e2..24f2a1391d 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -27,8 +27,9 @@ eth2: # multi-BN responses until this slot-relative deadline (ms into slot) and returns the # highest-value bid received, without early-exiting on the first MEV block. Match this # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, - # 3600ms]; values above 1100ms emit a startup warning (round-2 QBFT fallback no longer - # fits within the slot). Cannot be combined with ProposerDelay or ProposalSoftTimeout. + # 3600ms]; values above 1100ms emit a startup warning (round-2 QBFT fallback may not + # fit within the slot for typical clusters). Cannot be combined with ProposerDelay or + # ProposalSoftTimeout. # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1000ms). # Note: setting ProposalSoftDeadline = 1000ms is *not* a no-op — it opts into the # MEV-optimized path at the same numeric deadline the safe path uses by default. diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 0ef1058783..936d6f8e74 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -128,11 +128,11 @@ eth2: ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -Pushes the PBS-side cutoff to `1800ms` — well past the ~1100ms threshold where round-2 QBFT fallback stops fitting within the slot. This explicitly accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth. Last relay poll at ~1600ms; header at SSV by ~1850ms. +Pushes the PBS-side cutoff to `1800ms` — well past the ~1100ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1850ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. -Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2850ms required for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot is missed. Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2850ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -183,8 +183,8 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback must fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget required is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback can no longer complete within the slot deadline. For operators who set `ProposalSoftDeadline` (opting into cross-BN bid scoring), match it to `late_in_slot_time_ms + ~50ms`. -- **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot is missed. Example B (1800ms) sits in this regime. +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. For operators who set `ProposalSoftDeadline` (opting into cross-BN bid scoring), match it to `late_in_slot_time_ms + ~50ms`. +- **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. ### What to measure first @@ -215,7 +215,7 @@ Operators running multiple BNs who want SSV to compare bid *values* across BNs - Disables the early-exit on first blinded; SSV waits for all multi-BN responses up to the deadline. - Returns the highest-scored bid across all BNs. -Match the value to your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — the worst-case 2-round QBFT scenario can no longer fit within the slot (round 1 must succeed for the slot). +Match the value to your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. ### Legacy approach From a4b8d5c25c2eddecc01465563ef968217d5d2b1b Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 12:09:25 +0300 Subject: [PATCH 31/37] docs/MEV_CONSIDERATIONS: merge "What to measure first" + "SSV telemetry" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single section "What to measure" grouped by data source (SSV side / PBS side / end-to-end). Drop implementation-detail references (function names like measurements.PreConsensusTime, file paths like protocol/v2/ssv/runner/, beacon/goclient/proposer.go) — operator-facing doc should describe the signals, not where they live in the code. --- docs/MEV_CONSIDERATIONS.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 936d6f8e74..92b1b2cae1 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -187,21 +187,22 @@ Bid value grows through the slot, so the auction cutoff should be as late as pos - **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. -### What to measure first +### What to measure -- **RANDAO completion time** — `measurements.PreConsensusTime()` in `protocol/v2/ssv/runner/`. Visible on Grafana charts if exported. -- **BN → PBS RTT** — typically same machine, well under 10ms. Visible in PBS logs. -- **Per-relay RTT distribution (p50/p95/p99)** — PBSes log this on every `getHeader` call. -- **QBFT round-1 completion distribution** — `measurements.ConsensusTime()` in `protocol/v2/ssv/runner/`. Visible on Grafana charts if exported. -- **Submission round-trip** — from `SubmitBeaconBlock` in `beacon/goclient/proposer.go` through the relay payload-reveal step. +Useful signals to baseline before tuning, by data source: -### SSV telemetry +**On the SSV side** — metrics on Grafana (if export is enabled) and structured logs: +- **RANDAO completion time** — pre-consensus duration. +- **QBFT round-1 completion distribution** — consensus duration. +- `"got beacon block proposal"` log with `took` duration. +- `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending` fields — emitted per BN response in multi-BN setups. +- `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. -Relevant logs and metrics: +**On the PBS side** — PBS logs: +- BN → PBS RTT — typically same machine, well under 10ms. +- Per-relay RTT distribution (p50/p95/p99) — logged per `getHeader` call. -- `"got beacon block proposal"` log with `took` duration, in `protocol/v2/ssv/runner/proposer.go`. -- `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending`, in `beacon/goclient/proposer.go`. Emitted per BN response in multi-BN setups. -- `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. +**End-to-end** — submission round-trip from the signed block leaving SSV through the relay payload-reveal step (visible from PBS and relay logs). ## SSV-side block-fetch configuration From 25180c5bcf8bd74d2dc01b9ca06f503629849797 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 19 May 2026 12:30:33 +0300 Subject: [PATCH 32/37] docs/MEV_CONSIDERATIONS: consolidate multi-BN guidance into one section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename "SSV-side block-fetch configuration" to "Multi-BN setup" and restructure so the recommended action (set ProposalSoftDeadline) leads; add a blockquote at the top telling single-BN operators to skip the section entirely. Rewrite the TL;DR multi-BN paragraph as a direct instruction: ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms transport with a pointer to the new section. Drop the sprinkled multi-BN/cross-BN parentheticals in: - TL;DR PBS-side paragraph (replace "cross-BN bid scoring" wording and update link) - Example A and Example B SSV-side prefaces (now read "multi-BN setups only — see Multi-BN setup; single-BN operators skip this") - Tuning bullet (Round-2 fallback) — the "For operators who set ProposalSoftDeadline..." parenthetical is redundant now that the Multi-BN setup section leads with that instruction - Interaction pseudocode comment (describe behavior instead of using the dropped "cross-BN bid scoring" term) --- docs/MEV_CONSIDERATIONS.md | 39 +++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 92b1b2cae1..d00f9194ab 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -2,11 +2,11 @@ ## TL;DR -To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` - operators relying on `ProposerDelay` can't also opt into cross-BN bid scoring; see [Interaction](#interaction). +To get the most out of MEV opportunities, configure **timing games on the PBS layer** — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the multi-BN bid scoring described in [Multi-BN setup](#multi-bn-setup). If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. -If you run **multiple Beacon nodes** and want SSV to cross-compare bids across them rather than taking the first BN's response, set `ProposalSoftDeadline` on the SSV side to opt into cross-BN bid scoring (see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)). Single-BN setups don't need this — they bypass the parallel-fetch logic entirely. +If you run **multiple Beacon nodes**, set `ProposalSoftDeadline` = your PBS `late_in_slot_time_ms` + ~50ms BN→SSV transport. See [Multi-BN setup](#multi-bn-setup) for details. Single-BN operators can skip that section entirely. `ProposalSoftDeadline` and the legacy `ProposerDelay` / `ProposalSoftTimeout` select mutually-exclusive SSV-side block-fetch paths — combining them is rejected at startup. @@ -120,7 +120,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (optional; recommended for multi-BN setups to enable cross-BN bid scoring — see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)): +**SSV-side** (multi-BN setups only — see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -169,7 +169,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (recommended for multi-BN setups; 1850ms triggers a startup warning since it exceeds the ~1100ms safe-max for the worst-case 2-round QBFT scenario — see [SSV-side block-fetch configuration](#ssv-side-block-fetch-configuration)): +**SSV-side** (multi-BN setups only — 1850ms triggers the safe-max startup warning since it exceeds the ~1100ms threshold; see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -183,7 +183,7 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. For operators who set `ProposalSoftDeadline` (opting into cross-BN bid scoring), match it to `late_in_slot_time_ms + ~50ms`. +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. - **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. @@ -204,35 +204,40 @@ Useful signals to baseline before tuning, by data source: **End-to-end** — submission round-trip from the signed block leaving SSV through the relay payload-reveal step (visible from PBS and relay logs). -## SSV-side block-fetch configuration +## Multi-BN setup -SSV supports two mutually-exclusive approaches to multi-BN block-header fetch, selected at startup from your config. Single-BN setups bypass this entirely and use a direct BN call regardless of which knobs are set. +> Single-BN operators can skip this section — SSV bypasses parallel fetch entirely and calls the single BN directly regardless of which knobs are set below. -### New approach (recommended) +With multiple Beacon nodes, SSV races them in parallel for the block proposal. The recommended action is to set `ProposalSoftDeadline`: -By default, the multi-BN fetch returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response is received by the slot-relative `ProposalSoftDeadline` (default 1000ms), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. +```yaml +eth2: + ProposalSoftDeadline: +``` + +This makes SSV wait for all BN responses up to that slot-relative deadline and return the highest-scored bid. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. + +### Default behavior (if you don't set `ProposalSoftDeadline`) -Operators running multiple BNs who want SSV to compare bid *values* across BNs — instead of taking the first BN to respond with blinded — should set `ProposalSoftDeadline` explicitly. Setting it: -- Disables the early-exit on first blinded; SSV waits for all multi-BN responses up to the deadline. -- Returns the highest-scored bid across all BNs. +SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response arrives by the default slot-relative deadline (1000ms), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. -Match the value to your PBS `late_in_slot_time_ms + ~50ms BN→SSV transport`. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. +This default is faster but doesn't compare bid *values* across BNs — the first BN to return blinded wins regardless of bid quality. Fine for multi-BN setups run primarily for redundancy. ### Legacy approach -Setting `ProposerDelay` or `ProposalSoftTimeout` selects the legacy block-fetch behavior — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). The legacy behavior is preserved bit-for-bit; SSV logs a startup warning suggesting migration to the new approach. +Setting `ProposerDelay` or `ProposalSoftTimeout` selects legacy block-fetch behavior (preserved bit-for-bit) — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). SSV logs a startup warning suggesting migration. ### Interaction -The two approaches are mutually exclusive. Selection at startup: +The approaches are mutually exclusive. Selection at startup: ``` if ProposerDelay > 0 || ProposalSoftTimeout is set: -> legacy approach (see Appendix A) elif ProposalSoftDeadline is set: - -> new approach with cross-BN bid scoring + -> new approach (waits for all BN responses, picks highest-scored) else: - -> new approach (default behavior) + -> new approach default (returns first blinded response) ``` Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one approach. From fd8846394d2902afdc0ae26c272b66be8042ef74 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 20 May 2026 12:33:08 +0300 Subject: [PATCH 33/37] tighten typical-value estimates; raise DefaultProposalSoftDeadline to 1450ms docs/MEV_CONSIDERATIONS: - RANDAO 100 -> 50, QBFTRoundChange 150 -> 100, QBFTRound2Time 350 -> 250 (QBFT worst-case 2-round: 2500 -> 2350), PostConsensusSigning 150 -> 50, BlockSubmission 200 -> 100. - Re-derive thresholds throughout: post-cutoff budget needed 2850ms -> 2500ms, safe-max threshold 1100ms -> 1450ms. - Appendix A theoretical ProposerDelay max 850ms -> 1250ms (recommended ~700ms unchanged; headroom grows from ~150ms to ~550ms). beacon/goclient/options.go: - SafeMaxProposalSoftDeadline 1100ms -> 1450ms with new derivation in the comment (the "largest safest" deadline under the tightened estimates). - DefaultProposalSoftDeadline now equals SafeMax (was 1000ms) - the safe path defaults to the largest deadline that still fits worst-case 2-round QBFT. - MinProposalSoftDeadline decoupled from Default and held at 1000ms - Min exists to floor the BN-response window, not to mirror Default. Lets operators opt into MEV-optimized with a tighter-than-default deadline (e.g., to match an early PBS cutoff like Example A's 1100ms). Tests + config.example.yaml updated to track the new thresholds. --- beacon/goclient/options.go | 30 +++++++++++++++++------------- beacon/goclient/options_test.go | 5 +++-- config/config.example.yaml | 2 +- docs/MEV_CONSIDERATIONS.md | 22 +++++++++++----------- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 4ba77618b4..d4fcfdd34f 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -50,15 +50,19 @@ func (p BlockFetchPath) String() string { // ProposalSoftDeadline bounds and defaults. Values are slot-relative (measured from slot start). const ( - // DefaultProposalSoftDeadline is the default deadline for the safe path. Picked so - // the worst-case 2-round QBFT scenario still fits within the 4000ms slot deadline: - // 1000ms (deadline) + 2500ms (QBFT worst-case 2-round) + 150ms (signing) + 200ms (submission) = 3850ms - DefaultProposalSoftDeadline = 1000 * time.Millisecond - - // MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline values. - // Matches DefaultProposalSoftDeadline — going lower defeats the purpose of opting into the - // MEV-optimized path (BNs won't have responded yet). - MinProposalSoftDeadline = DefaultProposalSoftDeadline + // DefaultProposalSoftDeadline is the default deadline used by the safe path when the + // operator hasn't set ProposalSoftDeadline. It's the largest value that still fits + // the worst-case 2-round QBFT scenario within the 4000ms slot deadline for clusters + // with typical latencies — equal to SafeMaxProposalSoftDeadline. See its derivation + // below. + DefaultProposalSoftDeadline = SafeMaxProposalSoftDeadline + + // MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline + // values. Decoupled from DefaultProposalSoftDeadline so operators can opt into the + // MEV-optimized path with a tighter window than the safe-path default if they want + // (e.g., to match an early PBS cutoff). Set at 1000ms — below this, the BN response + // window becomes too tight for meaningful bid collection across BNs. + MinProposalSoftDeadline = 1000 * time.Millisecond // MaxProposalSoftDeadline is the hard upper bound for operator-set ProposalSoftDeadline // values. Intentionally loose — past the SafeMax warning threshold, the operator has @@ -73,13 +77,13 @@ const ( // scenario may no longer fit within the slot deadline for clusters with typical // latencies (round 1 effectively has to succeed). Derived from the typical values in // docs/MEV_CONSIDERATIONS.md: - // deadline + 50ms (BN→SSV transport) + 2500ms (QBFT worst-case 2-round) + - // 150ms (PostConsensusSigning) + 200ms (BlockSubmission) <= 4000ms - // => deadline <= 1100ms + // deadline + 50ms (BN→SSV transport) + 2350ms (QBFT worst-case 2-round) + + // 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms + // => deadline <= 1450ms // Values above this trigger a startup warning but are still permitted — the operator // is accepting that round 1 must succeed (Example B is such a setup). Clusters with // measurably faster QBFT + submission may still leave room for round 2. - SafeMaxProposalSoftDeadline = 1100 * time.Millisecond + SafeMaxProposalSoftDeadline = 1450 * time.Millisecond ) // Legacy-path constants — preserved for backward-compat. diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go index 1e1e19a6b5..6ab8e9c69b 100644 --- a/beacon/goclient/options_test.go +++ b/beacon/goclient/options_test.go @@ -106,7 +106,8 @@ func TestValidateProposalSoftDeadline(t *testing.T) { }{ {name: "at minimum (1000ms) -> ok", value: 1000 * time.Millisecond, wantErr: false}, {name: "below minimum (999ms) -> error", value: 999 * time.Millisecond, wantErr: true}, - {name: "at safe max (1100ms) -> ok (warn handled externally)", value: 1100 * time.Millisecond, wantErr: false}, + {name: "below safe max (1100ms) -> ok", value: 1100 * time.Millisecond, wantErr: false}, + {name: "at safe max (1450ms) -> ok (warn handled externally)", value: 1450 * time.Millisecond, wantErr: false}, {name: "above safe max but below hard max (2500ms) -> ok", value: 2500 * time.Millisecond, wantErr: false}, {name: "at hard max (3600ms) -> ok", value: 3600 * time.Millisecond, wantErr: false}, {name: "above hard max (3601ms) -> error", value: 3601 * time.Millisecond, wantErr: true}, @@ -128,7 +129,7 @@ func TestValidateProposalSoftDeadline(t *testing.T) { } func TestNewOptions_PathDefaulting(t *testing.T) { - t.Run("safe path defaults ProposalSoftDeadline to 1000ms", func(t *testing.T) { + t.Run("safe path defaults ProposalSoftDeadline to the largest safest value", func(t *testing.T) { base := Options{BeaconNodeAddr: "http://localhost:5052"} opts, err := NewOptions(base, 0, BlockFetchPathSafe) require.NoError(t, err) diff --git a/config/config.example.yaml b/config/config.example.yaml index 24f2a1391d..93ece90efa 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -27,7 +27,7 @@ eth2: # multi-BN responses until this slot-relative deadline (ms into slot) and returns the # highest-value bid received, without early-exiting on the first MEV block. Match this # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, - # 3600ms]; values above 1100ms emit a startup warning (round-2 QBFT fallback may not + # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not # fit within the slot for typical clusters). Cannot be combined with ProposerDelay or # ProposalSoftTimeout. # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1000ms). diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index d00f9194ab..d1fd7eca57 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -16,12 +16,12 @@ The variables below name the stages of the SSV proposer-duty timeline. The value | Variable | Typical | Description | |---|---|---| -| `RANDAO` | ~100ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | +| `RANDAO` | ~50ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | | `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-timing-games-recommended). | | `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | -| `QBFT` | ~2500ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~150ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~350ms successful round 2). | -| `PostConsensusSigning` | ~150ms | Operators reconstruct the validator BLS signature from partial signatures. | -| `BlockSubmission` | ~200ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | +| `QBFT` | ~2350ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~100ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~250ms successful round 2). | +| `PostConsensusSigning` | ~50ms | Operators reconstruct the validator BLS signature from partial signatures. | +| `BlockSubmission` | ~100ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | The Ethereum slot-propagation deadline is **4000ms** after slot start. @@ -128,11 +128,11 @@ eth2: ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -Pushes the PBS-side cutoff to `1800ms` — well past the ~1100ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1850ms. +Pushes the PBS-side cutoff to `1800ms` — past the ~1450ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1850ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. -Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2850ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2500ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -169,7 +169,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (multi-BN setups only — 1850ms triggers the safe-max startup warning since it exceeds the ~1100ms threshold; see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): +**SSV-side** (multi-BN setups only — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -183,8 +183,8 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2850ms, resolving to `late_in_slot_time_ms ≲ ~1100ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. -- **Cutoffs above ~1100ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, resolving to `late_in_slot_time_ms ≲ ~1450ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. +- **Cutoffs above ~1450ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. ### What to measure @@ -215,7 +215,7 @@ eth2: ProposalSoftDeadline: ``` -This makes SSV wait for all BN responses up to that slot-relative deadline and return the highest-scored bid. Valid range `[1000ms, 3600ms]`; values above ~1100ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. +This makes SSV wait for all BN responses up to that slot-relative deadline and return the highest-scored bid. Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. ### Default behavior (if you don't set `ProposalSoftDeadline`) @@ -259,7 +259,7 @@ With `ProposerDelay` active, the slot-budget equation becomes: RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` -Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (100 + 200 + 2500 + 150 + 200) = 850ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~150ms of headroom for variance. +Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (50 + 200 + 2350 + 50 + 100) = 1250ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~550ms of headroom for variance. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. From 24a00bd9477bc404890ca64a48fe15d2b624d083 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 20 May 2026 18:18:42 +0300 Subject: [PATCH 34/37] address review sweep: fix stale defaults; reorder const block; loosen variance heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/MEV_CONSIDERATIONS.md "Default behavior" subsection still cited "(default 1000ms)" — update to "(1450ms — the largest safest deadline for typical clusters)" with a pointer to the Tuning guidance section. - Tuning bullet variance heuristic "much beyond ~2500ms" → "~3000ms" to align with the tightened typical values (more headroom for round-1-only cutoffs now that post-deadline budget shrank from ~2850ms to ~2500ms). - beacon/goclient/options.go: reorder the ProposalSoftDeadline const block so SafeMaxProposalSoftDeadline is declared first and DefaultProposalSoftDeadline = SafeMaxProposalSoftDeadline references an already-declared constant. Functionally identical (Go const blocks permit forward references), but easier to read top-to-bottom. --- beacon/goclient/options.go | 31 +++++++++++++++---------------- docs/MEV_CONSIDERATIONS.md | 4 ++-- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index d4fcfdd34f..fe3df89c0e 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -50,11 +50,22 @@ func (p BlockFetchPath) String() string { // ProposalSoftDeadline bounds and defaults. Values are slot-relative (measured from slot start). const ( + // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT + // scenario may no longer fit within the slot deadline for clusters with typical + // latencies (round 1 effectively has to succeed). Derived from the typical values in + // docs/MEV_CONSIDERATIONS.md: + // deadline + 50ms (BN→SSV transport) + 2350ms (QBFT worst-case 2-round) + + // 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms + // => deadline <= 1450ms + // Values above this trigger a startup warning but are still permitted — the operator + // is accepting that round 1 must succeed (Example B is such a setup). Clusters with + // measurably faster QBFT + submission may still leave room for round 2. + SafeMaxProposalSoftDeadline = 1450 * time.Millisecond + // DefaultProposalSoftDeadline is the default deadline used by the safe path when the - // operator hasn't set ProposalSoftDeadline. It's the largest value that still fits - // the worst-case 2-round QBFT scenario within the 4000ms slot deadline for clusters - // with typical latencies — equal to SafeMaxProposalSoftDeadline. See its derivation - // below. + // operator hasn't set ProposalSoftDeadline. It equals SafeMaxProposalSoftDeadline — + // the largest value that still fits the worst-case 2-round QBFT scenario within the + // 4000ms slot deadline for clusters with typical latencies. DefaultProposalSoftDeadline = SafeMaxProposalSoftDeadline // MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline @@ -72,18 +83,6 @@ const ( // bid growth as possible. Operators in this regime should baseline their own latencies // (see docs/MEV_CONSIDERATIONS.md "Tuning guidance") before going anywhere near the cap. MaxProposalSoftDeadline = 3600 * time.Millisecond - - // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT - // scenario may no longer fit within the slot deadline for clusters with typical - // latencies (round 1 effectively has to succeed). Derived from the typical values in - // docs/MEV_CONSIDERATIONS.md: - // deadline + 50ms (BN→SSV transport) + 2350ms (QBFT worst-case 2-round) + - // 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms - // => deadline <= 1450ms - // Values above this trigger a startup warning but are still permitted — the operator - // is accepting that round 1 must succeed (Example B is such a setup). Clusters with - // measurably faster QBFT + submission may still leave room for round 2. - SafeMaxProposalSoftDeadline = 1450 * time.Millisecond ) // Legacy-path constants — preserved for backward-compat. diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index d1fd7eca57..26537ea9ac 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -185,7 +185,7 @@ Bid value grows through the slot, so the auction cutoff should be as late as pos - **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, resolving to `late_in_slot_time_ms ≲ ~1450ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. - **Cutoffs above ~1450ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. -- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~2500ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. +- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~3000ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. ### What to measure @@ -219,7 +219,7 @@ This makes SSV wait for all BN responses up to that slot-relative deadline and r ### Default behavior (if you don't set `ProposalSoftDeadline`) -SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response arrives by the default slot-relative deadline (1000ms), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. +SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response arrives by the default slot-relative deadline (1450ms — the largest safest deadline for typical clusters; see [Tuning guidance](#tuning-guidance--measurement-methodology)), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. This default is faster but doesn't compare bid *values* across BNs — the first BN to return blinded wins regardless of bid quality. Fine for multi-BN setups run primarily for redundancy. From 1939b0cfa50681cdfd235aee1fdee88b8588d65c Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 29 May 2026 15:41:51 +0300 Subject: [PATCH 35/37] address review: consolidate parallel fetch + clean stale defaults - Consolidate getProposalParallelSafe + getProposalParallelMEVOptimized into getProposalParallelByDeadline(..., earlyExitOnBlinded bool). Behavior unchanged; shared helper's doc folds in the in-flight-goroutine cleanup note. - proposer_path_dispatch_test.go: widen timing bounds (250->350ms upper for EarlyExitOnBlinded; 400->450ms upper for SoftDeadlineFires) to reduce CI flake; add a fee-recipient identity assertion to the past-slot fallback test so the primary signal isn't elapsed-time-only. - Replace remaining "default 1000ms" with DefaultProposalSoftDeadline references (4 sites: BlockFetchPathSafe const doc, ProposalSoftDeadline struct field doc, NewOptions safe-path case, config.example.yaml). The default has been 1450ms since the typical-values tightening commit; these doc/yaml sites had been overlooked. - Document the defensive 'if options.ProposalSoftDeadline == 0' branch in NewOptions case BlockFetchPathSafe, the intentional strict '>' (not '>=') in the SafeMax warning, and that range validation via ValidateProposalSoftDeadline is the caller's responsibility (cli/operator runs it; NewOptions does not enforce it). - Tighten GoClient.blockFetchPath comment to describe the consolidated dispatch; drop leftover "path-0/path-2" terminology in the DetermineBlockFetchPath doc. --- beacon/goclient/goclient.go | 6 +- beacon/goclient/options.go | 30 +++-- beacon/goclient/proposer.go | 122 ++++-------------- .../goclient/proposer_path_dispatch_test.go | 24 +++- cli/operator/node.go | 3 + config/config.example.yaml | 7 +- 6 files changed, 76 insertions(+), 116 deletions(-) diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index 8a1003fb50..fcc585a9e4 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -143,8 +143,10 @@ type GoClient struct { // and MEV-optimized paths. See docs/MEV_CONSIDERATIONS.md. proposalSoftDeadline time.Duration - // blockFetchPath selects which getProposalParallel* variant GetBeaconBlock - // dispatches to in the multi-BN case. + // blockFetchPath selects the multi-BN block-fetch strategy GetBeaconBlock + // dispatches to: getProposalParallelLegacy for BlockFetchPathLegacy, or + // getProposalParallelByDeadline (with earlyExitOnBlinded set per path) for + // BlockFetchPathSafe and BlockFetchPathMEVOptimized. blockFetchPath BlockFetchPath // blockRootToSlotCache is used for attestation data scoring. When multiple Consensus clients are used, diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index fe3df89c0e..806c685f94 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -23,7 +23,8 @@ type BlockFetchPath int const ( // BlockFetchPathSafe is the default. Multi-BN parallel fetch with early-exit on - // first blinded response; fallback at slot-relative ProposalSoftDeadline (default 1000ms). + // first blinded response; fallback at slot-relative ProposalSoftDeadline + // (defaults to DefaultProposalSoftDeadline when the operator hasn't set it). BlockFetchPathSafe BlockFetchPath = iota // BlockFetchPathLegacy preserves the original ProposerDelay / ProposalSoftTimeout // behavior bit-for-bit; selected when an operator has set either of those legacy knobs. @@ -109,8 +110,9 @@ type Options struct { // ProposalSoftDeadline is the slot-relative deadline (in ms-into-slot) for the // multi-BN proposal-collection window used by the safe and MEV-optimized paths. - // - Unset (zero) -> safe path, default deadline 1000ms. - // - Set explicitly -> MEV-optimized path, value must be in [1000ms, 3600ms]. + // - Unset (zero) -> safe path, defaults to DefaultProposalSoftDeadline. + // - Set explicitly -> MEV-optimized path, value must be in + // [MinProposalSoftDeadline, MaxProposalSoftDeadline]. // Cannot be combined with ProposerDelay or ProposalSoftTimeout (which select the // legacy path). ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"WITH_PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the multi-BN proposal-collection window. Leave unset for the default safe path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 3600ms]). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` @@ -128,13 +130,13 @@ type Options struct { // // Returns an error when: // - any of the MEV-related duration knobs is negative; or -// - the config combines path-0 (legacy) knobs with the path-2 (MEV-optimized) -// ProposalSoftDeadline — operators must pick one. +// - the config combines legacy knobs (ProposerDelay / ProposalSoftTimeout) with +// the MEV-optimized ProposalSoftDeadline — operators must pick one. func DetermineBlockFetchPath(base Options, proposerDelay time.Duration) (BlockFetchPath, error) { // Negative values are nonsensical for any of these and would silently be // treated as "unset" by the `> 0` checks below — reject them upfront so the // operator gets a clear startup error instead of a confusing late-firing - // soft-deadline or skipped path-0 selection. + // soft-deadline or skipped legacy-path selection. if proposerDelay < 0 { return 0, fmt.Errorf("ProposerDelay must be non-negative, got %v", proposerDelay) } @@ -210,14 +212,24 @@ func NewOptions(base Options, proposerDelay time.Duration, path BlockFetchPath) } case BlockFetchPathSafe: - // Safe path: slot-relative deadline, default 1000ms. + // Safe path: slot-relative deadline, default DefaultProposalSoftDeadline. + // + // The == 0 check is defensive: in production, DetermineBlockFetchPath only + // routes ProposalSoftDeadline == 0 to the safe path (a non-zero value selects + // MEV-optimized), so this branch is always taken when path == safe. The check + // guards tests that construct Options directly and bypass DetermineBlockFetchPath. if options.ProposalSoftDeadline == 0 { options.ProposalSoftDeadline = DefaultProposalSoftDeadline } case BlockFetchPathMEVOptimized: - // MEV-optimized path: ProposalSoftDeadline must be set by the operator and - // validated upstream (ValidateProposalSoftDeadline). No defaults to apply. + // MEV-optimized path: ProposalSoftDeadline is set by the operator. No defaults + // to apply. + // + // Note: range validation via ValidateProposalSoftDeadline is the *caller's* + // responsibility — cli/operator/node.go runs it for production startup, but + // NewOptions does not enforce it. Tests that bypass the CLI should call + // ValidateProposalSoftDeadline themselves if they want the bounds check. } // Note: There is no hard timeout for proposals. The parent context from the diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index 67dd2addb9..b1a3d048ac 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -107,14 +107,15 @@ func (gc *GoClient) GetBeaconBlock( } } else { // For multiple clients, dispatch to the selected block-fetch path. - // See docs/MEV_CONSIDERATIONS.md for path semantics. + // Safe and MEV-optimized share an implementation differing only in whether + // to early-exit on the first blinded response. See docs/MEV_CONSIDERATIONS.md. switch gc.blockFetchPath { case BlockFetchPathSafe: - beaconBlock, err = gc.getProposalParallelSafe(ctx, logger, slot, sig, graffiti) + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, true /* earlyExitOnBlinded */) case BlockFetchPathLegacy: beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) case BlockFetchPathMEVOptimized: - beaconBlock, err = gc.getProposalParallelMEVOptimized(ctx, logger, slot, sig, graffiti) + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, false /* earlyExitOnBlinded */) default: return nil, nil, fmt.Errorf("unknown block-fetch path %d", gc.blockFetchPath) } @@ -391,22 +392,35 @@ func (gc *GoClient) waitForFirstValidProposal( return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) } -// getProposalParallelSafe implements the safe (default) block-fetch path. +// getProposalParallelByDeadline implements the slot-relative-deadline parallel +// block-fetch shared by the safe and MEV-optimized paths. // // Spawns a per-BN fetch in parallel; collects responses until the slot-relative -// ProposalSoftDeadline fires (default 1000ms into slot). Early-exits on the first -// blinded response (treats blinded == MEV). After the deadline, returns the best -// proposal seen so far, or falls through to the first valid response if none -// received yet. -func (gc *GoClient) getProposalParallelSafe( +// ProposalSoftDeadline (slot_start + gc.proposalSoftDeadline) fires. The +// earlyExitOnBlinded flag distinguishes the two paths: +// - true (safe path): returns immediately on the first blinded response +// (treats blinded == MEV). +// - false (MEV-optimized path): keeps collecting after blinded so the +// highest-value bid across BNs can be selected. +// +// After the deadline (or early-exit), returns the best-scored proposal collected +// so far, or falls through to waitForFirstValidProposal if nothing usable arrived. +// The parent ctx serves as the hard deadline. +// +// Note: in-flight BN fetches are spawned with the parent ctx (not softCtx), so a +// slow BN's HTTP call may keep running after we return — until the duty's slot +// deadline cancels ctx. Matches legacy behavior; the fetchProposal call's own +// HTTP timeouts bound the worst case. +func (gc *GoClient) getProposalParallelByDeadline( ctx context.Context, logger *zap.Logger, slot phase0.Slot, sig phase0.BLSSignature, graffiti [32]byte, + earlyExitOnBlinded bool, ) (*api.VersionedProposal, error) { // Slot-relative deadline: fires at slot_start + ProposalSoftDeadline regardless - // of when getProposalParallelSafe is invoked. + // of when this function is invoked. slotStart := gc.getBeaconConfig().SlotStartTime(slot) softCtx, cancelSoft := context.WithDeadline(ctx, slotStart.Add(gc.proposalSoftDeadline)) defer cancelSoft() @@ -450,8 +464,9 @@ collect: bestClient = res.client } - if res.proposal.Blinded { - // Early-exit on first blinded: treat blinded == MEV. + if earlyExitOnBlinded && res.proposal.Blinded { + // Safe path: treat blinded == MEV and return immediately. + // MEV-optimized path keeps collecting to compare bids across BNs. break collect } @@ -479,89 +494,6 @@ collect: return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) } -// getProposalParallelMEVOptimized implements the MEV-optimized (opt-in) block-fetch path. -// -// Spawns a per-BN fetch in parallel; collects responses until the slot-relative -// ProposalSoftDeadline fires. **Does not** early-exit on the first blinded response; -// instead, accumulates all responses received within the window so the highest-value -// bid across BNs can be selected. After the deadline, returns the best proposal -// seen so far, or falls through to the first valid response if none received yet. -func (gc *GoClient) getProposalParallelMEVOptimized( - ctx context.Context, - logger *zap.Logger, - slot phase0.Slot, - sig phase0.BLSSignature, - graffiti [32]byte, -) (*api.VersionedProposal, error) { - slotStart := gc.getBeaconConfig().SlotStartTime(slot) - softCtx, cancelSoft := context.WithDeadline(ctx, slotStart.Add(gc.proposalSoftDeadline)) - defer cancelSoft() - - resultCh := gc.spawnProposalFetchers(ctx, slot, sig, graffiti) - - var errs error - var bestProposal *api.VersionedProposal - var bestScore float64 - var bestClient string - - startCollect := time.Now() - pendingClients := len(gc.clients) -collect: - for pendingClients > 0 { - select { - case res := <-resultCh: - pendingClients-- - - if res.err != nil { - errs = errors.Join(errs, res.err) - continue - } - - proposalScore := gc.scoreProposal(res.proposal) - logger.Debug("received proposal", - zap.String("client", res.client), - zap.Float64("score", proposalScore), - zap.Duration("latency", time.Since(startCollect)), - zap.Int("pending", pendingClients), - zap.Bool("blinded", res.proposal.Blinded), - fields.Slot(slot), - ) - - if bestProposal == nil || - proposalScore > bestScore || - // prefer the blinded proposal even if same score as the best so far - (res.proposal.Blinded && proposalScore == bestScore) { - bestProposal = res.proposal - bestScore = proposalScore - bestClient = res.client - } - - // No early-exit on blinded: keep collecting to compare bids across BNs. - - case <-softCtx.Done(): - break collect - } - } - - if bestProposal != nil { - logger.Debug("selected best proposal", - zap.String("client", bestClient), - zap.Float64("score", bestScore), - zap.Bool("blinded", bestProposal.Blinded), - fields.Slot(slot), - ) - return bestProposal, nil - } - - logger.Debug("did not receive any valid proposals during the collection period", - zap.Int("clients", len(gc.clients)), - zap.Int("pending", pendingClients), - fields.Slot(slot), - ) - - return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) -} - // scoreProposal computes a score for a beacon proposal. // see https://github.com/attestantio/vouch/blob/master/strategies/beaconblockproposal/best/score.go as well func (gc *GoClient) scoreProposal( diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index 2069381eee..c76892588f 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -88,8 +88,9 @@ func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { require.NoError(t, err) // Safe path should early-exit on BN1's blinded response (~10ms) and NOT wait for - // BN2 (~500ms). A generous 250ms ceiling tolerates HTTP / goroutine overhead. - assert.Less(t, elapsed, 250*time.Millisecond, + // BN2 (~500ms). The 350ms ceiling sits well below BN2's response time while + // tolerating HTTP / goroutine / loaded-CI overhead. + assert.Less(t, elapsed, 350*time.Millisecond, "safe path should early-exit on first blinded; took %v", elapsed) } @@ -188,16 +189,25 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi pastSlot := phase0.Slot(1) start := time.Now() - _, _, err := client.GetBeaconBlock(context.Background(), pastSlot, []byte("test"), getTestRANDAO()) + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), pastSlot, []byte("test"), getTestRANDAO()) elapsed := time.Since(start) require.NoError(t, err, "fallback to first-valid should return successfully") + require.NotNil(t, versionedProposal) + + // Primary assertion: BN1's fee recipient confirms we returned with the first + // valid response (BN1 at ~200ms), not the slower BN2 (~500ms). This is robust + // against timing jitter on busy CI runners. + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, + "waitForFirstValidProposal should return BN1's response (first valid), not BN2's") - // Should return after BN1 responds (~200ms), not wait for BN2 (~500ms). This - // confirms waitForFirstValidProposal is invoked (returning the first valid - // response, bounded by the parent context's slot deadline). + // Sanity check on elapsed: must be at least BN1's response time, and the upper + // bound just confirms we didn't end up waiting for BN2. Margins kept generous + // for CI scheduling overhead. assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, "should have waited for first BN response (~200ms); took %v", elapsed) - assert.Less(t, elapsed, 400*time.Millisecond, + assert.Less(t, elapsed, 450*time.Millisecond, "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) } diff --git a/cli/operator/node.go b/cli/operator/node.go index 9435c9ac2d..9075859126 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -211,6 +211,9 @@ var StartNodeCmd = &cobra.Command{ if err := goclient.ValidateProposalSoftDeadline(cfg.ConsensusClient.ProposalSoftDeadline); err != nil { logger.Fatal("invalid ProposalSoftDeadline configuration", zap.Error(err)) } + // Strict `>` (not `>=`) is intentional: DefaultProposalSoftDeadline equals + // SafeMaxProposalSoftDeadline, so the safe-path default sits exactly at the + // threshold and should not trip the warning. if cfg.ConsensusClient.ProposalSoftDeadline > goclient.SafeMaxProposalSoftDeadline { logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ diff --git a/config/config.example.yaml b/config/config.example.yaml index 93ece90efa..ca9588c44b 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -30,9 +30,10 @@ eth2: # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not # fit within the slot for typical clusters). Cannot be combined with ProposerDelay or # ProposalSoftTimeout. - # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1000ms). - # Note: setting ProposalSoftDeadline = 1000ms is *not* a no-op — it opts into the - # MEV-optimized path at the same numeric deadline the safe path uses by default. + # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1450ms). + # Note: setting ProposalSoftDeadline = 1450ms is *not* a no-op — it opts into the + # MEV-optimized path at the same numeric deadline the safe path uses by default + # (the difference is behavioral: no early-exit on first blinded, best-bid wins). # ProposalSoftDeadline: 1100ms # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring From df129407eebc842d09b4c39154c64297e011d67a Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 29 May 2026 16:39:47 +0300 Subject: [PATCH 36/37] address review: clarify safe-max math + reject unknown BlockFetchPath The first commit's SafeMaxProposalSoftDeadline doc-comment carried a math inconsistency flagged on review: the +50ms BN->SSV transport term made sense only if "deadline" meant PBS cutoff, but the constant is applied to the SSV-side ProposalSoftDeadline, and the doc tells operators to set ProposalSoftDeadline = PBS cutoff + 50ms. So an operator setting PBS cutoff to the documented ceiling (1450ms) computed ProposalSoftDeadline = 1500ms and tripped the >1450ms startup warning despite following the docs. Fix: rewrite the doc-comment with the honest strict-math derivation (ProposalSoftDeadline <= 1500ms) and explain the 1450ms threshold sits 50ms tighter as a variance buffer. Update the Tuning guidance section to recommend PBS cutoff <= ~1400ms (matches the SSV warning threshold via +50ms transport), while preserving the 1450ms strict-bound reference for the round-2-fit cliff. Constant value (1450ms) unchanged. Also addresses cleanup #1: NewOptions returned an error type but had no error path. Add a default arm that rejects unknown BlockFetchPath at startup rather than at per-slot dispatch in proposer.go. Test added for the new error path; the proposer.go default arm stays as defense-in-depth. --- beacon/goclient/options.go | 41 ++++++++++++++++++++++----------- beacon/goclient/options_test.go | 10 ++++++++ docs/MEV_CONSIDERATIONS.md | 4 ++-- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 806c685f94..8439bb9ef0 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -51,22 +51,28 @@ func (p BlockFetchPath) String() string { // ProposalSoftDeadline bounds and defaults. Values are slot-relative (measured from slot start). const ( - // SafeMaxProposalSoftDeadline is the threshold above which the worst-case 2-round QBFT - // scenario may no longer fit within the slot deadline for clusters with typical - // latencies (round 1 effectively has to succeed). Derived from the typical values in - // docs/MEV_CONSIDERATIONS.md: - // deadline + 50ms (BN→SSV transport) + 2350ms (QBFT worst-case 2-round) + - // 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms - // => deadline <= 1450ms - // Values above this trigger a startup warning but are still permitted — the operator - // is accepting that round 1 must succeed (Example B is such a setup). Clusters with - // measurably faster QBFT + submission may still leave room for round 2. + // SafeMaxProposalSoftDeadline is the startup-warning threshold for the SSV-side + // ProposalSoftDeadline. Above this value, the worst-case 2-round QBFT scenario + // has no safety margin for latency variance — round 1 effectively has to succeed + // in setups with typical latencies. + // + // Strict math from the typical values in docs/MEV_CONSIDERATIONS.md gives a hard + // upper bound of ProposalSoftDeadline <= 1500ms: + // ProposalSoftDeadline + 2350ms (QBFT worst-case 2-round) + + // 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms (slot deadline) + // => ProposalSoftDeadline <= 1500ms + // + // We set the warning threshold 50ms tighter (1450ms) to preserve a buffer for + // latency variance. Operators following docs/MEV_CONSIDERATIONS.md's recommended + // "PBS cutoff + 50ms BN→SSV transport" formula stay within this threshold when + // their PBS cutoff sits at the recommended ~1400ms ceiling; pushing PBS cutoff + // up to the strict ~1450ms still works in clusters with measurably faster QBFT + + // submission, but consumes the variance buffer (and trips this warning). SafeMaxProposalSoftDeadline = 1450 * time.Millisecond - // DefaultProposalSoftDeadline is the default deadline used by the safe path when the - // operator hasn't set ProposalSoftDeadline. It equals SafeMaxProposalSoftDeadline — - // the largest value that still fits the worst-case 2-round QBFT scenario within the - // 4000ms slot deadline for clusters with typical latencies. + // DefaultProposalSoftDeadline is the default deadline used by the safe path when + // the operator hasn't set ProposalSoftDeadline. Equal to SafeMaxProposalSoftDeadline + // — the largest value that keeps the 50ms latency-variance buffer described above. DefaultProposalSoftDeadline = SafeMaxProposalSoftDeadline // MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline @@ -230,6 +236,13 @@ func NewOptions(base Options, proposerDelay time.Duration, path BlockFetchPath) // responsibility — cli/operator/node.go runs it for production startup, but // NewOptions does not enforce it. Tests that bypass the CLI should call // ValidateProposalSoftDeadline themselves if they want the bounds check. + + default: + // Defense-in-depth: DetermineBlockFetchPath returns only the three values + // above, but callers that construct Options directly (notably tests) can + // reach here. Reject at startup rather than at per-slot dispatch in + // proposer.go. + return Options{}, fmt.Errorf("unknown block-fetch path %d", path) } // Note: There is no hard timeout for proposals. The parent context from the diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go index 6ab8e9c69b..b0b9308e3e 100644 --- a/beacon/goclient/options_test.go +++ b/beacon/goclient/options_test.go @@ -197,6 +197,16 @@ func TestNewOptions_PathDefaulting(t *testing.T) { // ProposalSoftTimeout should not be touched. assert.Equal(t, time.Duration(0), opts.ProposalSoftTimeout) }) + + t.Run("unknown path -> error at startup", func(t *testing.T) { + // Defense-in-depth: callers that bypass DetermineBlockFetchPath and pass an + // invalid BlockFetchPath value should fail at NewOptions rather than at the + // per-slot dispatch in proposer.go. + base := Options{BeaconNodeAddr: "http://localhost:5052"} + _, err := NewOptions(base, 0, BlockFetchPath(99)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown block-fetch path") + }) } func TestBlockFetchPath_String(t *testing.T) { diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 26537ea9ac..902ff6fc0d 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -183,8 +183,8 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, resolving to `late_in_slot_time_ms ≲ ~1450ms`. Above this threshold, a round-2 fallback may no longer complete within the slot deadline for typical clusters. -- **Cutoffs above ~1450ms** accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, giving a strict bound of `late_in_slot_time_ms ≲ ~1450ms`. **Recommended:** stay at `late_in_slot_time_ms ≲ ~1400ms` to keep a 50ms buffer for latency variance — this also matches SSV's startup-warning threshold (`SafeMaxProposalSoftDeadline = 1450ms` SSV-side, which equals `~1400ms` PBS-side plus the `~50ms` BN→SSV transport). +- **Cutoffs above ~1400ms** consume the variance buffer; SSV emits a startup warning. **Cutoffs above ~1450ms** are past the strict bound and accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~3000ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. ### What to measure From dacc7dd077fd8f50df767144c2785f555d4312d4 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 29 May 2026 16:45:18 +0300 Subject: [PATCH 37/37] proposer tests: gate BN responses on Release channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MultiBN path-dispatch tests previously relied on time.Sleep delays in the mock BN server combined with wall-clock elapsed-time bounds in assertions. That made them CI-sensitive (per the review feedback that flagged the timing-bound assertions as flake-prone) and slowed the suite — each test ran in 200–500ms purely from sleeping. Add a Release <-chan struct{} field to beaconProposalServerOptions. When non-nil, the proposal endpoint handler blocks until the channel receives a value, is closed, or the request context cancels. ProposalResponseDuration remains the default for tests that don't need ordering control. Refactor the four MultiBN path-dispatch tests (SafePath_EarlyExitOnBlinded, MEVOptimizedPath_NoEarlyExit, MEVOptimizedPath_HighestScoringBlindedWins, SoftDeadlineFires_FallsBackToFirstValid) to: - run GetBeaconBlock in a background goroutine via launchGetBeaconBlock, - close Release channels to deterministically order BN responses, - assert response identity (fee recipient) instead of elapsed time, - bound the wait on a 2s safety timeout, not a tight CI-sensitive bound. The NoEarlyExit test additionally uses a short (100ms) non-return check after releasing BN1 — strictly weaker than the old 400ms lower bound and sufficient to detect a regression to early-exit behavior on the MEV-optimized path. Net effect: each affected test runs in ~10ms instead of 200–500ms, and none depend on tight wall-clock bounds. --- .../goclient/proposer_path_dispatch_test.go | 236 +++++++++++------- beacon/goclient/proposer_test.go | 25 +- 2 files changed, 175 insertions(+), 86 deletions(-) diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index c76892588f..7bc158a738 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" @@ -16,6 +17,11 @@ import ( // Tests for the block-fetch path dispatch (BlockFetchPathSafe / Legacy / MEVOptimized). // See docs/MEV_CONSIDERATIONS.md for path semantics. +// +// These tests gate BN responses on Release channels rather than time.Sleep delays, +// so the assertions are identity/ordering-based rather than wall-clock-based. +// A 2s safety timeout on the result channel catches the "GetBeaconBlock never returns" +// failure mode without depending on tight CI-sensitive elapsed-time bounds. // TestNew_StoresBlockFetchPath verifies that the selected path and its associated // timing field (proposalSoftDeadline / proposalSoftTimeout) get propagated from @@ -58,57 +64,59 @@ func TestNew_StoresBlockFetchPath(t *testing.T) { } // TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded verifies the safe path's -// early-exit-on-first-blinded behavior. With one fast and one slow BN both returning -// blinded proposals, the safe path should return quickly after the fast BN responds, -// without waiting for the slow one. +// early-exit-on-first-blinded behavior. With BN1 released and BN2 left blocked, +// the safe path must return on BN1's blinded response without waiting for BN2. func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { + release1 := make(chan struct{}) + release2 := make(chan struct{}) // intentionally never closed + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 10 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllOnes(), + Release: release1, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 500 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllTwos(), + Release: release2, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), }) defer bn2.Close() client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1500*time.Millisecond) - // Use a slot starting in the near future so the slot-relative deadline lands - // well after both BN responses (we want to observe the early-exit on blinded, - // not the deadline firing). + // Future slot so the slot-relative deadline lands after both BN responses — + // we want to observe early-exit, not the deadline firing. slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 - start := time.Now() - _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) - elapsed := time.Since(start) - require.NoError(t, err) + resultCh, cancel := launchGetBeaconBlock(t, client, slot) + defer cancel() // unblocks BN2's still-pending request when test returns - // Safe path should early-exit on BN1's blinded response (~10ms) and NOT wait for - // BN2 (~500ms). The 350ms ceiling sits well below BN2's response time while - // tolerating HTTP / goroutine / loaded-CI overhead. - assert.Less(t, elapsed, 350*time.Millisecond, - "safe path should early-exit on first blinded; took %v", elapsed) + close(release1) + + proposal := requireBeaconBlockResult(t, resultCh, "safe path should early-exit on first blinded") + assertProposalFeeRecipient(t, proposal, feeRecipientAllOnes(), + "safe path should return BN1's blinded (early-exit), not BN2's") } -// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit verifies that the MEV-optimized -// path does NOT early-exit on the first blinded response — it keeps collecting until all -// BNs respond (or the soft deadline fires). With the same setup as the safe-path test, -// the MEV-optimized path should wait for the slow BN. +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit verifies that the +// MEV-optimized path does NOT early-exit on the first blinded response. After +// releasing BN1, GetBeaconBlock must still be waiting; only after releasing BN2 +// does it return. func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { + release1 := make(chan struct{}) + release2 := make(chan struct{}) + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 10 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllOnes(), + Release: release1, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 500 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllTwos(), + Release: release2, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), }) defer bn2.Close() @@ -116,36 +124,43 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 - start := time.Now() - _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) - elapsed := time.Since(start) - require.NoError(t, err) + resultCh, cancel := launchGetBeaconBlock(t, client, slot) + defer cancel() + + close(release1) + + // MEV-optimized path keeps collecting after blinded. A short non-return check + // (100ms) is enough to detect a regression to early-exit behavior; we're + // asserting "no event for a brief window" rather than the much weaker + // "wall-clock bound around 500ms". + assertNoReturnWithin(t, resultCh, 100*time.Millisecond, + "MEV-optimized path returned after BN1 alone — should not early-exit on blinded") + + close(release2) - // MEV-optimized path should NOT early-exit; it waits for BN2's response at ~500ms - // before returning the best-scored proposal. The 400ms floor tolerates clock jitter. - assert.GreaterOrEqual(t, elapsed, 400*time.Millisecond, - "MEV-optimized path should wait for the slower BN; took %v", elapsed) + requireBeaconBlockResult(t, resultCh, "MEV-optimized path should return after both BNs respond") } -// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins verifies that -// when multiple BNs return blinded proposals within the collection window, the -// MEV-optimized path selects the one with the highest scoreProposal value (sum of -// ConsensusValue and ExecutionValue) rather than the first-arriving one. BN1 returns -// a fast low-value blinded; BN2 returns a slow high-value blinded — the function must -// return BN2's proposal. +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins verifies +// that when multiple BNs return blinded proposals within the collection window, +// the MEV-optimized path selects the one with the highest scoreProposal value +// (sum of ConsensusValue and ExecutionValue) rather than the first-arriving one. func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *testing.T) { + release1 := make(chan struct{}) + release2 := make(chan struct{}) + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 10 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllOnes(), - ExecutionValue: big.NewInt(1_000_000), // low bid + Release: release1, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + ExecutionValue: big.NewInt(1_000_000), // low bid }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 300 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllTwos(), - ExecutionValue: big.NewInt(5_000_000), // high bid (must win) + Release: release2, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + ExecutionValue: big.NewInt(5_000_000), // high bid (must win) }) defer bn2.Close() @@ -153,13 +168,16 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *te slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 - versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) - require.NoError(t, err) - require.NotNil(t, versionedProposal) + resultCh, cancel := launchGetBeaconBlock(t, client, slot) + defer cancel() - actualFeeRecipient, err := versionedProposal.FeeRecipient() - require.NoError(t, err) - assert.Equal(t, feeRecipientAllTwos(), actualFeeRecipient, + // Release BN1 first so it arrives first (lower bid). MEV-optimized must keep + // collecting until BN2 (higher bid) arrives, then prefer BN2 by score. + close(release1) + close(release2) + + proposal := requireBeaconBlockResult(t, resultCh, "MEV-optimized path should select highest-scoring proposal") + assertProposalFeeRecipient(t, proposal, feeRecipientAllTwos(), "MEV-optimized path should select the higher-value blinded (BN2's), not the first-arriving (BN1's)") } @@ -168,16 +186,19 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *te // the parallel-fetch path falls through to waitForFirstValidProposal and returns // the first valid BN response. Uses a slot in the past so the deadline is past. func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testing.T) { + release1 := make(chan struct{}) + release2 := make(chan struct{}) // intentionally never closed + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 200 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllOnes(), + Release: release1, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 500 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllTwos(), + Release: release2, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), }) defer bn2.Close() @@ -188,27 +209,74 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi // done when the collection loop starts. pastSlot := phase0.Slot(1) - start := time.Now() - versionedProposal, _, err := client.GetBeaconBlock(context.Background(), pastSlot, []byte("test"), getTestRANDAO()) - elapsed := time.Since(start) - require.NoError(t, err, "fallback to first-valid should return successfully") - require.NotNil(t, versionedProposal) + resultCh, cancel := launchGetBeaconBlock(t, client, pastSlot) + defer cancel() + + close(release1) + + proposal := requireBeaconBlockResult(t, resultCh, "waitForFirstValidProposal should return the first BN response") + assertProposalFeeRecipient(t, proposal, feeRecipientAllOnes(), + "waitForFirstValidProposal should return BN1's response (first released), not BN2's") +} + +// beaconBlockResult bundles the values returned by client.GetBeaconBlock for +// transmission over a channel from the background goroutine spawned by +// launchGetBeaconBlock. +type beaconBlockResult struct { + proposal *api.VersionedProposal + err error +} + +// launchGetBeaconBlock runs client.GetBeaconBlock in a background goroutine and +// returns a channel that receives the result when it returns, plus a cancel +// function that aborts any in-flight BN requests (used to unblock test-server +// handlers whose Release channels weren't closed). +func launchGetBeaconBlock(t *testing.T, client *GoClient, slot phase0.Slot) (<-chan beaconBlockResult, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + resultCh := make(chan beaconBlockResult, 1) + go func() { + p, _, e := client.GetBeaconBlock(ctx, slot, []byte("test"), getTestRANDAO()) + resultCh <- beaconBlockResult{proposal: p, err: e} + }() + return resultCh, cancel +} - // Primary assertion: BN1's fee recipient confirms we returned with the first - // valid response (BN1 at ~200ms), not the slower BN2 (~500ms). This is robust - // against timing jitter on busy CI runners. - actualFeeRecipient, err := versionedProposal.FeeRecipient() +// requireBeaconBlockResult waits for the result channel with a 2s safety timeout, +// failing the test if no result arrives. Returns the proposal on success. +func requireBeaconBlockResult(t *testing.T, resultCh <-chan beaconBlockResult, msg string) *api.VersionedProposal { + t.Helper() + select { + case r := <-resultCh: + require.NoError(t, r.err, msg) + require.NotNil(t, r.proposal, msg) + return r.proposal + case <-time.After(2 * time.Second): + t.Fatalf("GetBeaconBlock did not return within 2s: %s", msg) + return nil + } +} + +// assertNoReturnWithin fails the test if a result arrives on the channel within +// the given window. Used to verify that the MEV-optimized path does NOT +// early-exit after one BN responds. +func assertNoReturnWithin(t *testing.T, resultCh <-chan beaconBlockResult, window time.Duration, msg string) { + t.Helper() + select { + case <-resultCh: + t.Fatalf("unexpected early return within %v: %s", window, msg) + case <-time.After(window): + // Expected: still waiting. + } +} + +// assertProposalFeeRecipient extracts the fee recipient from a VersionedProposal +// and asserts equality with the expected value. +func assertProposalFeeRecipient(t *testing.T, proposal *api.VersionedProposal, expected bellatrix.ExecutionAddress, msg string) { + t.Helper() + actual, err := proposal.FeeRecipient() require.NoError(t, err) - assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, - "waitForFirstValidProposal should return BN1's response (first valid), not BN2's") - - // Sanity check on elapsed: must be at least BN1's response time, and the upper - // bound just confirms we didn't end up waiting for BN2. Margins kept generous - // for CI scheduling overhead. - assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, - "should have waited for first BN response (~200ms); took %v", elapsed) - assert.Less(t, elapsed, 450*time.Millisecond, - "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) + assert.Equal(t, expected, actual, msg) } // setupMultiBNClient builds a GoClient connected to two test BN servers via diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index 98047f509f..5c16a4212f 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -58,6 +58,16 @@ type beaconProposalServerOptions struct { // (go-eth2-client defaults ExecutionValue to 0). Used by tests that need to // influence scoreProposal across multiple BN responses. ExecutionValue *big.Int + // Release, if non-nil, gates the proposal-endpoint handler: the response is + // withheld until the channel receives a value or is closed (whichever first). + // Use this in tests that need deterministic response ordering instead of + // wall-clock delays via ProposalResponseDuration. The handler also unblocks + // on request context cancellation (e.g., when the parent test ends), so a + // never-released channel does not stall server shutdown. + // + // Mutually exclusive with ProposalResponseDuration: when Release is non-nil, + // the duration is ignored. + Release <-chan struct{} } // Creates a mock beacon server for proposal testing @@ -90,8 +100,19 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption require.NoError(t, err) require.NotZero(t, slot) - // Add delay if specified - time.Sleep(options.ProposalResponseDuration) + // Gate the response: either wait for the Release signal (deterministic + // ordering) or sleep for the configured duration. The request context + // also unblocks the handler so an unreleased channel doesn't stall + // server shutdown. + if options.Release != nil { + select { + case <-options.Release: + case <-r.Context().Done(): + return + } + } else if options.ProposalResponseDuration > 0 { + time.Sleep(options.ProposalResponseDuration) + } // Return custom response if provided if len(options.ProposalResponse) > 0 {