Skip to content

beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc#2855

Open
iurii-ssv wants to merge 34 commits into
stagefrom
mev-considerations-revised
Open

beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc#2855
iurii-ssv wants to merge 34 commits into
stagefrom
mev-considerations-revised

Conversation

@iurii-ssv
Copy link
Copy Markdown
Contributor

@iurii-ssv iurii-ssv commented May 18, 2026

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 ProposerDelay soft-deprecated into a legacy appendix.

New code:

  • New BlockFetchPath enum (safe / legacy / MEV-optimized) and ProposalSoftDeadline config knob in beacon/goclient/options.go.
  • New getProposalParallelSafe and getProposalParallelMEVOptimized implementations in beacon/goclient/proposer.go; original getProposalParallel preserved as getProposalParallelLegacy (bit-for-bit unchanged).
  • DetermineBlockFetchPath at startup picks the path from operator-provided config; setting ProposerDelay or ProposalSoftTimeout selects legacy, setting ProposalSoftDeadline selects MEV-optimized, all-zero defaults select safe. Combining legacy + MEV-optimized knobs is rejected at startup.
  • Path-specific validation: legacy path runs validateProposerDelayConfig (existing 1000ms dangerous-delay cap); MEV-optimized path runs ValidateProposalSoftDeadline ([1000ms, 3600ms]) and warns above the 1450ms safe-max threshold.

Behavior change for default-config operators:
Operators who haven't set ProposerDelay or ProposalSoftTimeout now use the new safe path (slot-relative ProposalSoftDeadline = 1450ms + early-exit on first blinded) rather than the legacy path (relative-duration ProposalSoftTimeout = 1800ms). Under typical timing the new safe deadline lands at slot_start + 1450ms versus legacy's fetch_start + 1800ms (≈ slot_start + 1850ms after RANDAO) — so the multi-BN collection window tightens by ~400ms in exchange for a stable, slot-relative budget. 1450ms is the largest safest deadline derived from the tightened typical values documented in docs/MEV_CONSIDERATIONS.md.

Operators who want the previous behavior can opt in by setting ProposerDelay (any value > 0, including a small one like 1ms) or ProposalSoftTimeout directly. The PR preserves the legacy code path bit-for-bit for them.

Docs:

  • Rewrite docs/MEV_CONSIDERATIONS.md to recommend PBS-side timing games (mev-boost v1.11+ launched with -config, or commit-boost) as the primary MEV-extraction approach.
  • Document the shared budget formula 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 legacy ProposerDelay = 1000ms) and an aggressive "round 1 must succeed" variant (Example B: header at SSV by ~1850ms).
  • Tightened typical-value estimates (RANDAO 50ms, QBFT worst-case 2350ms, PostConsensusSigning 50ms, BlockSubmission 100ms) re-derive the safe-max threshold to ~1450ms throughout.
  • Tuning & measurement methodology section pointing at SSV telemetry ("got beacon block proposal" / "received proposal" / "successfully finished duty processing" logs, plus pre-consensus / consensus duration metrics) and PBS / relay logs.
  • Multi-BN configuration consolidated into a dedicated "Multi-BN setup" section; single-BN operators are told upfront to skip it.
  • Align code-side doc comments (config/config.example.yaml, cli/operator/node.go, protocol/v2/ssv/runner/proposer.go) with the new framing; ms unit consistency throughout.

