beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc#2855
beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc#2855iurii-ssv wants to merge 34 commits into
Conversation
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.
Codecov Report❌ Patch coverage is
☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Greptile SummaryThis PR rewrites
Confidence Score: 5/5Safe to merge — no logic changes; the only runtime-visible delta is a log field rename and ms-integer error message formatting. All changed code paths are in error-handling and logging for the ProposerDelay validation — the format change from time.Duration %v to int64 %dms is correct, .Milliseconds() returns int64 matching %d, and the test assertions were updated consistently. The documentation rewrite carries no behavioral change; ProposerDelay default remains 0 and the 1000ms safety cap is unchanged. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant SSV as SSV Node (Leader)
participant BN as Beacon Node
participant PBS as PBS (mev-boost / commit-boost)
participant Relay as Relay(s)
note over SSV: t=0 slot start
SSV->>SSV: Pre-consensus RANDAO (~100ms)
note over PBS,Relay: PBS timing games polling (target_first_request_ms, frequency_get_header_ms)
PBS->>Relay: poll at 700ms
PBS->>Relay: poll at 900ms (Ex A) / 850ms (Ex B)
PBS->>Relay: poll at 1100ms (Ex A) / 1000ms (Ex B)
PBS->>Relay: poll at 1300ms (Ex A only)
note over PBS: PBS holds best bid until late_in_slot_time_ms cutoff
SSV->>BN: getHeader request (SSV asks once, no ProposerDelay)
BN->>PBS: forward getHeader
PBS-->>BN: return best bid (by ~1450ms Ex A / ~1050ms Ex B)
BN-->>SSV: blinded block header (~1500ms Ex A)
SSV->>SSV: "QBFT consensus round 1 (< 2000ms deadline)"
SSV->>BN: submit signed block
BN->>PBS: unblind (getPayload)
PBS-->>BN: full block
BN->>BN: "propagate block (< 4000ms deadline)"
Reviews (2): Last reviewed commit: "cli/operator: align node_test with renam..." | Re-trigger Greptile |
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.
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.
|
@greptile pls re-review |
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
… paths 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.
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.
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.
…cy 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.
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.
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.
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.
#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.
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.
…vs legacy 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.
… 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.
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.
8e0c9df to
d0c5c0e
Compare
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.
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.
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)
… 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.
… variance heuristic - 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.
Summary
Splits SSV's multi-BN block-fetch into three explicit paths and reframes the MEV doc around PBS-side timing games as the primary recommendation, with SSV's
ProposerDelaysoft-deprecated into a legacy appendix.New code:
BlockFetchPathenum (safe/legacy/MEV-optimized) andProposalSoftDeadlineconfig knob inbeacon/goclient/options.go.getProposalParallelSafeandgetProposalParallelMEVOptimizedimplementations inbeacon/goclient/proposer.go; originalgetProposalParallelpreserved asgetProposalParallelLegacy(bit-for-bit unchanged).DetermineBlockFetchPathat startup picks the path from operator-provided config; settingProposerDelayorProposalSoftTimeoutselects legacy, settingProposalSoftDeadlineselects MEV-optimized, all-zero defaults select safe. Combining legacy + MEV-optimized knobs is rejected at startup.validateProposerDelayConfig(existing 1000ms dangerous-delay cap); MEV-optimized path runsValidateProposalSoftDeadline([1000ms, 3600ms]) and warns above the 1450ms safe-max threshold.Behavior change for default-config operators:
Operators who haven't set
ProposerDelayorProposalSoftTimeoutnow use the new safe path (slot-relativeProposalSoftDeadline = 1450ms+ early-exit on first blinded) rather than the legacy path (relative-durationProposalSoftTimeout = 1800ms). Under typical timing the new safe deadline lands atslot_start + 1450msversus legacy'sfetch_start + 1800ms(≈slot_start + 1850msafter RANDAO) — so the multi-BN collection window tightens by ~400ms in exchange for a stable, slot-relative budget.1450msis the largest safest deadline derived from the tightened typical values documented indocs/MEV_CONSIDERATIONS.md.Operators who want the previous behavior can opt in by setting
ProposerDelay(any value > 0, including a small one like 1ms) orProposalSoftTimeoutdirectly. The PR preserves the legacy code path bit-for-bit for them.Docs:
docs/MEV_CONSIDERATIONS.mdto recommend PBS-side timing games (mev-boost v1.11+ launched with-config, or commit-boost) as the primary MEV-extraction approach.min(timeout_get_header_ms, late_in_slot_time_ms - ms_into_slot)with two worked examples for both PBSes — a recommended migration baseline (Example A: header at SSV by ~1100ms, matching legacyProposerDelay = 1000ms) and an aggressive "round 1 must succeed" variant (Example B: header at SSV by ~1850ms).config/config.example.yaml,cli/operator/node.go,protocol/v2/ssv/runner/proposer.go) with the new framing;msunit consistency throughout.Test plan
go vet ./... && go build ./...clean.go test -race -count=1 ./beacon/goclient/... ./cli/operator/... ./protocol/v2/ssv/runner/...passes.beacon/goclient/proposer_path_dispatch_test.go: safe-path early-exit-on-blinded; MEV-optimized no-early-exit + highest-scoring-blinded-wins; safe-path soft-deadline-fired fallback.getProposalParallelLegacyis the pre-PRgetProposalParallelrenamed;proposalSoftTimeout -= proposerDelaymath atbeacon/goclient/options.gounchanged;AllowDangerousProposerDelay1000ms cap unchanged.docs/MEV_CONSIDERATIONS.mdon GitHub and skim for any anchor or code-block issues.timeout_get_header_ms < late_in_slot_time_msclaim for commit-boost still holds in the version you're targeting (validated against commit-boostmainat the time of writing).target_first_request_ms = 700,frequency_get_header_ms = 150) and Example B (target_first_request_ms = 1000,frequency_get_header_ms = 200) are reasonable starting points for your operator profile.