Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5224e96
docs/MEV_CONSIDERATIONS: rewrite around PBS-side timing games
iurii-ssv May 18, 2026
159b35b
docs+operator: MEV_CONSIDERATIONS rewrite review follow-ups
iurii-ssv May 18, 2026
43ddf96
cli/operator: align node_test with renamed proposer_delay_ms log fields
iurii-ssv May 18, 2026
e452577
docs/MEV_CONSIDERATIONS: remove incorrect round-1 deadline framing
iurii-ssv May 18, 2026
3dd5817
docs/MEV_CONSIDERATIONS: restructure Example A/B around bid-sample time
iurii-ssv May 18, 2026
7183dd4
docs/MEV_CONSIDERATIONS: update BlockSubmissionTime estimate to ~200ms
iurii-ssv May 18, 2026
bfd7bc6
docs/MEV_CONSIDERATIONS: clarify slot-budget math in tuning bullet
iurii-ssv May 18, 2026
4e89418
docs/MEV_CONSIDERATIONS: remove "Mainnet ground truth" section
iurii-ssv May 18, 2026
c33e588
docs/MEV_CONSIDERATIONS: fix Example A header-arrival figures
iurii-ssv May 18, 2026
81ee7cf
minor adjustment
iurii-ssv May 18, 2026
b91ba8f
docs/MEV_CONSIDERATIONS: budget for worst-case 2-round QBFT
iurii-ssv May 18, 2026
494bdaa
docs/MEV_CONSIDERATIONS: add QBFTRoundChange to slot-budget equation
iurii-ssv May 18, 2026
534ae74
docs/MEV_CONSIDERATIONS: align tuning bullets with 2-round budget
iurii-ssv May 18, 2026
6a38859
docs/MEV_CONSIDERATIONS: collapse QBFT terms, drop Time suffixes
iurii-ssv May 18, 2026
19e1dbe
docs/MEV_CONSIDERATIONS: drop blinding from slot-budget math
iurii-ssv May 18, 2026
b026313
beacon/goclient: split block-fetch into safe / legacy / MEV-optimized…
iurii-ssv May 18, 2026
d4dff91
beacon/goclient: unit tests for block-fetch path selection
iurii-ssv May 18, 2026
f211ebe
docs/MEV_CONSIDERATIONS: document the three-path SSV block-fetch model
iurii-ssv May 18, 2026
7ab723a
docs/MEV_CONSIDERATIONS: clarify Multi-BN caveat applies to safe/lega…
iurii-ssv May 18, 2026
94ef2b2
beacon/goclient: per-path behavior tests; remove plan-doc
iurii-ssv May 18, 2026
2edbbbd
cli/operator: drop dangling reference to deleted plan-doc
iurii-ssv May 18, 2026
f672d24
docs/MEV_CONSIDERATIONS: add upfront definitions, slim duplications
iurii-ssv May 18, 2026
1026a86
address review: fix SafeMax math; small fixes; doc clarifications
iurii-ssv May 18, 2026
d656e5e
address review: fix race in test fixture; reject negative configs
iurii-ssv May 18, 2026
df94a2c
docs/MEV_CONSIDERATIONS: drop Path 0/1/2 terminology; reframe as new …
iurii-ssv May 19, 2026
4ebcbd8
address review: drop Path 0/1/2 labels; tighten dispatch; add scoring…
iurii-ssv May 19, 2026
e169ea1
docs/MEV_CONSIDERATIONS: clarify PBS preference; drop tuning subsections
iurii-ssv May 19, 2026
d0c5c0e
minor adjustments
iurii-ssv May 19, 2026
5e441cc
more adjustments
iurii-ssv May 19, 2026
5376117
address review: soften absolute "can/will not fit" phrasings
iurii-ssv May 19, 2026
a4b8d5c
docs/MEV_CONSIDERATIONS: merge "What to measure first" + "SSV telemetry"
iurii-ssv May 19, 2026
25180c5
docs/MEV_CONSIDERATIONS: consolidate multi-BN guidance into one section
iurii-ssv May 19, 2026
fd88463
tighten typical-value estimates; raise DefaultProposalSoftDeadline to…
iurii-ssv May 20, 2026
24a00bd
address review sweep: fix stale defaults; reorder const block; loosen…
iurii-ssv May 20, 2026
1939b0c
address review: consolidate parallel fetch + clean stale defaults
iurii-ssv May 29, 2026
df12940
address review: clarify safe-max math + reject unknown BlockFetchPath
iurii-ssv May 29, 2026
dacc7dd
proposer tests: gate BN responses on Release channels
iurii-ssv May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion beacon/goclient/attest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ func createClient(
CommonTimeout: defaultHardTimeout,
LongTimeout: time.Second,
WithWeightedAttestationData: withWeightedAttestationData,
}, 0)
}, 0, BlockFetchPathSafe)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion beacon/goclient/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func TestNewEventHandler(t *testing.T) {
}