Test plan

  • go vet ./... && go build ./... clean.
  • go test -race -count=1 ./beacon/goclient/... ./cli/operator/... ./protocol/v2/ssv/runner/... passes.
  • Per-path behavior tests added in 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.
  • Legacy-path behavior unchanged: getProposalParallelLegacy is the pre-PR getProposalParallel renamed; proposalSoftTimeout -= proposerDelay math at beacon/goclient/options.go unchanged; AllowDangerousProposerDelay 1000ms cap unchanged.
  • Reviewer: render docs/MEV_CONSIDERATIONS.md on GitHub and skim for any anchor or code-block issues.
  • Reviewer: sanity-check that the timeout_get_header_ms < late_in_slot_time_ms claim for commit-boost still holds in the version you're targeting (validated against commit-boost main at the time of writing).
  • Reviewer: confirm Example A (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.
  • Reviewer: confirm the tightened typical-value estimates (RANDAO 50ms, QBFTRoundChange 100ms, QBFTRound2Time 250ms, PostConsensusSigning 50ms, BlockSubmission 100ms) match your cluster's observed latencies on Ethereum mainnet.

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.
@iurii-ssv iurii-ssv requested review from a team as code owners May 18, 2026 14:17
@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 79.90868% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.2%. Comparing base (0fa3b4a) to head (24a00bd).

Files with missing lines Patch % Lines
cli/operator/node.go 14.8% 23 Missing ⚠️
beacon/goclient/proposer.go 85.1% 19 Missing and 2 partials ⚠️

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 18, 2026

Greptile Summary

This PR rewrites docs/MEV_CONSIDERATIONS.md to recommend PBS-side timing games (mev-boost v1.11+ with -config, or commit-boost) as the primary MEV approach, and soft-deprecates ProposerDelay to a legacy appendix. Accompanying code changes improve unit consistency — error messages and log fields now use explicit ms integers instead of time.Duration %v formatting.

  • Documentation rewrite: adds the shared budget formula, two worked examples (Example A at ~1500ms cutoff, Example B as a ~1000ms legacy equivalent) for both PBSes, tuning/measurement guidance, and a multi-BN caveat.
  • Logging change (node.go): proposer_delay / max_safe_proposer_delay fields replaced by proposer_delay_ms / max_safe_proposer_delay_ms (zap.Int64); error message uses %dms with .Milliseconds().
  • Tests (node_test.go): assertions updated to match the renamed fields and int64 values.

Confidence Score: 5/5

Safe 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

Filename Overview
docs/MEV_CONSIDERATIONS.md Complete rewrite from ~112 lines to ~269 lines; restructures around PBS-side timing games as the primary recommendation, soft-deprecates ProposerDelay to appendices. Content is internally consistent and math checks out.
cli/operator/node.go Error message now uses %dms with .Milliseconds() (int64) instead of %v with time.Duration; log fields renamed to *_ms variants. Format is correct and consistent with the updated doc style.
cli/operator/node_test.go Test assertions updated to match renamed log fields (proposer_delay_ms, max_safe_proposer_delay_ms) and int64 comparison values; correctly mirrors the node.go logging change.
config/config.example.yaml Comments updated to recommend PBS-side timing games and reframe ProposerDelay as a legacy option; '1s' replaced with '1000ms' for unit consistency.
protocol/v2/ssv/runner/proposer.go Doc comment on proposerDelay field simplified to a cross-reference; ProposerRunnerOptions.ProposerDelay comment expanded with the legacy/PBS framing. No logic changes.

Sequence Diagram

sequenceDiagram
    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)"
Loading

Reviews (2): Last reviewed commit: "cli/operator: align node_test with renam..." | Re-trigger Greptile

Comment thread docs/MEV_CONSIDERATIONS.md Outdated
Comment thread docs/MEV_CONSIDERATIONS.md Outdated
iurii-ssv added 2 commits May 18, 2026 17:51
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.
@iurii-ssv
Copy link
Copy Markdown
Contributor Author

@greptile pls re-review

iurii-ssv added 21 commits May 18, 2026 18:23
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.
iurii-ssv added 2 commits May 19, 2026 09:58
…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.
@iurii-ssv iurii-ssv changed the title docs/MEV_CONSIDERATIONS: rewrite around PBS-side timing games beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc May 19, 2026
iurii-ssv added 2 commits May 19, 2026 11:37
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.
@iurii-ssv iurii-ssv force-pushed the mev-considerations-revised branch from 8e0c9df to d0c5c0e Compare May 19, 2026 08:44
iurii-ssv added 6 commits May 19, 2026 11:51
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant