Skip to content

proposer tests: deterministic BN response ordering via Release channels - #2865

Draft
iurii-ssv wants to merge 37 commits into
mev-considerations-revisedfrom
proposer-test-deterministic-bn-servers
Draft

proposer tests: deterministic BN response ordering via Release channels#2865
iurii-ssv wants to merge 37 commits into
mev-considerations-revisedfrom
proposer-test-deterministic-bn-servers

Conversation

@iurii-ssv

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2855 addressing reviewer cleanup #2: the MultiBN path-dispatch tests in beacon/goclient/proposer_path_dispatch_test.go previously depended on wall-clock timing bounds (elapsed >= 400ms, elapsed < 350ms) combined with time.Sleep delays in the mock BN server. CI-sensitive, slow, and brittle under load.

This PR gates BN responses on per-test Release <-chan struct{} channels so the assertions become identity/ordering-based instead of timing-based.

What's changed

beacon/goclient/proposer_test.gobeaconProposalServerOptions gains a Release field. When non-nil, the proposal-endpoint handler waits on it (also unblocking on request context cancellation) before responding. ProposalResponseDuration remains the default for tests that don't need ordering control, so existing tests are unaffected.

beacon/goclient/proposer_path_dispatch_test.go — four tests refactored:

  • TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded — release BN1 only; assert response identity == BN1.
  • TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit — release BN1, assert no return within 100ms (vs old 400ms lower bound), then release BN2.
  • TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins — release both; assert response identity == BN2 (high-bid winner).
  • TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid — release BN1 only (past slot → softCtx already fired → waitForFirstValidProposal); assert response identity == BN1.

Helpers introduced:

  • launchGetBeaconBlock — spawns GetBeaconBlock in a background goroutine, returns a result channel + cancel func.
  • requireBeaconBlockResult — blocks on the result channel with a 2s safety timeout.
  • assertNoReturnWithin — fails if a result arrives within a short window (used to verify non-early-exit).
  • assertProposalFeeRecipient — extracts and compares fee recipient.

Effect

Each of the four refactored tests runs in ~10ms instead of 200–500ms. None depend on tight wall-clock bounds. The only remaining timing knob is the 2s safety timeout on the result channel — comfortably above any reasonable test runtime, and serves as a "GetBeaconBlock never returned" failure signal.

Test plan

  • go vet ./... clean.
  • go build ./... clean.
  • go test -race -count=1 ./beacon/goclient/... ./cli/operator/... passes locally.
  • Verified per-test runtime collapsed (e.g., HighestScoringBlindedWins went from ~320ms to ~10ms).
  • Reviewer: confirm the Release mechanism in createProposalBeaconServer is the right abstraction (vs. e.g. injecting a delay function).

Base

Targets mev-considerations-revised (the head branch of #2855). Will rebase onto stage automatically when #2855 merges.

iurii-ssv added 30 commits May 18, 2026 17:16
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.
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.
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.
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.
iurii-ssv added 7 commits May 19, 2026 12:09
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.
- 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.
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.
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.
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.2%. Comparing base (df12940) to head (dacc7dd).

☔ 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.

@iurii-ssv
iurii-ssv force-pushed the mev-considerations-revised branch 7 times, most recently from 4054c1d to e40a5fe Compare June 5, 2026 12:06
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