func eventsTestClient(t *testing.T, serverURL string) *GoClient {
opt, err := NewOptions(Options{BeaconNodeAddr: serverURL}, 0)
opt, err := NewOptions(Options{BeaconNodeAddr: serverURL}, 0, BlockFetchPathSafe)
require.NoError(t, err)

server, err := New(t.Context(), zap.NewNop(), opt)
Expand Down
18 changes: 14 additions & 4 deletions beacon/goclient/goclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,20 @@ type GoClient struct {
weightedAttestationDataSoftTimeout time.Duration
weightedAttestationDataHardTimeout time.Duration

// proposalSoftTimeout is the collection period during which we gather proposals
// from multiple beacon nodes to select the best one. After this timeout, we return
// the best proposal seen so far, or wait for the first valid proposal if none
// received yet. The parent context (duty deadline) serves as the hard timeout.
// proposalSoftTimeout is the legacy collection-period timeout used by
// getProposalParallelLegacy. Other paths use proposalSoftDeadline instead.
proposalSoftTimeout time.Duration

// proposalSoftDeadline is the slot-relative deadline (ms into slot) for the safe
// and MEV-optimized paths. See docs/MEV_CONSIDERATIONS.md.
proposalSoftDeadline time.Duration

// blockFetchPath selects the multi-BN block-fetch strategy GetBeaconBlock
// dispatches to: getProposalParallelLegacy for BlockFetchPathLegacy, or
// getProposalParallelByDeadline (with earlyExitOnBlinded set per path) for
// BlockFetchPathSafe and BlockFetchPathMEVOptimized.
blockFetchPath BlockFetchPath

// blockRootToSlotCache is used for attestation data scoring. When multiple Consensus clients are used,
// the cache helps reduce the number of Consensus Client calls by `n-1`, where `n` is the number of Consensus clients
// that successfully fetched attestation data and proceeded to the scoring phase. Capacity is rather an arbitrary number,
Expand Down Expand Up @@ -201,6 +209,8 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error
weightedAttestationDataSoftTimeout: time.Duration(float64(opt.CommonTimeout) / 2.5),
weightedAttestationDataHardTimeout: opt.CommonTimeout,
proposalSoftTimeout: opt.ProposalSoftTimeout,
proposalSoftDeadline: opt.ProposalSoftDeadline,
blockFetchPath: opt.BlockFetchPath,
supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock},
activatedClients: hashmap.New[string, struct{}](),
}
Expand Down
228 changes: 205 additions & 23 deletions beacon/goclient/options.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package goclient

import (
"fmt"
"time"

"github.com/ssvlabs/ssv/networkconfig"
Expand All @@ -14,6 +15,89 @@ const (
defaultLongTimeout = time.Second * 60
)

// BlockFetchPath identifies which block-header fetch strategy the SSV node is using.
// Determined at startup from operator-provided config; see DetermineBlockFetchPath.
//
// Documented end-to-end in docs/MEV_CONSIDERATIONS.md.
type BlockFetchPath int

const (
// BlockFetchPathSafe is the default. Multi-BN parallel fetch with early-exit on
// first blinded response; fallback at slot-relative ProposalSoftDeadline
// (defaults to DefaultProposalSoftDeadline when the operator hasn't set it).
BlockFetchPathSafe BlockFetchPath = iota
// BlockFetchPathLegacy preserves the original ProposerDelay / ProposalSoftTimeout
// behavior bit-for-bit; selected when an operator has set either of those legacy knobs.
BlockFetchPathLegacy
// BlockFetchPathMEVOptimized is opt-in. Multi-BN parallel fetch without early-exit,
// returns the best-scored response collected by ProposalSoftDeadline. Selected when an
// operator sets ProposalSoftDeadline explicitly.
BlockFetchPathMEVOptimized
)

// String returns a human-readable label for logging.
func (p BlockFetchPath) String() string {
switch p {
case BlockFetchPathSafe:
return "safe"
case BlockFetchPathLegacy:
return "legacy"
case BlockFetchPathMEVOptimized:
return "mev-optimized"
default:
return fmt.Sprintf("unknown(%d)", int(p))
}
}

// ProposalSoftDeadline bounds and defaults. Values are slot-relative (measured from slot start).
const (
// SafeMaxProposalSoftDeadline is the startup-warning threshold for the SSV-side
// ProposalSoftDeadline. Above this value, the worst-case 2-round QBFT scenario
// has no safety margin for latency variance — round 1 effectively has to succeed
// in setups with typical latencies.
//
// Strict math from the typical values in docs/MEV_CONSIDERATIONS.md gives a hard
// upper bound of ProposalSoftDeadline <= 1500ms:
// ProposalSoftDeadline + 2350ms (QBFT worst-case 2-round) +
// 50ms (PostConsensusSigning) + 100ms (BlockSubmission) <= 4000ms (slot deadline)
// => ProposalSoftDeadline <= 1500ms
//
// We set the warning threshold 50ms tighter (1450ms) to preserve a buffer for
// latency variance. Operators following docs/MEV_CONSIDERATIONS.md's recommended
// "PBS cutoff + 50ms BN→SSV transport" formula stay within this threshold when
// their PBS cutoff sits at the recommended ~1400ms ceiling; pushing PBS cutoff
// up to the strict ~1450ms still works in clusters with measurably faster QBFT +
// submission, but consumes the variance buffer (and trips this warning).
SafeMaxProposalSoftDeadline = 1450 * time.Millisecond

// DefaultProposalSoftDeadline is the default deadline used by the safe path when
// the operator hasn't set ProposalSoftDeadline. Equal to SafeMaxProposalSoftDeadline
// — the largest value that keeps the 50ms latency-variance buffer described above.
DefaultProposalSoftDeadline = SafeMaxProposalSoftDeadline

// MinProposalSoftDeadline is the lower bound for operator-set ProposalSoftDeadline
// values. Decoupled from DefaultProposalSoftDeadline so operators can opt into the
// MEV-optimized path with a tighter window than the safe-path default if they want
// (e.g., to match an early PBS cutoff). Set at 1000ms — below this, the BN response
// window becomes too tight for meaningful bid collection across BNs.
MinProposalSoftDeadline = 1000 * time.Millisecond

// MaxProposalSoftDeadline is the hard upper bound for operator-set ProposalSoftDeadline
// values. Intentionally loose — past the SafeMax warning threshold, the operator has
// already opted into "round 1 must succeed". This cap exists to accommodate exceptionally
// performant clusters that can complete the entire post-header pipeline (QBFT round 1 +
// signing + submission) in well under 350ms and want to capture as much of the slot's
// bid growth as possible. Operators in this regime should baseline their own latencies
// (see docs/MEV_CONSIDERATIONS.md "Tuning guidance") before going anywhere near the cap.
MaxProposalSoftDeadline = 3600 * time.Millisecond
)

// Legacy-path constants — preserved for backward-compat.
const (
defaultProposalSoftTimeout = 1800 * time.Millisecond
minProposalSoftTimeout = 500 * time.Millisecond
)

// Options defines beacon client options
type Options struct {
BeaconConfig *networkconfig.Beacon
Expand All @@ -25,42 +109,140 @@ type Options struct {
CommonTimeout time.Duration `yaml:"CommonTimeout" env:"WITH_COMMON_TIMEOUT" env-description:"Specifies the common timeout for network operations"`
LongTimeout time.Duration `yaml:"LongTimeout" env:"WITH_LONG_TIMEOUT" env-description:"Specifies the long timeout for network operations"`

ProposalSoftTimeout time.Duration `yaml:"ProposalSoftTimeout" env:"WITH_PROPOSAL_SOFT_TIMEOUT" env-description:"Specifies the beacon proposal collection soft timeout (collection period for comparing proposals from multiple beacon nodes to select the most profitable one). Note: the 1st MEV (blinded) block is accepted immediately, so this timeout mainly affects how long we wait for an MEV block before giving up deciding to use a vanilla block instead (if we got one already). This value cannot be set any lower than 500ms to ensure there is enough time for the Beacon node to serve the block-fetch request"`
// ProposalSoftTimeout is the legacy collection-period timeout in multi-BN parallel
// fetch. Setting this (or ProposerDelay) selects BlockFetchPathLegacy. New operators
// should prefer ProposalSoftDeadline. See docs/MEV_CONSIDERATIONS.md.
ProposalSoftTimeout time.Duration `yaml:"ProposalSoftTimeout" env:"WITH_PROPOSAL_SOFT_TIMEOUT" env-description:"Legacy MEV configuration. Specifies the beacon proposal collection soft timeout (collection period for comparing proposals from multiple beacon nodes to select the most profitable one). Setting this opts the SSV node into the legacy block-fetch path; the recommended approach is to leave this unset and use ProposalSoftDeadline instead. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."`

// ProposalSoftDeadline is the slot-relative deadline (in ms-into-slot) for the
// multi-BN proposal-collection window used by the safe and MEV-optimized paths.
// - Unset (zero) -> safe path, defaults to DefaultProposalSoftDeadline.
// - Set explicitly -> MEV-optimized path, value must be in
// [MinProposalSoftDeadline, MaxProposalSoftDeadline].
// Cannot be combined with ProposerDelay or ProposalSoftTimeout (which select the
// legacy path).
ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"WITH_PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the multi-BN proposal-collection window. Leave unset for the default safe path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 3600ms]). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."`

// BlockFetchPath is set by NewOptions from the determined path; not directly
// configured by the operator. Consumed by GoClient at runtime to dispatch block
// fetching to the correct strategy.
BlockFetchPath BlockFetchPath `yaml:"-"`
}

func NewOptions(base Options, proposerDelay time.Duration) (Options, error) {
// DetermineBlockFetchPath returns the block-fetch path selected by the operator's config.
//
// Must be called with raw operator-provided values (before NewOptions applies any defaults)
// so that the "operator explicitly set" vs "defaulted" distinction is preserved.
//
// Returns an error when:
// - any of the MEV-related duration knobs is negative; or
// - the config combines legacy knobs (ProposerDelay / ProposalSoftTimeout) with
// the MEV-optimized ProposalSoftDeadline — operators must pick one.
func DetermineBlockFetchPath(base Options, proposerDelay time.Duration) (BlockFetchPath, error) {
// Negative values are nonsensical for any of these and would silently be
// treated as "unset" by the `> 0` checks below — reject them upfront so the
// operator gets a clear startup error instead of a confusing late-firing
// soft-deadline or skipped legacy-path selection.
if proposerDelay < 0 {
return 0, fmt.Errorf("ProposerDelay must be non-negative, got %v", proposerDelay)
}
if base.ProposalSoftTimeout < 0 {
return 0, fmt.Errorf("ProposalSoftTimeout must be non-negative, got %v", base.ProposalSoftTimeout)
}
if base.ProposalSoftDeadline < 0 {
return 0, fmt.Errorf("ProposalSoftDeadline must be non-negative, got %v", base.ProposalSoftDeadline)
}

legacySet := proposerDelay > 0 || base.ProposalSoftTimeout > 0
deadlineSet := base.ProposalSoftDeadline > 0

if legacySet && deadlineSet {
return 0, fmt.Errorf("ProposalSoftDeadline conflicts with legacy ProposerDelay/ProposalSoftTimeout config — remove one. See docs/MEV_CONSIDERATIONS.md for path selection guidance")
}

switch {
case legacySet:
return BlockFetchPathLegacy, nil
case deadlineSet:
return BlockFetchPathMEVOptimized, nil
default:
return BlockFetchPathSafe, nil
}
}

// ValidateProposalSoftDeadline ensures the value is within the acceptable range for
// the MEV-optimized fetch path. The caller is responsible for emitting an additional
// log-warning when the value exceeds SafeMaxProposalSoftDeadline.
func ValidateProposalSoftDeadline(d time.Duration) error {
if d < MinProposalSoftDeadline || d > MaxProposalSoftDeadline {
return fmt.Errorf("ProposalSoftDeadline value %dms is out of range [%dms, %dms]",
d.Milliseconds(),
MinProposalSoftDeadline.Milliseconds(),
MaxProposalSoftDeadline.Milliseconds())
}
return nil
}

// NewOptions applies path-specific defaults to base options and returns the result.
//
// path is the value returned by DetermineBlockFetchPath. proposerDelay is only consumed
// when path == BlockFetchPathLegacy. The selected path is stashed in the returned
// Options.BlockFetchPath for consumption by GoClient at runtime.
func NewOptions(base Options, proposerDelay time.Duration, path BlockFetchPath) (Options, error) {
options := base
options.BlockFetchPath = path

if options.CommonTimeout == 0 {
options.CommonTimeout = defaultCommonTimeout
}

if options.LongTimeout == 0 {
options.LongTimeout = defaultLongTimeout
}

// If user explicitly set ProposalSoftTimeout, use it as-is (power user mode).
// Otherwise, use the default value and reduce it by proposer delay if needed.
if options.ProposalSoftTimeout == 0 {
// The default value shouldn't be too high because an operator might not be able to participate
// in QBFT round 2 (or finish it in time) if it is roughly > 2000 ms.
const defaultProposalSoftTimeout = time.Millisecond * 1800
options.ProposalSoftTimeout = defaultProposalSoftTimeout
// Reduce soft timeout by proposer delay to maintain consistent duty-execution timelines
// for different operators in the cluster, ensuring QBFT consensus starts at roughly
// the same time (timing out round 1 at roughly the same time) regardless of proposer
// delay configuration a particular operator is using - operators with higher proposer
// delay start fetching blocks later, so they must have a shorter collection period.
if proposerDelay > 0 {
options.ProposalSoftTimeout -= proposerDelay
switch path {
case BlockFetchPathLegacy:
// Legacy path: preserve the original ProposalSoftTimeout defaulting (1800ms,
// reduced by ProposerDelay, floored at 500ms). Behavior bit-for-bit unchanged
// from before the path split was introduced.
if options.ProposalSoftTimeout == 0 {
options.ProposalSoftTimeout = defaultProposalSoftTimeout
// Reduce by proposer delay to maintain consistent duty-execution timelines
// for different operators in the cluster, ensuring QBFT consensus starts at
// roughly the same time regardless of proposer-delay configuration.
if proposerDelay > 0 {
options.ProposalSoftTimeout -= proposerDelay
}
}
}
if options.ProposalSoftTimeout < minProposalSoftTimeout {
options.ProposalSoftTimeout = minProposalSoftTimeout
}

case BlockFetchPathSafe:
// Safe path: slot-relative deadline, default DefaultProposalSoftDeadline.
//
// The == 0 check is defensive: in production, DetermineBlockFetchPath only
// routes ProposalSoftDeadline == 0 to the safe path (a non-zero value selects
// MEV-optimized), so this branch is always taken when path == safe. The check
// guards tests that construct Options directly and bypass DetermineBlockFetchPath.
if options.ProposalSoftDeadline == 0 {
options.ProposalSoftDeadline = DefaultProposalSoftDeadline
}

case BlockFetchPathMEVOptimized:
// MEV-optimized path: ProposalSoftDeadline is set by the operator. No defaults
// to apply.
//
// Note: range validation via ValidateProposalSoftDeadline is the *caller's*
// responsibility — cli/operator/node.go runs it for production startup, but
// NewOptions does not enforce it. Tests that bypass the CLI should call
// ValidateProposalSoftDeadline themselves if they want the bounds check.

// minProposalSoftTimeout is the minimum soft timeout value allowed.
// It ensures we always have enough time to fetch and compare proposals.
const minProposalSoftTimeout = time.Millisecond * 500
if options.ProposalSoftTimeout < minProposalSoftTimeout {
options.ProposalSoftTimeout = minProposalSoftTimeout
default:
// Defense-in-depth: DetermineBlockFetchPath returns only the three values
// above, but callers that construct Options directly (notably tests) can
// reach here. Reject at startup rather than at per-slot dispatch in
// proposer.go.
return Options{}, fmt.Errorf("unknown block-fetch path %d", path)
}

// Note: There is no hard timeout for proposals. The parent context from the
Expand Down
Loading