From 508ee62df3a1b6125121e84a0a1872aa7de5ba0e Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 29 May 2026 21:50:05 +0300 Subject: [PATCH 01/16] beacon/goclient: split block-fetch into safe / legacy / MEV-optimized paths + rewrite MEV doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config resolution + validation lives in cli/operator/config.go behind config.resolveAndValidate (consolidated in stage via #2866); this commit extends that resolution with block-fetch path selection and has goclient consume the resolved values. beacon/goclient: - New BlockFetchPath enum (safe / legacy / MEV-optimized) + dispatch in GetBeaconBlock. - getProposalParallelByDeadline (slot-relative deadline; safe early-exits on first blinded, MEV-optimized collects best-scored bid) + getProposalParallelLegacy (relative-timeout, preserved). Options gains ProposalSoftDeadline + BlockFetchPath. - New() applies the CommonTimeout/LongTimeout defaults directly; NewOptions removed. goclient consumes pre-resolved, pre-validated values — it owns mechanism, not config policy. cli/operator/config.go: - resolveAndValidate extended with block-fetch resolution (resolveBlockFetch): determine the path, validate the path-specific knob (legacy ProposerDelay / MEV ProposalSoftDeadline), resolve defaults onto cfg.ConsensusClient, set BlockFetchPath, and emit advisory warnings. - determineBlockFetchPath + validateProposalSoftDeadline + the bound consts moved here from goclient (business policy lives at the config boundary). cli/operator/node.go: drop the separate goclient.NewOptions step — resolveAndValidate (already run before goclient.New) resolves the block-fetch values onto cfg.ConsensusClient, which goclient.New now consumes directly. docs/MEV_CONSIDERATIONS.md + config.example.yaml: rewrite around PBS-side timing games, with ProposerDelay reframed as the legacy path. --- beacon/goclient/attest_test.go | 19 +- beacon/goclient/events_test.go | 8 +- beacon/goclient/goclient.go | 28 +- beacon/goclient/options.go | 90 ++--- beacon/goclient/options_test.go | 24 ++ beacon/goclient/proposer.go | 217 +++++++++++- .../goclient/proposer_path_dispatch_test.go | 249 +++++++++++++ beacon/goclient/proposer_test.go | 86 +++-- cli/operator/config.go | 158 +++++++-- cli/operator/config_test.go | 104 +++++- cli/operator/node.go | 11 +- config/config.example.yaml | 38 +- docs/MEV_CONSIDERATIONS.md | 330 +++++++++++++----- protocol/v2/ssv/runner/proposer.go | 11 +- 14 files changed, 1141 insertions(+), 232 deletions(-) create mode 100644 beacon/goclient/options_test.go create mode 100644 beacon/goclient/proposer_path_dispatch_test.go diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index d230c13d75..1295768fb8 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -506,18 +506,13 @@ func createClient( ctx context.Context, beaconServerURL string, withWeightedAttestationData bool) (*GoClient, error) { - opt, err := NewOptions( - Options{ - BeaconNodeAddr: beaconServerURL, - CommonTimeout: defaultHardTimeout, - LongTimeout: time.Second, - WithWeightedAttestationData: withWeightedAttestationData, - }, 0) - if err != nil { - return nil, err - } - - return New(ctx, zap.NewNop(), opt) + return New(ctx, zap.NewNop(), Options{ + BeaconNodeAddr: beaconServerURL, + CommonTimeout: defaultHardTimeout, + LongTimeout: time.Second, + WithWeightedAttestationData: withWeightedAttestationData, + BlockFetchPath: BlockFetchPathSafe, + }) } type beaconServerResponseOptions struct { diff --git a/beacon/goclient/events_test.go b/beacon/goclient/events_test.go index 05b3aa6b9c..8b5da3abb2 100644 --- a/beacon/goclient/events_test.go +++ b/beacon/goclient/events_test.go @@ -251,10 +251,10 @@ func TestNewEventHandler(t *testing.T) { } func eventsTestClient(t *testing.T, serverURL string) *GoClient { - opt, err := NewOptions(Options{BeaconNodeAddr: serverURL}, 0) - require.NoError(t, err) - - server, err := New(t.Context(), zap.NewNop(), opt) + server, err := New(t.Context(), zap.NewNop(), Options{ + BeaconNodeAddr: serverURL, + BlockFetchPath: BlockFetchPathSafe, + }) require.NoError(t, err) return server diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index de823542c1..5c088df8cb 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -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, @@ -188,6 +196,16 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error return nil, fmt.Errorf("no beacon node address provided") } + // Apply mechanical network-timeout defaults (previously done by NewOptions, now removed). + // Block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline / BlockFetchPath) arrive + // pre-resolved from cli/operator config resolution. + if opt.CommonTimeout == 0 { + opt.CommonTimeout = defaultCommonTimeout + } + if opt.LongTimeout == 0 { + opt.LongTimeout = defaultLongTimeout + } + beaconAddrList := strings.Split(opt.BeaconNodeAddr, ";") client := &GoClient{ @@ -201,6 +219,8 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error weightedAttestationDataSoftTimeout: time.Duration(float64(opt.CommonTimeout) / 2.5), weightedAttestationDataHardTimeout: opt.CommonTimeout, proposalSoftTimeout: opt.ProposalSoftTimeout, + proposalSoftDeadline: opt.ProposalSoftDeadline, + blockFetchPath: opt.BlockFetchPath, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), } diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index fae1208e58..3cc6154baa 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -1,6 +1,7 @@ package goclient import ( + "fmt" "time" "github.com/ssvlabs/ssv/networkconfig" @@ -14,6 +15,39 @@ const ( defaultLongTimeout = time.Second * 60 ) +// BlockFetchPath identifies which block-header fetch strategy the SSV node is using. +// Determined at startup from operator-provided config by cli/operator config resolution. +// +// 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. + 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)) + } +} + // Options defines beacon client options type Options struct { BeaconConfig *networkconfig.Beacon @@ -25,47 +59,21 @@ 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"` -} - -func NewOptions(base Options, proposerDelay time.Duration) (Options, error) { - options := base - - 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 - } - } - - // 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 - } + // 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."` - // Note: There is no hard timeout for proposals. The parent context from the - // duty runner (bounded by slot timing) serves as the ultimate deadline. - // This ensures we never give up early on getting a block proposal. + // 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 the safe-path default deadline. + // - Set explicitly -> MEV-optimized path. + // 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."` - return options, nil + // BlockFetchPath is set by cli/operator config resolution 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:"-"` } diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go new file mode 100644 index 0000000000..64d387b0e1 --- /dev/null +++ b/beacon/goclient/options_test.go @@ -0,0 +1,24 @@ +package goclient + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBlockFetchPath_String(t *testing.T) { + tests := []struct { + path BlockFetchPath + want string + }{ + {BlockFetchPathSafe, "safe"}, + {BlockFetchPathLegacy, "legacy"}, + {BlockFetchPathMEVOptimized, "mev-optimized"}, + {BlockFetchPath(99), "unknown(99)"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.path.String()) + }) + } +} diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index a072c476f0..e467926210 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -106,8 +106,19 @@ func (gc *GoClient) GetBeaconBlock( return nil, nil, err } } else { - // For multiple clients, race them in parallel for the fastest response - beaconBlock, err = gc.getProposalParallel(ctx, logger, slot, sig, graffiti) + // For multiple clients, dispatch to the selected block-fetch path. + // Safe and MEV-optimized share an implementation differing only in whether + // to early-exit on the first blinded response. See docs/MEV_CONSIDERATIONS.md. + switch gc.blockFetchPath { + case BlockFetchPathSafe: + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, true /* earlyExitOnBlinded */) + case BlockFetchPathLegacy: + beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) + case BlockFetchPathMEVOptimized: + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, false /* earlyExitOnBlinded */) + default: + return nil, nil, fmt.Errorf("unknown block-fetch path %d", gc.blockFetchPath) + } if err != nil { return nil, nil, err } @@ -155,25 +166,18 @@ func (gc *GoClient) GetBeaconBlock( } } -// getProposalParallel races all beacon nodes and collects proposals for a short time -// and returns the best one according to our score function. -// If no valid proposals are collected in this time it returns the first valid one -// it sees. -// -// This minimizes latency for time-critical block proposals, while still affording -// some time for selecting maximally profitable proposals. Remaining requests are -// canceled immediately to reduce load. +// getProposalParallelLegacy implements the legacy block-fetch path — preserved +// bit-for-bit from the pre-path-split code for backward-compat with operators using +// ProposerDelay / ProposalSoftTimeout. // -// Note: We used to prioritize speed over fee recipient validation - returning -// the first response rather than waiting to compare fee recipients, as missing -// a proposal slot is worse than a nil fee recipient. -// However, it has been observed that the first proposal is usually not the most -// profitable, so we added a little slack time to collect proposals. +// Races all beacon nodes, collects proposals for a short relative-duration timeout +// (gc.proposalSoftTimeout), and returns the best one according to our score function. +// Early-exits on the first blinded response (assumes blinded == MEV). If no valid +// proposals are collected by the soft timeout, returns the first valid one received. // // The parent context (from duty runner, bounded by slot timing) serves as the hard -// deadline. We never give up early on getting a block proposal - missing a proposal -// is catastrophic, so we wait as long as the slot allows. -func (gc *GoClient) getProposalParallel( +// deadline. +func (gc *GoClient) getProposalParallelLegacy( ctx context.Context, logger *zap.Logger, slot phase0.Slot, @@ -315,6 +319,183 @@ collect: return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) } +// proposalFetchResult bundles the outcome of a single per-BN fetch goroutine. +type proposalFetchResult struct { + proposal *api.VersionedProposal + err error + client string +} + +// spawnProposalFetchers starts a goroutine per beacon-node client; each goroutine +// fetches a proposal and writes its result to the returned channel. Used by the +// safe and MEV-optimized block-fetch implementations. +// +// The channel is buffered to `len(gc.clients)` so each goroutine can write without +// blocking even if the consumer has already returned. +func (gc *GoClient) spawnProposalFetchers( + ctx context.Context, + slot phase0.Slot, + sig phase0.BLSSignature, + graffiti [32]byte, +) <-chan proposalFetchResult { + resultCh := make(chan proposalFetchResult, len(gc.clients)) + for _, client := range gc.clients { + go func(c Client) { + proposal, err := gc.fetchProposal(ctx, c, slot, sig, graffiti) + select { + case resultCh <- proposalFetchResult{proposal: proposal, err: err, client: c.Address()}: + case <-ctx.Done(): + // Context canceled, exit without blocking. + } + }(client) + } + return resultCh +} + +// waitForFirstValidProposal returns the first valid proposal received from the +// remaining in-flight fetchers. Used by the safe and MEV-optimized paths as the +// fallback after the soft-deadline collection window has elapsed without producing +// a usable best proposal. Bounded by the parent context's slot deadline. +func (gc *GoClient) waitForFirstValidProposal( + ctx context.Context, + logger *zap.Logger, + slot phase0.Slot, + startCollect time.Time, + resultCh <-chan proposalFetchResult, + pendingClients int, + errs error, +) (*api.VersionedProposal, error) { + for pendingClients > 0 { + select { + case res := <-resultCh: + pendingClients-- + if res.err != nil { + errs = errors.Join(errs, res.err) + continue + } + proposalScore := gc.scoreProposal(res.proposal) + logger.Debug("received proposal; selected first proposal", + zap.String("client", res.client), + zap.Float64("score", proposalScore), + zap.Duration("latency", time.Since(startCollect)), + zap.Int("pending", pendingClients), + zap.Bool("blinded", res.proposal.Blinded), + fields.Slot(slot), + ) + return res.proposal, nil + case <-ctx.Done(): + // Preserve any accumulated BN failure context alongside the parent + // deadline error — operators need both to diagnose missed slots. + return nil, errors.Join(ctx.Err(), errs) + } + } + return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) +} + +// getProposalParallelByDeadline implements the slot-relative-deadline parallel +// block-fetch shared by the safe and MEV-optimized paths. +// +// Spawns a per-BN fetch in parallel; collects responses until the slot-relative +// ProposalSoftDeadline (slot_start + gc.proposalSoftDeadline) fires. The +// earlyExitOnBlinded flag distinguishes the two paths: +// - true (safe path): stops collecting on the first blinded response and +// returns the best seen so far (treats blinded == MEV). +// - false (MEV-optimized path): keeps collecting after blinded so the +// highest-value bid across BNs can be selected. +// +// After the deadline (or early-exit), returns the best-scored proposal collected +// so far, or falls through to waitForFirstValidProposal if nothing usable arrived. +// The parent ctx serves as the hard deadline. +// +// Note: in-flight BN fetches are spawned with the parent ctx (not softCtx), so a +// slow BN's HTTP call may keep running after we return — until the duty's slot +// deadline cancels ctx. Matches legacy behavior; the fetchProposal call's own +// HTTP timeouts bound the worst case. +func (gc *GoClient) getProposalParallelByDeadline( + ctx context.Context, + logger *zap.Logger, + slot phase0.Slot, + sig phase0.BLSSignature, + graffiti [32]byte, + earlyExitOnBlinded bool, +) (*api.VersionedProposal, error) { + // Slot-relative deadline: fires at slot_start + ProposalSoftDeadline regardless + // of when this function is invoked. + slotStart := gc.getBeaconConfig().SlotStartTime(slot) + softCtx, cancelSoft := context.WithDeadline(ctx, slotStart.Add(gc.proposalSoftDeadline)) + defer cancelSoft() + + resultCh := gc.spawnProposalFetchers(ctx, slot, sig, graffiti) + + var errs error + var bestProposal *api.VersionedProposal + var bestScore float64 + var bestClient string + + startCollect := time.Now() + pendingClients := len(gc.clients) +collect: + for pendingClients > 0 { + select { + case res := <-resultCh: + pendingClients-- + + if res.err != nil { + errs = errors.Join(errs, res.err) + continue + } + + proposalScore := gc.scoreProposal(res.proposal) + logger.Debug("received proposal", + zap.String("client", res.client), + zap.Float64("score", proposalScore), + zap.Duration("latency", time.Since(startCollect)), + zap.Int("pending", pendingClients), + zap.Bool("blinded", res.proposal.Blinded), + fields.Slot(slot), + ) + + if bestProposal == nil || + proposalScore > bestScore || + // prefer the blinded proposal even if same score as the best so far + (res.proposal.Blinded && proposalScore == bestScore) { + bestProposal = res.proposal + bestScore = proposalScore + bestClient = res.client + } + + if earlyExitOnBlinded && res.proposal.Blinded { + // Safe path: treat blinded == MEV and stop collecting. We return the + // best seen so far — usually this blinded one, but a higher-scored + // proposal that already arrived wins. + // MEV-optimized path keeps collecting to compare bids across BNs. + break collect + } + + case <-softCtx.Done(): + break collect + } + } + + if bestProposal != nil { + logger.Debug("selected best proposal", + zap.String("client", bestClient), + zap.Float64("score", bestScore), + zap.Bool("blinded", bestProposal.Blinded), + fields.Slot(slot), + ) + return bestProposal, nil + } + + logger.Debug("did not receive any valid proposals during the collection period", + zap.Int("clients", len(gc.clients)), + zap.Int("pending", pendingClients), + fields.Slot(slot), + ) + + return gc.waitForFirstValidProposal(ctx, logger, slot, startCollect, resultCh, pendingClients, errs) +} + // scoreProposal computes a score for a beacon proposal. // see https://github.com/attestantio/vouch/blob/master/strategies/beaconblockproposal/best/score.go as well func (gc *GoClient) scoreProposal( diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go new file mode 100644 index 0000000000..7b4b8cb08a --- /dev/null +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -0,0 +1,249 @@ +package goclient + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/observability/log" +) + +// Tests for the block-fetch path dispatch (BlockFetchPathSafe / Legacy / MEVOptimized). +// See docs/MEV_CONSIDERATIONS.md for path semantics. + +// TestNew_StoresBlockFetchPath verifies that the selected path and its associated +// timing field (proposalSoftDeadline / proposalSoftTimeout) get propagated from +// Options into the resulting GoClient. +func TestNew_StoresBlockFetchPath(t *testing.T) { + for _, path := range []BlockFetchPath{BlockFetchPathSafe, BlockFetchPathLegacy, BlockFetchPathMEVOptimized} { + t.Run(path.String(), func(t *testing.T) { + server, _ := createProposalBeaconServer(t, beaconProposalServerOptions{}) + defer server.Close() + + // In production these resolved values come from cli/operator config resolution; + // here we set them explicitly and verify New propagates them onto the GoClient. + base := Options{ + BeaconNodeAddr: server.URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + BlockFetchPath: path, + } + switch path { + case BlockFetchPathSafe, BlockFetchPathMEVOptimized: + base.ProposalSoftDeadline = 1100 * time.Millisecond + case BlockFetchPathLegacy: + base.ProposalSoftTimeout = 1800 * time.Millisecond + } + + client, err := New(t.Context(), log.TestLogger(t), base) + require.NoError(t, err) + + assert.Equal(t, path, client.blockFetchPath, "GoClient.blockFetchPath should reflect opt.BlockFetchPath") + + switch path { + case BlockFetchPathSafe, BlockFetchPathMEVOptimized: + assert.Equal(t, 1100*time.Millisecond, client.proposalSoftDeadline, + "New should propagate ProposalSoftDeadline") + case BlockFetchPathLegacy: + assert.Equal(t, 1800*time.Millisecond, client.proposalSoftTimeout, + "New should propagate ProposalSoftTimeout") + } + }) + } +} + +// TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded verifies the safe path's +// early-exit-on-first-blinded behavior. With one fast and one slow BN both returning +// blinded proposals, the safe path should return quickly after the fast BN responds, +// without waiting for the slow one. +func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1500*time.Millisecond) + + // Use a slot starting in the near future so the slot-relative deadline lands + // well after both BN responses (we want to observe the early-exit on blinded, + // not the deadline firing). + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + // Safe path should early-exit on BN1's blinded response (~10ms) and NOT wait for + // BN2 (~500ms). The 350ms ceiling sits well below BN2's response time while + // tolerating HTTP / goroutine / loaded-CI overhead. + assert.Less(t, elapsed, 350*time.Millisecond, + "safe path should early-exit on first blinded; took %v", elapsed) +} + +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit verifies that the MEV-optimized +// path does NOT early-exit on the first blinded response — it keeps collecting until all +// BNs respond (or the soft deadline fires). With the same setup as the safe-path test, +// the MEV-optimized path should wait for the slow BN. +func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + // MEV-optimized path should NOT early-exit; it waits for BN2's response at ~500ms + // before returning the best-scored proposal. The 400ms floor tolerates clock jitter. + assert.GreaterOrEqual(t, elapsed, 400*time.Millisecond, + "MEV-optimized path should wait for the slower BN; took %v", elapsed) +} + +// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins verifies that +// when multiple BNs return blinded proposals within the collection window, the +// MEV-optimized path selects the one with the highest scoreProposal value (sum of +// ConsensusValue and ExecutionValue) rather than the first-arriving one. BN1 returns +// a fast low-value blinded; BN2 returns a slow high-value blinded — the function must +// return BN2's proposal. +func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + ExecutionValue: big.NewInt(1_000_000), // low bid + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 300 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + ExecutionValue: big.NewInt(5_000_000), // high bid (must win) + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + require.NoError(t, err) + require.NotNil(t, versionedProposal) + + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllTwos(), actualFeeRecipient, + "MEV-optimized path should select the higher-value blinded (BN2's), not the first-arriving (BN1's)") +} + +// TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid verifies that +// when the slot-relative soft deadline has already fired before any BN responds, +// the parallel-fetch path falls through to waitForFirstValidProposal and returns +// the first valid BN response. Uses a slot in the past so the deadline is past. +func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 200 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1000*time.Millisecond) + + // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative + // deadline = slotStart + 1000ms is also in the past, so softCtx is already + // done when the collection loop starts. + pastSlot := phase0.Slot(1) + + start := time.Now() + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), pastSlot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err, "fallback to first-valid should return successfully") + require.NotNil(t, versionedProposal) + + // Primary assertion: BN1's fee recipient confirms we returned with the first + // valid response (BN1 at ~200ms), not the slower BN2 (~500ms). This is robust + // against timing jitter on busy CI runners. + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, + "waitForFirstValidProposal should return BN1's response (first valid), not BN2's") + + // Sanity check on elapsed: must be at least BN1's response time, and the upper + // bound just confirms we didn't end up waiting for BN2. Margins kept generous + // for CI scheduling overhead. + assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, + "should have waited for first BN response (~200ms); took %v", elapsed) + assert.Less(t, elapsed, 450*time.Millisecond, + "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) +} + +// setupMultiBNClient builds a GoClient connected to two test BN servers via +// semicolon-separated URLs, with the given block-fetch path and deadline. Used by +// the per-path behavior tests. +func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, path BlockFetchPath, deadline time.Duration) *GoClient { + t.Helper() + + client, err := New(t.Context(), log.TestLogger(t), Options{ + BeaconNodeAddr: bn1URL + ";" + bn2URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalSoftDeadline: deadline, + BlockFetchPath: path, + }) + require.NoError(t, err) + return client +} + +// feeRecipientAllOnes returns a fee-recipient address filled with 0x01 bytes, +// distinguishable from feeRecipientAllTwos in test assertions. +func feeRecipientAllOnes() bellatrix.ExecutionAddress { + var addr bellatrix.ExecutionAddress + for i := range addr { + addr[i] = 1 + } + return addr +} + +// feeRecipientAllTwos returns a fee-recipient address filled with 0x02 bytes. +func feeRecipientAllTwos() bellatrix.ExecutionAddress { + var addr bellatrix.ExecutionAddress + for i := range addr { + addr[i] = 2 + } + return addr +} diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index adf02efb16..3a863f7346 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "net/http" "net/http/httptest" "strconv" @@ -53,6 +54,10 @@ type beaconProposalServerOptions struct { FeeRecipient bellatrix.ExecutionAddress // Use blinded proposal BlindedProposal bool + // Optional Eth-Execution-Payload-Value header value; left nil ⇒ header not set + // (go-eth2-client defaults ExecutionValue to 0). Used by tests that need to + // influence scoreProposal across multiple BN responses. + ExecutionValue *big.Int } // Creates a mock beacon server for proposal testing @@ -90,11 +95,7 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption // Return custom response if provided if len(options.ProposalResponse) > 0 { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Eth-Consensus-Version", "electra") - if options.BlindedProposal { - w.Header().Set("Eth-Execution-Payload-Blinded", "true") - } + writeProposalHeaders(w, options) if _, err := w.Write(options.ProposalResponse); err != nil { w.WriteHeader(http.StatusInternalServerError) } @@ -103,11 +104,7 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption // Generate response dynamically (this should not cause races since each server has its own goroutine) proposalResp := createProposalResponseSafe(phase0.Slot(slot), options.FeeRecipient, options.BlindedProposal) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Eth-Consensus-Version", "electra") - if options.BlindedProposal { - w.Header().Set("Eth-Execution-Payload-Blinded", "true") - } + writeProposalHeaders(w, options) if _, err := w.Write(proposalResp); err != nil { w.WriteHeader(http.StatusInternalServerError) } @@ -128,8 +125,34 @@ func createProposalBeaconServer(t *testing.T, options beaconProposalServerOption return server, serverGotRequests } -// Create a safe proposal response using ssv-spec utilities (called once during server setup) +// writeProposalHeaders sets the response headers that go-eth2-client parses to +// reconstruct VersionedProposal — Eth-Consensus-Version, the blinded flag, and +// optionally Eth-Execution-Payload-Value when the test wants to influence +// scoreProposal across multiple BN responses. +func writeProposalHeaders(w http.ResponseWriter, options beaconProposalServerOptions) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "electra") + if options.BlindedProposal { + w.Header().Set("Eth-Execution-Payload-Blinded", "true") + } + if options.ExecutionValue != nil { + w.Header().Set("Eth-Execution-Payload-Value", options.ExecutionValue.String()) + } +} + +// spectestingMu serializes mutations on the shared ssv-spec testing fixtures. +// TestingBlindedBeaconBlockV / TestingBeaconBlockV return wrappers that point at +// cached block structs; without this lock, concurrent test-server goroutines +// (e.g., the multi-BN tests) racially mutate the same struct on `block.Slot` and +// `block.Body.*.FeeRecipient`. The JSON marshaling happens inside the lock so +// each call captures its own intended state before the next mutation begins. +var spectestingMu sync.Mutex + +// Create a safe proposal response using ssv-spec utilities. func createProposalResponseSafe(slot phase0.Slot, feeRecipient bellatrix.ExecutionAddress, blinded bool) []byte { + spectestingMu.Lock() + defer spectestingMu.Unlock() + if blinded { // Get a blinded block from ssv-spec testing utilities versionedBlinded := spectestingutils.TestingBlindedBeaconBlockV(spec.DataVersionElectra) @@ -388,26 +411,25 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { feeRecipient2 := bellatrix.ExecutionAddress{0x22} feeRecipient3 := bellatrix.ExecutionAddress{0x33} - // Pre-generate block responses to avoid race conditions - blockResponse1 := createProposalResponseSafe(testSlot, feeRecipient1, false) - blockResponse2 := createProposalResponseSafe(testSlot, feeRecipient2, false) - blockResponse3 := createProposalResponseSafe(testSlot, feeRecipient3, false) - + // Responses are generated per-request from the URL slot (rather than + // pre-generated) so the safe path's slot-relative deadline can be set against + // a future slot below — pre-baking a fixed slot would trip go-eth2-client's + // "expected slot N" response check. server1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 500 * time.Millisecond, - ProposalResponse: blockResponse1, + FeeRecipient: feeRecipient1, }) defer server1.Close() server2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 50 * time.Millisecond, // Fastest - ProposalResponse: blockResponse2, + FeeRecipient: feeRecipient2, }) defer server2.Close() server3, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 1000 * time.Millisecond, - ProposalResponse: blockResponse3, + FeeRecipient: feeRecipient3, }) defer server3.Close() @@ -418,8 +440,13 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { graffiti := []byte(testGraffiti) randao := getTestRANDAO() + // Use a future slot so the safe path's slot-relative ProposalSoftDeadline doesn't + // fire before the collection loop starts — otherwise this test would exercise + // waitForFirstValidProposal instead of the multi-BN scoring/racing logic. + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + startTime := time.Now() - versionedProposal, marshaledBlk, err := client.GetBeaconBlock(t.Context(), testSlot, graffiti, randao) + versionedProposal, marshaledBlk, err := client.GetBeaconBlock(t.Context(), slot, graffiti, randao) elapsed := time.Since(startTime) require.NoError(t, err) @@ -679,15 +706,12 @@ func TestProposalPreparationReconnectLogic_SkipsOnNilProvider(t *testing.T) { } func createClientForProposerTest(t *testing.T, serverURL string) (*GoClient, error) { - opt, err := NewOptions( - Options{ - BeaconNodeAddr: serverURL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - }, 0) - if err != nil { - return nil, err - } - - return New(t.Context(), log.TestLogger(t), opt) + return New(t.Context(), log.TestLogger(t), Options{ + BeaconNodeAddr: serverURL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + BlockFetchPath: BlockFetchPathSafe, + // safe-path slot-relative deadline (config resolution defaults this in production). + ProposalSoftDeadline: 1450 * time.Millisecond, + }) } diff --git a/cli/operator/config.go b/cli/operator/config.go index b82c16b026..5ada206e42 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -57,10 +57,25 @@ type config struct { EnableDoppelgangerProtection bool `yaml:"EnableDoppelgangerProtection" env:"ENABLE_DOPPELGANGER_PROTECTION" env-description:"Enable doppelganger protection for validators"` } -// maxSafeProposerDelay is the largest ProposerDelay considered safe. Above this, the -// worst-case 2-round QBFT scenario risks missing the slot, so the operator must explicitly -// acknowledge the risk via AllowDangerousProposerDelay. -const maxSafeProposerDelay = 1000 * time.Millisecond +// Block-fetch tuning bounds (operator-policy thresholds; goclient consumes the resolved +// values, not these). See docs/MEV_CONSIDERATIONS.md for the derivations. +const ( + // maxSafeProposerDelay is the largest ProposerDelay considered safe. Above this, the + // worst-case 2-round QBFT scenario risks missing the slot, so the operator must explicitly + // acknowledge the risk via AllowDangerousProposerDelay. + maxSafeProposerDelay = 1000 * time.Millisecond + + // ProposalSoftDeadline bounds (slot-relative). safeMaxProposalSoftDeadline is the + // startup-warning threshold; the safe path defaults to it. + safeMaxProposalSoftDeadline = 1450 * time.Millisecond + defaultProposalSoftDeadline = safeMaxProposalSoftDeadline + minProposalSoftDeadline = 1000 * time.Millisecond + maxProposalSoftDeadline = 3600 * time.Millisecond + + // Legacy-path soft-timeout defaulting (1800ms, reduced by ProposerDelay, floored at 500ms). + defaultProposalSoftTimeout = 1800 * time.Millisecond + minProposalSoftTimeout = 500 * time.Millisecond +) // nodeMode is the resolved operating mode of the node, derived once from ExporterOptions by // resolveAndValidate so startup can dispatch on a typed value instead of re-deriving the mode @@ -105,12 +120,14 @@ func (c *config) load(configPath, shareConfigPath string) error { return nil } -// resolveAndValidate validates the operator configuration, emits advisory warnings, and returns -// the derived state (operating mode + signing flags). A returned error is fatal — the caller logs -// it once. logger is used only for warnings, never for fatal conditions. +// resolveAndValidate resolves + validates the operator configuration: it mutates +// c.ConsensusClient with the resolved block-fetch values (consumed by goclient), emits advisory +// warnings/info (via logger), and returns the derived state (operating mode + signing flags). A +// returned error is fatal — the caller logs it once. logger is used only for warnings/info, +// never for fatal conditions. func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { - // Resolve signing before the proposer-delay check so a doubly-misconfigured node surfaces - // the signing error first. + // Resolve signing first so a doubly-misconfigured node surfaces the signing error before the + // block-fetch one (matches the pre-MEV ordering). var res resolved if c.ExporterOptions.Enabled { c.warnExporterSigning(logger) @@ -123,19 +140,13 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { } } - // ProposerDelay validation runs in both exporter and non-exporter modes. - if err := validateProposerDelay(c.ProposerDelay, c.AllowDangerousProposerDelay); err != nil { + // Block-fetch: select the path, validate + resolve its knobs onto c.ConsensusClient. + if err := c.resolveBlockFetch(logger); err != nil { return resolved{}, err } - if c.ProposerDelay > maxSafeProposerDelay { - // Reachable only after validateProposerDelay passed, i.e. AllowDangerousProposerDelay is set. - logger.Warn("Using dangerous ProposerDelay value that may cause missed block proposals", - zap.Duration("proposer_delay", c.ProposerDelay), - zap.Duration("max_safe_proposer_delay", maxSafeProposerDelay)) - } // Resolve the operating mode last so a doubly-misconfigured node still surfaces the signing - // or proposer-delay error first. + // or block-fetch error first. m, err := resolveMode(c.ExporterOptions) if err != nil { return resolved{}, err @@ -145,14 +156,121 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { return res, nil } +// resolveBlockFetch determines the block-fetch path from the operator's config, validates the +// path-specific knobs, resolves their defaults onto c.ConsensusClient (consumed by goclient at +// runtime), and emits advisory warnings. A returned error is fatal. +func (c *config) resolveBlockFetch(logger *zap.Logger) error { + path, err := determineBlockFetchPath(c.ConsensusClient, c.ProposerDelay) + if err != nil { + return err + } + + switch path { + case goclient.BlockFetchPathLegacy: + if err := validateProposerDelay(c.ProposerDelay, c.AllowDangerousProposerDelay); err != nil { + return err + } + // Default the legacy soft timeout: 1800ms reduced by ProposerDelay, floored at 500ms. + if c.ConsensusClient.ProposalSoftTimeout == 0 { + c.ConsensusClient.ProposalSoftTimeout = defaultProposalSoftTimeout + if c.ProposerDelay > 0 { + c.ConsensusClient.ProposalSoftTimeout -= c.ProposerDelay + } + } + if c.ConsensusClient.ProposalSoftTimeout < minProposalSoftTimeout { + c.ConsensusClient.ProposalSoftTimeout = minProposalSoftTimeout + } + case goclient.BlockFetchPathMEVOptimized: + if err := validateProposalSoftDeadline(c.ConsensusClient.ProposalSoftDeadline); err != nil { + return err + } + case goclient.BlockFetchPathSafe: + if c.ConsensusClient.ProposalSoftDeadline == 0 { + c.ConsensusClient.ProposalSoftDeadline = defaultProposalSoftDeadline + } + } + + c.ConsensusClient.BlockFetchPath = path + logger.Info("block-fetch path selected", zap.String("path", path.String())) + + // Advisory warnings — emitted after validation, so they never precede a validation error. + switch path { + case goclient.BlockFetchPathLegacy: + if c.ProposerDelay > maxSafeProposerDelay { + // Reachable only after validateProposerDelay passed (AllowDangerousProposerDelay set). + logger.Warn("Using dangerous ProposerDelay value that may cause missed block proposals", + zap.Int64("proposer_delay_ms", c.ProposerDelay.Milliseconds()), + zap.Int64("max_safe_proposer_delay_ms", maxSafeProposerDelay.Milliseconds())) + } + case goclient.BlockFetchPathMEVOptimized: + if c.ConsensusClient.ProposalSoftDeadline > safeMaxProposalSoftDeadline { + logger.Warn( + "ProposalSoftDeadline exceeds the safe-max threshold: "+ + "round-2 QBFT fallback may not fit within the slot deadline "+ + "for clusters with typical latencies, "+ + "so the slot may be missed when round 1 fails "+ + "(this is an explicit 'round 1 must succeed' configuration).", + zap.Int64("proposal_soft_deadline_ms", c.ConsensusClient.ProposalSoftDeadline.Milliseconds()), + zap.Int64("safe_max_ms", safeMaxProposalSoftDeadline.Milliseconds())) + } + case goclient.BlockFetchPathSafe: + // Safe path has no advisory warning — its default deadline sits at the safe-max. + } + + return nil +} + +// determineBlockFetchPath selects the block-fetch path from the operator's raw config. Negative +// durations and combining legacy knobs (ProposerDelay/ProposalSoftTimeout) with the MEV-optimized +// ProposalSoftDeadline are rejected. +func determineBlockFetchPath(base goclient.Options, proposerDelay time.Duration) (goclient.BlockFetchPath, error) { + 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 goclient.BlockFetchPathLegacy, nil + case deadlineSet: + return goclient.BlockFetchPathMEVOptimized, nil + default: + return goclient.BlockFetchPathSafe, nil + } +} + +// validateProposalSoftDeadline ensures an operator-set ProposalSoftDeadline (MEV-optimized path) +// is within the acceptable range. The safe-max advisory warning is emitted separately. +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 +} + // validateProposerDelay rejects a ProposerDelay above maxSafeProposerDelay unless the operator // explicitly acknowledges the risk via allowDangerous. func validateProposerDelay(proposerDelay time.Duration, allowDangerous bool) error { if proposerDelay > maxSafeProposerDelay && !allowDangerous { - return fmt.Errorf("ProposerDelay value %v exceeds maximum safe delay of %v. "+ + return fmt.Errorf("ProposerDelay value %dms exceeds maximum safe delay of %dms. "+ "This may cause missed block proposals. "+ "If you understand the risks and want to proceed, set AllowDangerousProposerDelay to true or use the ALLOW_DANGEROUS_PROPOSER_DELAY environment variable", - proposerDelay, maxSafeProposerDelay) + proposerDelay.Milliseconds(), maxSafeProposerDelay.Milliseconds()) } return nil } diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index 64012a59c7..adc74d7acd 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" + "github.com/ssvlabs/ssv/beacon/goclient" "github.com/ssvlabs/ssv/exporter" "github.com/ssvlabs/ssv/networkconfig" operatorstorage "github.com/ssvlabs/ssv/operator/storage" @@ -42,7 +43,7 @@ func Test_config_load(t *testing.T) { // resolveAndValidate (validation itself is covered by Test_validateProposerDelay). A minimal // operator signing source is set so resolveSigning passes and these cases isolate proposer-delay. func Test_resolveAndValidate_proposerDelay(t *testing.T) { - t.Run("dangerous delay with flag - warns with duration fields", func(t *testing.T) { + t.Run("dangerous delay with flag - warns with ms fields", func(t *testing.T) { for _, delay := range []time.Duration{1001 * time.Millisecond, 2000 * time.Millisecond, 5000 * time.Millisecond} { t.Run(delay.String(), func(t *testing.T) { core, recorded := observer.New(zapcore.WarnLevel) @@ -61,8 +62,8 @@ func Test_resolveAndValidate_proposerDelay(t *testing.T) { require.Contains(t, logs[0].Message, "may cause missed block proposals") fields := logs[0].ContextMap() - require.Equal(t, delay, fields["proposer_delay"]) - require.Equal(t, 1000*time.Millisecond, fields["max_safe_proposer_delay"]) + require.Equal(t, delay.Milliseconds(), fields["proposer_delay_ms"]) + require.Equal(t, int64(1000), fields["max_safe_proposer_delay_ms"]) }) } }) @@ -186,6 +187,103 @@ func Test_validateProposerDelay(t *testing.T) { } } +func TestDetermineBlockFetchPath(t *testing.T) { + tests := []struct { + name string + opts goclient.Options + proposerDelay time.Duration + want goclient.BlockFetchPath + wantErr string + }{ + {name: "nothing set -> safe", want: goclient.BlockFetchPathSafe}, + {name: "ProposerDelay -> legacy", proposerDelay: 300 * time.Millisecond, want: goclient.BlockFetchPathLegacy}, + {name: "ProposalSoftTimeout -> legacy", opts: goclient.Options{ProposalSoftTimeout: 1500 * time.Millisecond}, want: goclient.BlockFetchPathLegacy}, + {name: "ProposalSoftDeadline -> mev-optimized", opts: goclient.Options{ProposalSoftDeadline: 1100 * time.Millisecond}, want: goclient.BlockFetchPathMEVOptimized}, + {name: "legacy + deadline -> conflict", opts: goclient.Options{ProposalSoftDeadline: 1100 * time.Millisecond}, proposerDelay: 300 * time.Millisecond, wantErr: "conflicts with legacy"}, + {name: "negative ProposerDelay -> error", proposerDelay: -1, wantErr: "ProposerDelay must be non-negative"}, + {name: "negative ProposalSoftTimeout -> error", opts: goclient.Options{ProposalSoftTimeout: -1}, wantErr: "ProposalSoftTimeout must be non-negative"}, + {name: "negative ProposalSoftDeadline -> error", opts: goclient.Options{ProposalSoftDeadline: -1}, wantErr: "ProposalSoftDeadline must be non-negative"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := determineBlockFetchPath(tt.opts, tt.proposerDelay) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestValidateProposalSoftDeadline(t *testing.T) { + tests := []struct { + name string + value time.Duration + wantErr bool + }{ + {"at min 1000ms -> ok", 1000 * time.Millisecond, false}, + {"below min 999ms -> error", 999 * time.Millisecond, true}, + {"at safe-max 1450ms -> ok", 1450 * time.Millisecond, false}, + {"above safe-max 2500ms -> ok", 2500 * time.Millisecond, false}, + {"at max 3600ms -> ok", 3600 * time.Millisecond, false}, + {"above max 3601ms -> error", 3601 * time.Millisecond, true}, + {"zero -> error", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProposalSoftDeadline(tt.value) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), "out of range") + return + } + require.NoError(t, err) + }) + } +} + +func Test_resolveBlockFetch_defaults(t *testing.T) { + t.Run("safe path defaults deadline to 1450ms and sets path", func(t *testing.T) { + c := config{} + require.NoError(t, c.resolveBlockFetch(zap.NewNop())) + require.Equal(t, goclient.BlockFetchPathSafe, c.ConsensusClient.BlockFetchPath) + require.Equal(t, 1450*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + }) + + t.Run("legacy path defaults soft timeout to 1800ms - delay", func(t *testing.T) { + c := config{} + c.ProposerDelay = 300 * time.Millisecond + require.NoError(t, c.resolveBlockFetch(zap.NewNop())) + require.Equal(t, goclient.BlockFetchPathLegacy, c.ConsensusClient.BlockFetchPath) + require.Equal(t, 1500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) + }) + + t.Run("legacy path floors soft timeout at 500ms", func(t *testing.T) { + c := config{} + c.ProposerDelay = 1500 * time.Millisecond // 1800-1500=300, floored to 500 + c.AllowDangerousProposerDelay = true // 1500ms > maxSafe, must be acknowledged + require.NoError(t, c.resolveBlockFetch(zap.NewNop())) + require.Equal(t, 500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) + }) + + t.Run("mev-optimized path keeps operator deadline and sets path", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond + require.NoError(t, c.resolveBlockFetch(zap.NewNop())) + require.Equal(t, goclient.BlockFetchPathMEVOptimized, c.ConsensusClient.BlockFetchPath) + require.Equal(t, 1850*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + }) + + t.Run("mev-optimized out-of-range deadline -> error", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 5000 * time.Millisecond + require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "out of range") + }) +} + func Test_validateConfig(t *testing.T) { logger := zap.New(zapcore.NewNopCore(), zap.WithFatalHook(zapcore.WriteThenPanic)) diff --git a/cli/operator/node.go b/cli/operator/node.go index 704eb20c1c..5351bb7cce 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -80,14 +80,9 @@ func runNode(ctx context.Context, cfg *config, logger *zap.Logger) error { zap.Bool("with_parallel_submissions", cfg.ConsensusClient.WithParallelSubmissions), ) - cliopt, err := goclient.NewOptions(cfg.ConsensusClient, cfg.ProposerDelay) - if err != nil { - return startupError{ - err: fmt.Errorf("failed to create beacon client options: %w", err), - fields: []zap.Field{fields.Address(cfg.ConsensusClient.BeaconNodeAddr)}, - } - } - consensusClient, err := goclient.New(ctx, logger, cliopt) + // goclient consumes the block-fetch values (BlockFetchPath / ProposalSoftTimeout / + // ProposalSoftDeadline) that resolveAndValidate already resolved onto cfg.ConsensusClient. + consensusClient, err := goclient.New(ctx, logger, cfg.ConsensusClient) if err != nil { return startupError{ err: fmt.Errorf("failed to create beacon go-client: %w", err), diff --git a/config/config.example.yaml b/config/config.example.yaml index adc5f36b3c..9e1fb692eb 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,6 +19,30 @@ eth2: # HTTP URL of the Beacon node to connect to. BeaconNodeAddr: http://example.url:5052 + # Block-fetch tuning. The SSV node selects between the safe (default), MEV-optimized, + # and legacy paths at startup based on the settings below; see + # docs/MEV_CONSIDERATIONS.md for the full model. + # + # ProposalSoftDeadline opts the SSV node into the MEV-optimized path: SSV waits for all + # multi-BN responses until this slot-relative deadline (ms into slot) and returns the + # highest-value bid received, without early-exiting on the first MEV block. Match this + # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, + # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not + # fit within the slot for typical clusters). Cannot be combined with ProposerDelay or + # ProposalSoftTimeout. + # Only relevant with multiple Beacon nodes — with a single Beacon node SSV fetches the + # block directly and this setting has no effect. + # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1450ms). + # Note: setting ProposalSoftDeadline = 1450ms is *not* a no-op — it opts into the + # MEV-optimized path at the same numeric deadline the safe path uses by default + # (the difference is behavioral: no early-exit on first blinded, best-bid wins). + # ProposalSoftDeadline: 1100ms + + # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring + # in the legacy block-fetch path. Setting this (or ProposerDelay below) opts into the + # legacy path. New operators should leave this unset and use ProposalSoftDeadline above. + # ProposalSoftTimeout: 1800ms + ValidatorOptions: eth1: @@ -37,13 +61,17 @@ p2p: OperatorPrivateKey: # MEV Configuration (Optional) -# Duration to wait before requesting block proposal if this operator is proposer-duty Leader. -# This allows extracting higher MEV by waiting for better bids. Default is 0 (no delay). -# Recommended starting value: 300ms. See docs/MEV_CONSIDERATIONS.md for details. +# Recommended approach: configure timing games on the PBS layer (mev-boost v1.11+ launched +# with -config, or commit-boost). With PBS-side timing games configured, leave ProposerDelay +# at its default value of 0. See docs/MEV_CONSIDERATIONS.md for details. +# +# ProposerDelay is the legacy SSV-side lever — a delay before requesting the block-proposal, +# extracting higher MEV by waiting for better bids. Use this only when your PBS does not +# support timing games. Default is 0 (no delay). # ProposerDelay: 300ms -# Safety flag to allow ProposerDelay values higher than 1s. -# WARNING: Values above 1s significantly increase the risk of missing block proposals! +# Safety flag to allow ProposerDelay values higher than 1000ms. +# WARNING: Values above 1000ms significantly increase the risk of missing block proposals! # Only set to true if you understand the risks and have carefully read the MEV documentation. # AllowDangerousProposerDelay: false diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 0db7877d38..64d2c22c53 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -1,112 +1,282 @@ -## Getting started with `MEV` configuration +# MEV considerations + +## TL;DR + +To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the multi-BN bid scoring described in [Multi-BN setup](#multi-bn-setup). + +If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. + +**Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: +- configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout` +- if you run multiple Beacon nodes, set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` - see [Multi-BN setup](#multi-bn-setup) for details, single-BN operators can skip that section entirely +- restart SSV node to apply +- set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#configuration-knobs) for details + +## Definitions and typical values + +The variables below name the stages of the SSV proposer-duty timeline. The values are typical/average for a healthy mainnet SSV cluster — real-world variance is significant, and operators should baseline their own latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)) before treating them as hard numbers. + +| Variable | Typical | Description | +|---|---|---| +| `RANDAO` | ~50ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | +| `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-timing-games-recommended). | +| `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | +| `QBFT` | ~2350ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~100ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~250ms successful round 2). | +| `PostConsensusSigning` | ~50ms | Operators reconstruct the validator BLS signature from partial signatures. | +| `BlockSubmission` | ~100ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | + +The Ethereum slot-propagation deadline is **4000ms** after slot start. + +## SSV proposer-duty flow background + +The proposer-duty flow runs the stages above in sequence. For an SSV cluster to function reliably, the following must hold even in the worst case where round 1 fails and round 2 runs as fallback: -To get the most out of MEV opportunities Operator can configure ProposerDelay configuration setting using a configuration -file (or `PROPOSER_DELAY` environment variable): ``` -ProposerDelay: 300ms +RANDAO + (auction window) + QBFT + PostConsensusSigning + BlockSubmission < 4000ms +``` + +You must budget for the worst case: in the common case round 1 succeeds quickly and round 2 never runs, but the budget reserved by the equation cannot be reclaimed. If the equation doesn't hold, the validator risks missing its proposal slot whenever round 1 fails. + +Where `(auction window)` sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. + +## PBS-side timing games (recommended) + +The PBS layer implements "timing games" — proactively polling relays at intervals defined in its own config, decoupling *when* the auction happens from *when* SSV asks for the block. SSV asks once and receives whatever bid the PBS has selected by its slot-relative cutoff. + +Preferred over `ProposerDelay` because: +- The PBS polls each relay multiple times within the auction window (`target_first_request_ms` + `frequency_get_header_ms`), capturing higher-value bids than a single `getHeader` per relay. `ProposerDelay` + single `getHeader` gets one bid per relay at one point in time; PBS-side timing games sample several and keep the best. +- The auction cutoff is slot-relative (`late_in_slot_time_ms`), so QBFT round 1 starts at a predictable point in the slot. With `ProposerDelay`, QBFT starts at `ProposerDelay + variable relay-response time`, which makes the post-auction budget harder to size. +- The PBS handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. +- Configuration is concentrated in the PBS rather than split across SSV-side and PBS-side knobs. + +### Configuration knobs + +Both mev-boost and commit-boost expose the same five knobs: + +- `timeout_get_header_ms` — per-relay-request timeout for a single `getHeader` call. +- `late_in_slot_time_ms` — slot-relative hard cutoff. The PBS returns to the caller no later than this point in the slot. +- `enable_timing_games` (per-relay) — opt in to the multi-poll behavior for this relay. Defaults to `false`; must be set per-relay. +- `target_first_request_ms` — when the first poll for this relay fires, measured from slot start. +- `frequency_get_header_ms` — interval between subsequent polls. + +The effective per-request deadline is: +``` +max_timeout_ms = min(timeout_get_header_ms, late_in_slot_time_ms - ms_into_slot) +slot-relative cutoff = ms_into_slot + max_timeout_ms ``` +When the PBS receives the request early in the slot, `timeout_get_header_ms` tends to bind; when asked later, `late_in_slot_time_ms - ms_into_slot` binds. + +### PBS-specific notes + +- **commit-boost** validates `timeout_get_header_ms < late_in_slot_time_ms` at config load — refuses to start otherwise. Set `timeout_get_header_ms` just below `late_in_slot_time_ms`. +- **mev-boost (v1.11+)** has the same knobs and budget math but does not enforce that inequality — values may be equal. Requires `-config ` to enable the YAML-based timing-games config; `-watch-config` enables hot reload. +- Default `late_in_slot_time_ms`: mev-boost `2000ms`, commit-boost `3000ms`. Both are aggressive. +- mev-boost selects the most-recently-received bid per relay, then compares across relays for the highest value. + +Upstream references: +- mev-boost: [github.com/flashbots/mev-boost/blob/main/docs/timing-games.md](https://github.com/flashbots/mev-boost/blob/main/docs/timing-games.md) +- commit-boost: [commit-boost.github.io/commit-boost-client](https://commit-boost.github.io/commit-boost-client/) -As per our own estimates the max reasonable value of `ProposerDelay` for Ethereum mainnet is around ~1.2s, -although we recommend starting with something like 300ms gradually increasing it up - the higher -`ProposerDelay` value is the higher the chance of missing Ethereum block proposal will be. +## Configuration examples -### Important Safety Limitation +Two scenarios shown for both PBSes. The numbers are starting points for a healthy mainnet cluster — operators should validate against their own measured latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)). -**The SSV node will refuse to start if ProposerDelay is set higher than 1s without explicit confirmation.** +### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) -If you attempt to use a ProposerDelay value higher than 1s, the node will exit with an error message. -If you understand the risks and want to proceed anyway, you must also set the `AllowDangerousProposerDelay` flag: +Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV) instead of legacy's ~1300–2000ms (depending on relay response speed), leaving more slot budget for QBFT and submission. + +The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms. + +**commit-boost** (TOML): +```toml +[pbs] +late_in_slot_time_ms = 1050 +timeout_get_header_ms = 1030 # must be < late_in_slot_time_ms in commit-boost +timeout_get_payload_ms = 4000 + +[[relays]] +url = "https://@relay-1.example" +enable_timing_games = true +target_first_request_ms = 700 # polls at 700ms, 850ms, 1000ms +frequency_get_header_ms = 150 + +[[relays]] +url = "https://@relay-2.example" +enable_timing_games = true +target_first_request_ms = 700 +frequency_get_header_ms = 150 +``` +**mev-boost** (YAML): ```yaml -ProposerDelay: 2000ms -AllowDangerousProposerDelay: true +timeout_get_header_ms: 1050 +late_in_slot_time_ms: 1050 # mev-boost permits equality +relays: + - url: https://@relay-1.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 150 + - url: https://@relay-2.example + enable_timing_games: true + target_first_request_ms: 700 + frequency_get_header_ms: 150 ``` -Or using environment variables: -```bash -PROPOSER_DELAY=2000ms ALLOW_DANGEROUS_PROPOSER_DELAY=true ./bin/ssvnode start-node +**SSV-side** (multi-BN setups only — see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): +```yaml +eth2: + ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport ``` -**Warning:** Using ProposerDelay values higher than 1s significantly increases the risk of missing block proposals, -which can result in penalties and lost rewards. +### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -As per the notes in other sections of this document `ProposerDelay` depends on a number of things, to find -the best value Operator might want to start with lower values like 300ms gradually increasing it up. +Pushes the PBS-side cutoff to `1800ms` — past the ~1450ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1850ms. -## MEV considerations & SSV proposer-duty flow background +The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. + +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2500ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. + +**commit-boost** (TOML): +```toml +[pbs] +late_in_slot_time_ms = 1800 +timeout_get_header_ms = 1780 # must be < late_in_slot_time_ms in commit-boost +timeout_get_payload_ms = 4000 + +[[relays]] +url = "https://@relay-1.example" +enable_timing_games = true +target_first_request_ms = 1000 # polls at 1000ms, 1200ms, 1400ms, 1600ms +frequency_get_header_ms = 200 + +[[relays]] +url = "https://@relay-2.example" +enable_timing_games = true +target_first_request_ms = 1000 +frequency_get_header_ms = 200 +``` -To understand how MEV fits with the SSV cluster, here is some background on the SSV proposer-duty flow: -- SSV node participates in the pre-consensus phase to build RANDAO signature that will be used when - requesting block from Beacon node (let's say it takes `RANDAOTime`) -- SSV node (all nodes in the cluster really to handle round-changes, but current round Leader - specifically) requests blinded block header from Beacon node which in turn "proxies" this request - to MEV-boost that runs with some pre-configured timeout (call it `MEVBoostRelayTimeout`) -- MEV-boost sends multiple requests to Relays it knows about and waits until that - `MEVBoostRelayTimeout` time to choose the best block (based on the corresponding bid) -- SSV node receives the response with the chosen block header and goes through QBFT consensus phase - to sign it as Validator (let's say it takes `QBFTTime` at most - we can estimate it - statistically with some probability/confidence) -- QBFT consensus phase might require several rounds to complete in case there is a fault with the - chosen round leader, each round can take up to `RoundTimeout` (currently set to 2s on SSV-protocol - level, which also means there will be 2 rounds at most because Ethereum block must be proposed - within 4s from slot start) meaning if round 1 doesn't complete in under `RoundTimeout` another - leader will be chosen to try and complete QBFT in round 2, etc. -- once QBFT completes successfully, Operator needs to submit the signed block to Beacon node to - propagate it throughout Ethereum network (call it `BlockSubmissionTime`) -- there is some time spent on executing various code to "glue" this whole thing together - that's small but still matters (call it `MiscellaneousTime`) +**mev-boost** (YAML): +```yaml +timeout_get_header_ms: 1800 +late_in_slot_time_ms: 1800 # mev-boost permits equality +relays: + - url: https://@relay-1.example + enable_timing_games: true + target_first_request_ms: 1000 + frequency_get_header_ms: 200 + - url: https://@relay-2.example + enable_timing_games: true + target_first_request_ms: 1000 + frequency_get_header_ms: 200 +``` -and so this means for the best SSV cluster operations we want the following condition to always hold true: +**SSV-side** (multi-BN setups only — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): +```yaml +eth2: + ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport ``` -RANDAOTime + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s + +## Tuning guidance & measurement methodology + +The example configs are starting points. Production tuning requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value isn't optimal for everyone. + +### Where the auction window should land + +Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: + +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, giving a strict bound of `late_in_slot_time_ms ≲ ~1450ms`. **Recommended:** stay at `late_in_slot_time_ms ≲ ~1400ms` to keep a 50ms buffer for latency variance — this also matches SSV's startup-warning threshold (`SafeMaxProposalSoftDeadline = 1450ms` SSV-side, which equals `~1400ms` PBS-side plus the `~50ms` BN→SSV transport). +- **Cutoffs above ~1400ms** consume the variance buffer; SSV emits a startup warning. **Cutoffs above ~1450ms** are past the strict bound and accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. +- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~3000ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. + +### What to measure + +Useful signals to baseline before tuning, by data source: + +**On the SSV side** — metrics on Grafana (if export is enabled) and structured logs: +- **RANDAO completion time** — pre-consensus duration. +- **QBFT round-1 completion distribution** — consensus duration. +- `"got beacon block proposal"` log with `took` duration. +- `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending` fields — emitted per BN response in multi-BN setups. +- `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. + +**On the PBS side** — PBS logs: +- BN → PBS RTT — typically same machine, well under 10ms. +- Per-relay RTT distribution (p50/p95/p99) — logged per `getHeader` call. + +**End-to-end** — submission round-trip from the signed block leaving SSV through the relay payload-reveal step (visible from PBS and relay logs). + +## Multi-BN setup + +> Single-BN operators can skip this section — SSV bypasses parallel fetch entirely and calls the single BN directly regardless of which knobs are set below. + +With multiple Beacon nodes, SSV races them in parallel for the block proposal. The recommended action is to set `ProposalSoftDeadline`: + +```yaml +eth2: + ProposalSoftDeadline: ``` -if this equation doesn't hold, Validator will miss his opportunity to propose the block (slot will be -missed if the corresponding Ethereum block isn't published within 4s after slot start time) -and so the most straightforward approach to extract highest MEV is (and it probably works out-of-the box -with SSV nodes already, but with caveats mentioned below): +This makes SSV wait for all BN responses up to that slot-relative deadline and return the highest-scored bid. Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. + +### Default behavior (if you don't set `ProposalSoftDeadline`) + +SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response arrives by the default slot-relative deadline (1450ms — the largest safest deadline for typical clusters; see [Tuning guidance](#tuning-guidance--measurement-methodology)), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. -### approach 1 +This default is faster but doesn't compare bid *values* across BNs — the first BN to return blinded wins regardless of bid quality. Fine for multi-BN setups run primarily for redundancy. -To set `MEVBoostRelayTimeout` as high as possible, and have plenty of Relays -(MEV-boost is configured with) so that Relays themselves decide "how much time they want to wait -since Slot start before replying with a block/bid" - some Relays would be fast to reply but not -as profitable as those that withhold for longer +### Legacy approach -^ the problem of this approach is that SSV node Operator might not be able to find/choose Relays -that fit his MEV desires (maybe he can only use those that respond right away without any additional -delay for the sake of higher MEV) +Setting `ProposerDelay` or `ProposalSoftTimeout` selects legacy block-fetch behavior (preserved bit-for-bit) — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). SSV logs a startup warning suggesting migration. -thus an alternative approach would be: +### Interaction -### approach 2 +The approaches are mutually exclusive. Selection at startup: -To introduce an additional configurable delay `ProposerDelay` SSV Operator can set so -that it will work nicely even with Relays that "reply as fast as possible", the equation from above -becomes: ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s +if ProposerDelay > 0 || ProposalSoftTimeout is set: + -> legacy approach (see Appendix A) +elif ProposalSoftDeadline is set: + -> new approach (waits for all BN responses, picks highest-scored) +else: + -> new approach default (returns first blinded response) ``` -plugging in some realistic numbers into that ^ formula we get a rough estimate of ~2.2s for `ProposerDelay`: -```go -const randaoTime = 100 * time.Millisecond -const mevBoostRelayTimeout = 200 * time.Millisecond -const qbftTime = 350 * time.Millisecond -const miscellaneousTime = 150 * time.Millisecond -const blockSubmissionTime = 1000 * time.Millisecond -const proposerDelay = 4*time.Second - randaoTime - mevBoostRelayTimeout - qbftTime - blockSubmissionTime - miscellaneousTime + +Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one approach. + +## Appendix A — Legacy `ProposerDelay` approach + +`ProposerDelay` remains supported. It is the right tool when: +- Your PBS does not support timing games (mev-boost < v1.11, or any PBS without the feature). +- You are running mev-boost without `-config ` and don't want to introduce a YAML config file. +- Operator constraints prevent PBS-side configuration changes. + +To configure, set in the SSV config file (or via the `PROPOSER_DELAY` environment variable): +```yaml +ProposerDelay: 300ms +``` + +With `ProposerDelay` active, the slot-budget equation becomes: ``` -but on top of that, another consideration Operator needs to take into account is QBFT round timeout, specifically -round 1 timeout. For proposer duty round 1 times out at ~2s after slot start time (so that proposer duty can execute 2 -QBFT rounds, if necessary, and still complete before that desirable 4s after slot start deadline). To avoid round 1 timing out -we'd want the following equation to hold: -```go -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + MiscellaneousTime < 2s +RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + BlockSubmission < 4000ms +``` + +Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (50 + 200 + 2350 + 50 + 100) = 1250ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~550ms of headroom for variance. + +We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate. + +**The SSV node refuses to start if `ProposerDelay` is set higher than 1000ms without explicit confirmation.** This 1000ms hard stop sits above the ~700ms recommended ceiling: values in the 700–1000ms range start without complaint, but you should only push past ~700ms after baselining your cluster's latencies. + +If you attempt to use a `ProposerDelay` value higher than 1000ms, the node exits with an error message. If you understand the risks and want to proceed anyway, set the `AllowDangerousProposerDelay` flag: + +```yaml +ProposerDelay: 2000ms +AllowDangerousProposerDelay: true ``` -and with the values listed above this gives us `ProposerDelay` value of ~1.2s. -Therefore, we consider ~1.2s to be the maximum reasonable value for `ProposerDelay`, going beyond that value might -result in missed block proposal. +Or via environment variables: +```bash +PROPOSER_DELAY=2000ms ALLOW_DANGEROUS_PROPOSER_DELAY=true ./bin/ssvnode start-node +``` -**To enforce proposer safety limits, the SSV node will automatically prevent startup if ProposerDelay exceeds 1s -unless the Operator explicitly acknowledges the risk by setting `AllowDangerousProposerDelay: true`.** +**Warning:** `ProposerDelay` values higher than 1000ms significantly increase the risk of missed block proposals, which can result in penalties and lost rewards. diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 06316ea8a6..2c16565423 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -45,9 +45,7 @@ type ProposerRunner struct { // ValCheck is used to validate the qbft-value(s) proposed by other Operators. ValCheck ssv.ValueChecker - // proposerDelay allows Operator to configure a delay to wait out before requesting Ethereum - // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. + // proposerDelay; see ProposerRunnerOptions.ProposerDelay. proposerDelay time.Duration // cachedFullBlock holds the initially fetched full (non-blinded) block @@ -69,9 +67,10 @@ type ProposerRunnerOptions struct { ValCheck ssv.ValueChecker HighestDecidedSlot phase0.Slot Graffiti []byte - // ProposerDelay allows Operator to configure a delay to wait out before requesting Ethereum - // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. + // ProposerDelay is the legacy SSV-side MEV-extraction lever — a delay before requesting + // the Ethereum block to capture later (higher-value) bids. The recommended approach is + // PBS-side timing games (mev-boost v1.11+ with -config, or commit-boost), in which case + // this stays at 0. See docs/MEV_CONSIDERATIONS.md. ProposerDelay time.Duration } From b4a3fa7fbf58894efd0bb3716ab5ce81a3e3c0fe Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 5 Jun 2026 10:12:23 +0300 Subject: [PATCH 02/16] beacon/goclient, cli/operator: address block-fetch review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - goclient.New: reject multi-BN clients whose selected path lacks its timing knob (safe/mev need ProposalSoftDeadline > 0, legacy needs ProposalSoftTimeout > 0). Such Options previously degraded silently to "return first valid response" via an already-expired collection window. - cli/operator: de-conflate resolveBlockFetch — snapshot the raw operator inputs up front so path selection never observes a value resolution produced; determineBlockFetchPath now takes the timing durations directly instead of the whole goclient.Options. Document the run-once invariant. - Fix the ProposerDelay env-description's dead doc anchor (the MEV-doc rewrite removed its target heading) and restore the 500ms-floor note on the ProposalSoftTimeout env-description. - Add end-to-end coverage for the legacy block-fetch path (early-exit-on-blinded + relative-soft-timeout fallback), which lost its behavioral test when TestGetProposalParallel_MultiClient was repointed to the safe path. - Assert the mev-optimized "exceeds safe-max" startup warning. --- beacon/goclient/attest_test.go | 3 + beacon/goclient/goclient.go | 22 +++++ beacon/goclient/goclient_test.go | 3 + beacon/goclient/options.go | 2 +- .../goclient/proposer_path_dispatch_test.go | 95 +++++++++++++++++++ cli/operator/config.go | 70 ++++++++------ cli/operator/config_test.go | 47 ++++++--- 7 files changed, 203 insertions(+), 39 deletions(-) diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index 1295768fb8..a5388d7a8b 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -512,6 +512,9 @@ func createClient( LongTimeout: time.Second, WithWeightedAttestationData: withWeightedAttestationData, BlockFetchPath: BlockFetchPathSafe, + // Safe-path deadline (config resolution defaults this in production); required for the + // multi-BN variants of this helper to satisfy New's block-fetch precondition. + ProposalSoftDeadline: 1450 * time.Millisecond, }) } diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index 5c088df8cb..3cf6db79ba 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -208,6 +208,28 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error beaconAddrList := strings.Split(opt.BeaconNodeAddr, ";") + // Defensive precondition for the multi-BN block-fetch paths: each path's collection + // window is driven by a timing knob that must be positive, otherwise the window is already + // expired on entry and the path silently degrades to "return the first valid response". + // These values arrive pre-resolved/pre-validated from cli/operator config resolution; this + // guard only catches a future caller that constructs Options directly without resolving them. + // Single-BN clients fetch directly (GetBeaconBlock) and never consult these knobs, so they + // are exempt. + if len(beaconAddrList) > 1 { + switch opt.BlockFetchPath { + case BlockFetchPathSafe, BlockFetchPathMEVOptimized: + if opt.ProposalSoftDeadline <= 0 { + return nil, fmt.Errorf("block-fetch path %q requires a positive ProposalSoftDeadline, got %v", opt.BlockFetchPath, opt.ProposalSoftDeadline) + } + case BlockFetchPathLegacy: + if opt.ProposalSoftTimeout <= 0 { + return nil, fmt.Errorf("block-fetch path %q requires a positive ProposalSoftTimeout, got %v", opt.BlockFetchPath, opt.ProposalSoftTimeout) + } + default: + return nil, fmt.Errorf("unknown block-fetch path %d", opt.BlockFetchPath) + } + } + client := &GoClient{ log: logger.Named(log.NameConsensusClient), beaconConfigInit: make(chan struct{}), diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index 700e309ee1..0d61703300 100644 --- a/beacon/goclient/goclient_test.go +++ b/beacon/goclient/goclient_test.go @@ -174,6 +174,9 @@ func runHealthyTest( CommonTimeout: commonTimeout, LongTimeout: longTimeout, SyncDistanceTolerance: syncDistanceTolerance, + // This multi-BN client defaults to the safe block-fetch path; a positive deadline is + // required to satisfy New's block-fetch precondition (unused by this sync-focused test). + ProposalSoftDeadline: 1450 * time.Millisecond, }) require.NoError(t, err) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 3cc6154baa..38aaaccaf7 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -62,7 +62,7 @@ type Options struct { // 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."` + 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). Cannot be set lower than 500ms, to leave the Beacon node enough time to serve the block-fetch request. 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. diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index 7b4b8cb08a..791d60de32 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -212,6 +212,84 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) } +// TestGetBeaconBlock_MultiBN_LegacyPath_EarlyExitOnBlinded drives the legacy block-fetch path +// (getProposalParallelLegacy) end-to-end. Like the safe path, legacy early-exits on the first +// blinded response: with one fast and one slow blinded BN it must return shortly after the fast +// BN without waiting for the slow one. Legacy uses a *relative* collection timeout (unlike the +// safe/MEV slot-relative deadline), so slot timing is irrelevant here. +func TestGetBeaconBlock_MultiBN_LegacyPath_EarlyExitOnBlinded(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNLegacyClient(t, bn1.URL, bn2.URL, 1500*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + require.NotNil(t, versionedProposal) + + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, + "legacy path should return the first blinded (BN1's), not wait for BN2's") + assert.Less(t, elapsed, 350*time.Millisecond, + "legacy path should early-exit on first blinded; took %v", elapsed) +} + +// TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid exercises the legacy +// path's *relative* collection timeout (gc.proposalSoftTimeout, measured from when the fetch +// starts — the key behavioral difference from the safe/MEV slot-relative deadline) and its +// fallback. With a 50ms relative timeout and no BN responding that fast, the collection window +// expires before any proposal arrives, so the path falls through to waitForFirstValidProposal +// and returns the first valid response (BN1's, ~200ms). +func TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 200 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 500 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + // 50ms relative collection window — fires well before either BN responds. + client := setupMultiBNLegacyClient(t, bn1.URL, bn2.URL, 50*time.Millisecond) + + slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + + start := time.Now() + versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + require.NotNil(t, versionedProposal) + + actualFeeRecipient, err := versionedProposal.FeeRecipient() + require.NoError(t, err) + assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, + "legacy fallback should return the first valid response (BN1's)") + assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, + "should have waited for BN1's response (~200ms); took %v", elapsed) + assert.Less(t, elapsed, 450*time.Millisecond, + "should NOT have waited for the slower BN2 (~500ms); took %v", elapsed) +} + // setupMultiBNClient builds a GoClient connected to two test BN servers via // semicolon-separated URLs, with the given block-fetch path and deadline. Used by // the per-path behavior tests. @@ -229,6 +307,23 @@ func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, path BlockFetchPath return client } +// setupMultiBNLegacyClient builds a GoClient connected to two test BN servers on the legacy +// block-fetch path, with the given relative ProposalSoftTimeout. Mirrors setupMultiBNClient +// (which covers the safe / MEV-optimized slot-relative-deadline paths). +func setupMultiBNLegacyClient(t *testing.T, bn1URL, bn2URL string, softTimeout time.Duration) *GoClient { + t.Helper() + + client, err := New(t.Context(), log.TestLogger(t), Options{ + BeaconNodeAddr: bn1URL + ";" + bn2URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalSoftTimeout: softTimeout, + BlockFetchPath: BlockFetchPathLegacy, + }) + require.NoError(t, err) + return client +} + // feeRecipientAllOnes returns a fee-recipient address filled with 0x01 bytes, // distinguishable from feeRecipientAllTwos in test assertions. func feeRecipientAllOnes() bellatrix.ExecutionAddress { diff --git a/cli/operator/config.go b/cli/operator/config.go index 5ada206e42..af8b853d48 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -42,7 +42,7 @@ type config struct { KeyStore KeyStore `yaml:"KeyStore"` SSVSigner SSVSignerConfig `yaml:"SSVSigner" env-prefix:"SSV_SIGNER_"` Graffiti string `yaml:"Graffiti" env:"GRAFFITI" env-description:"Custom graffiti for block proposals" env-default:"ssv.network"` - ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#getting-started-with-mev-configuration for detailed instructions on how to use it."` + ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#appendix-a--legacy-proposerdelay-approach for detailed instructions on how to use it."` AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1s (dangerous, may cause missed block proposals)"` OperatorPrivateKey string `yaml:"OperatorPrivateKey" env:"OPERATOR_KEY" env-description:"Operator private key for contract event decryption"` MetricsAPIPort int `yaml:"MetricsAPIPort" env:"METRICS_API_PORT" env-description:"Port for metrics API server"` @@ -159,35 +159,51 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { // resolveBlockFetch determines the block-fetch path from the operator's config, validates the // path-specific knobs, resolves their defaults onto c.ConsensusClient (consumed by goclient at // runtime), and emits advisory warnings. A returned error is fatal. +// +// Must run exactly once. It reads the raw operator inputs once, up front, then writes the +// resolved values back onto c.ConsensusClient — including, on the safe path, a default +// ProposalSoftDeadline. Because that field is also one of the inputs path selection snapshots, a +// second invocation would observe the resolved default and silently flip safe → mev-optimized. +// resolveAndValidate (the sole caller) runs once at startup. func (c *config) resolveBlockFetch(logger *zap.Logger) error { - path, err := determineBlockFetchPath(c.ConsensusClient, c.ProposerDelay) + // Raw operator inputs, snapshotted before any resolution writes below — so path selection and + // defaulting never observe a value that this function itself produced. + var ( + proposerDelay = c.ProposerDelay + rawSoftTimeout = c.ConsensusClient.ProposalSoftTimeout + rawSoftDeadline = c.ConsensusClient.ProposalSoftDeadline + ) + + path, err := determineBlockFetchPath(rawSoftTimeout, rawSoftDeadline, proposerDelay) if err != nil { return err } switch path { case goclient.BlockFetchPathLegacy: - if err := validateProposerDelay(c.ProposerDelay, c.AllowDangerousProposerDelay); err != nil { + if err := validateProposerDelay(proposerDelay, c.AllowDangerousProposerDelay); err != nil { return err } // Default the legacy soft timeout: 1800ms reduced by ProposerDelay, floored at 500ms. - if c.ConsensusClient.ProposalSoftTimeout == 0 { - c.ConsensusClient.ProposalSoftTimeout = defaultProposalSoftTimeout - if c.ProposerDelay > 0 { - c.ConsensusClient.ProposalSoftTimeout -= c.ProposerDelay + softTimeout := rawSoftTimeout + if softTimeout == 0 { + softTimeout = defaultProposalSoftTimeout + if proposerDelay > 0 { + softTimeout -= proposerDelay } } - if c.ConsensusClient.ProposalSoftTimeout < minProposalSoftTimeout { - c.ConsensusClient.ProposalSoftTimeout = minProposalSoftTimeout + if softTimeout < minProposalSoftTimeout { + softTimeout = minProposalSoftTimeout } + c.ConsensusClient.ProposalSoftTimeout = softTimeout case goclient.BlockFetchPathMEVOptimized: - if err := validateProposalSoftDeadline(c.ConsensusClient.ProposalSoftDeadline); err != nil { + if err := validateProposalSoftDeadline(rawSoftDeadline); err != nil { return err } case goclient.BlockFetchPathSafe: - if c.ConsensusClient.ProposalSoftDeadline == 0 { - c.ConsensusClient.ProposalSoftDeadline = defaultProposalSoftDeadline - } + // The safe path is selected only when no deadline was set, so resolve the unset deadline + // to the safe-path default. + c.ConsensusClient.ProposalSoftDeadline = defaultProposalSoftDeadline } c.ConsensusClient.BlockFetchPath = path @@ -196,21 +212,21 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { // Advisory warnings — emitted after validation, so they never precede a validation error. switch path { case goclient.BlockFetchPathLegacy: - if c.ProposerDelay > maxSafeProposerDelay { + if proposerDelay > maxSafeProposerDelay { // Reachable only after validateProposerDelay passed (AllowDangerousProposerDelay set). logger.Warn("Using dangerous ProposerDelay value that may cause missed block proposals", - zap.Int64("proposer_delay_ms", c.ProposerDelay.Milliseconds()), + zap.Int64("proposer_delay_ms", proposerDelay.Milliseconds()), zap.Int64("max_safe_proposer_delay_ms", maxSafeProposerDelay.Milliseconds())) } case goclient.BlockFetchPathMEVOptimized: - if c.ConsensusClient.ProposalSoftDeadline > safeMaxProposalSoftDeadline { + if rawSoftDeadline > safeMaxProposalSoftDeadline { logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ "round-2 QBFT fallback may not fit within the slot deadline "+ "for clusters with typical latencies, "+ "so the slot may be missed when round 1 fails "+ "(this is an explicit 'round 1 must succeed' configuration).", - zap.Int64("proposal_soft_deadline_ms", c.ConsensusClient.ProposalSoftDeadline.Milliseconds()), + zap.Int64("proposal_soft_deadline_ms", rawSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", safeMaxProposalSoftDeadline.Milliseconds())) } case goclient.BlockFetchPathSafe: @@ -220,22 +236,22 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { return nil } -// determineBlockFetchPath selects the block-fetch path from the operator's raw config. Negative -// durations and combining legacy knobs (ProposerDelay/ProposalSoftTimeout) with the MEV-optimized -// ProposalSoftDeadline are rejected. -func determineBlockFetchPath(base goclient.Options, proposerDelay time.Duration) (goclient.BlockFetchPath, error) { +// determineBlockFetchPath selects the block-fetch path from the operator's raw timing knobs. +// Negative durations and combining legacy knobs (ProposerDelay/ProposalSoftTimeout) with the +// MEV-optimized ProposalSoftDeadline are rejected. +func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposerDelay time.Duration) (goclient.BlockFetchPath, error) { 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 proposalSoftTimeout < 0 { + return 0, fmt.Errorf("ProposalSoftTimeout must be non-negative, got %v", proposalSoftTimeout) } - if base.ProposalSoftDeadline < 0 { - return 0, fmt.Errorf("ProposalSoftDeadline must be non-negative, got %v", base.ProposalSoftDeadline) + if proposalSoftDeadline < 0 { + return 0, fmt.Errorf("ProposalSoftDeadline must be non-negative, got %v", proposalSoftDeadline) } - legacySet := proposerDelay > 0 || base.ProposalSoftTimeout > 0 - deadlineSet := base.ProposalSoftDeadline > 0 + legacySet := proposerDelay > 0 || proposalSoftTimeout > 0 + deadlineSet := 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") diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index adc74d7acd..2f4c0c048c 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -189,24 +189,25 @@ func Test_validateProposerDelay(t *testing.T) { func TestDetermineBlockFetchPath(t *testing.T) { tests := []struct { - name string - opts goclient.Options - proposerDelay time.Duration - want goclient.BlockFetchPath - wantErr string + name string + proposalSoftTimeout time.Duration + proposalSoftDeadline time.Duration + proposerDelay time.Duration + want goclient.BlockFetchPath + wantErr string }{ {name: "nothing set -> safe", want: goclient.BlockFetchPathSafe}, {name: "ProposerDelay -> legacy", proposerDelay: 300 * time.Millisecond, want: goclient.BlockFetchPathLegacy}, - {name: "ProposalSoftTimeout -> legacy", opts: goclient.Options{ProposalSoftTimeout: 1500 * time.Millisecond}, want: goclient.BlockFetchPathLegacy}, - {name: "ProposalSoftDeadline -> mev-optimized", opts: goclient.Options{ProposalSoftDeadline: 1100 * time.Millisecond}, want: goclient.BlockFetchPathMEVOptimized}, - {name: "legacy + deadline -> conflict", opts: goclient.Options{ProposalSoftDeadline: 1100 * time.Millisecond}, proposerDelay: 300 * time.Millisecond, wantErr: "conflicts with legacy"}, + {name: "ProposalSoftTimeout -> legacy", proposalSoftTimeout: 1500 * time.Millisecond, want: goclient.BlockFetchPathLegacy}, + {name: "ProposalSoftDeadline -> mev-optimized", proposalSoftDeadline: 1100 * time.Millisecond, want: goclient.BlockFetchPathMEVOptimized}, + {name: "legacy + deadline -> conflict", proposalSoftDeadline: 1100 * time.Millisecond, proposerDelay: 300 * time.Millisecond, wantErr: "conflicts with legacy"}, {name: "negative ProposerDelay -> error", proposerDelay: -1, wantErr: "ProposerDelay must be non-negative"}, - {name: "negative ProposalSoftTimeout -> error", opts: goclient.Options{ProposalSoftTimeout: -1}, wantErr: "ProposalSoftTimeout must be non-negative"}, - {name: "negative ProposalSoftDeadline -> error", opts: goclient.Options{ProposalSoftDeadline: -1}, wantErr: "ProposalSoftDeadline must be non-negative"}, + {name: "negative ProposalSoftTimeout -> error", proposalSoftTimeout: -1, wantErr: "ProposalSoftTimeout must be non-negative"}, + {name: "negative ProposalSoftDeadline -> error", proposalSoftDeadline: -1, wantErr: "ProposalSoftDeadline must be non-negative"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := determineBlockFetchPath(tt.opts, tt.proposerDelay) + got, err := determineBlockFetchPath(tt.proposalSoftTimeout, tt.proposalSoftDeadline, tt.proposerDelay) if tt.wantErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tt.wantErr) @@ -282,6 +283,30 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { c.ConsensusClient.ProposalSoftDeadline = 5000 * time.Millisecond require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "out of range") }) + + t.Run("mev-optimized above safe-max - warns with ms fields", func(t *testing.T) { + core, recorded := observer.New(zapcore.WarnLevel) + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1450ms), within range + require.NoError(t, c.resolveBlockFetch(zap.New(core))) + + logs := recorded.All() + require.Len(t, logs, 1) + require.Equal(t, zapcore.WarnLevel, logs[0].Level) + require.Contains(t, logs[0].Message, "exceeds the safe-max threshold") + + fields := logs[0].ContextMap() + require.Equal(t, int64(1850), fields["proposal_soft_deadline_ms"]) + require.Equal(t, int64(1450), fields["safe_max_ms"]) + }) + + t.Run("mev-optimized at safe-max - no warning", func(t *testing.T) { + core, recorded := observer.New(zapcore.WarnLevel) + c := config{} + c.ConsensusClient.ProposalSoftDeadline = safeMaxProposalSoftDeadline // == safe-max, no warning + require.NoError(t, c.resolveBlockFetch(zap.New(core))) + require.Len(t, recorded.All(), 0) + }) } func Test_validateConfig(t *testing.T) { From e40a5fe42f59644310f40bbba1fcb862d17cedc9 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 5 Jun 2026 10:49:56 +0300 Subject: [PATCH 03/16] beacon/goclient: replace BlockFetchPath enum with two mechanical knobs goclient no longer carries the operator-facing safe/legacy/MEV-optimized block-fetch "path" enum or its three-way dispatch. The multi-BN block-fetch strategy is now expressed as two mechanical Options knobs that cli/operator resolves the path down to: - ProposalCollectionSlotRelative: slot-relative deadline (true) vs legacy relative timeout (false) - EarlyExitOnBlinded: stop collecting on the first blinded (MEV) response GetBeaconBlock dispatches on these via a 2-way branch instead of switching on the enum, and New's multi-BN precondition validates the matching duration knob. The path concept (and its String() label for the startup log line) moves into cli/operator as policy vocabulary; resolveBlockFetch maps each path to the knob pair. Runtime behavior is unchanged. Multi-BN test clients now set the mechanical knobs explicitly; the path String() test moves to cli/operator alongside the type. --- beacon/goclient/attest_test.go | 11 +- beacon/goclient/events_test.go | 1 - beacon/goclient/goclient.go | 42 ++++---- beacon/goclient/goclient_test.go | 5 +- beacon/goclient/options.go | 52 +++------ beacon/goclient/options_test.go | 24 ----- beacon/goclient/proposer.go | 19 ++-- .../goclient/proposer_path_dispatch_test.go | 102 ++++++++++-------- beacon/goclient/proposer_test.go | 9 +- cli/operator/config.go | 63 +++++++++-- cli/operator/config_test.go | 41 +++++-- cli/operator/node.go | 5 +- 12 files changed, 195 insertions(+), 179 deletions(-) delete mode 100644 beacon/goclient/options_test.go diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index a5388d7a8b..b233b2a90d 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -507,11 +507,12 @@ func createClient( beaconServerURL string, withWeightedAttestationData bool) (*GoClient, error) { return New(ctx, zap.NewNop(), Options{ - BeaconNodeAddr: beaconServerURL, - CommonTimeout: defaultHardTimeout, - LongTimeout: time.Second, - WithWeightedAttestationData: withWeightedAttestationData, - BlockFetchPath: BlockFetchPathSafe, + BeaconNodeAddr: beaconServerURL, + CommonTimeout: defaultHardTimeout, + LongTimeout: time.Second, + WithWeightedAttestationData: withWeightedAttestationData, + ProposalCollectionSlotRelative: true, + EarlyExitOnBlinded: true, // Safe-path deadline (config resolution defaults this in production); required for the // multi-BN variants of this helper to satisfy New's block-fetch precondition. ProposalSoftDeadline: 1450 * time.Millisecond, diff --git a/beacon/goclient/events_test.go b/beacon/goclient/events_test.go index 8b5da3abb2..56c6249444 100644 --- a/beacon/goclient/events_test.go +++ b/beacon/goclient/events_test.go @@ -253,7 +253,6 @@ func TestNewEventHandler(t *testing.T) { func eventsTestClient(t *testing.T, serverURL string) *GoClient { server, err := New(t.Context(), zap.NewNop(), Options{ BeaconNodeAddr: serverURL, - BlockFetchPath: BlockFetchPathSafe, }) require.NoError(t, err) diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index 3cf6db79ba..e2aa293e9b 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -135,19 +135,22 @@ type GoClient struct { weightedAttestationDataSoftTimeout time.Duration weightedAttestationDataHardTimeout time.Duration - // proposalSoftTimeout is the legacy collection-period timeout used by - // getProposalParallelLegacy. Other paths use proposalSoftDeadline instead. + // proposalSoftTimeout is the relative collection-period timeout used by the legacy + // collection (getProposalParallelLegacy); the slot-relative collection uses + // 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 is the slot-relative deadline (ms into slot) for the slot-relative + // collection (getProposalParallelByDeadline). 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 + // proposalCollectionSlotRelative selects how GetBeaconBlock collects proposals across + // multiple BNs: true -> slot-relative deadline (getProposalParallelByDeadline), false -> + // legacy relative timeout (getProposalParallelLegacy). earlyExitOnBlinded stops the + // slot-relative collection on the first blinded (MEV) response. Both are resolved from + // operator config by cli/operator; see docs/MEV_CONSIDERATIONS.md. + proposalCollectionSlotRelative bool + earlyExitOnBlinded bool // 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 @@ -197,8 +200,9 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error } // Apply mechanical network-timeout defaults (previously done by NewOptions, now removed). - // Block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline / BlockFetchPath) arrive - // pre-resolved from cli/operator config resolution. + // Block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline / + // ProposalCollectionSlotRelative / EarlyExitOnBlinded) arrive pre-resolved from + // cli/operator config resolution. if opt.CommonTimeout == 0 { opt.CommonTimeout = defaultCommonTimeout } @@ -216,17 +220,12 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error // Single-BN clients fetch directly (GetBeaconBlock) and never consult these knobs, so they // are exempt. if len(beaconAddrList) > 1 { - switch opt.BlockFetchPath { - case BlockFetchPathSafe, BlockFetchPathMEVOptimized: + if opt.ProposalCollectionSlotRelative { if opt.ProposalSoftDeadline <= 0 { - return nil, fmt.Errorf("block-fetch path %q requires a positive ProposalSoftDeadline, got %v", opt.BlockFetchPath, opt.ProposalSoftDeadline) + return nil, fmt.Errorf("slot-relative proposal collection requires a positive ProposalSoftDeadline, got %v", opt.ProposalSoftDeadline) } - case BlockFetchPathLegacy: - if opt.ProposalSoftTimeout <= 0 { - return nil, fmt.Errorf("block-fetch path %q requires a positive ProposalSoftTimeout, got %v", opt.BlockFetchPath, opt.ProposalSoftTimeout) - } - default: - return nil, fmt.Errorf("unknown block-fetch path %d", opt.BlockFetchPath) + } else if opt.ProposalSoftTimeout <= 0 { + return nil, fmt.Errorf("legacy (relative) proposal collection requires a positive ProposalSoftTimeout, got %v", opt.ProposalSoftTimeout) } } @@ -242,7 +241,8 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error weightedAttestationDataHardTimeout: opt.CommonTimeout, proposalSoftTimeout: opt.ProposalSoftTimeout, proposalSoftDeadline: opt.ProposalSoftDeadline, - blockFetchPath: opt.BlockFetchPath, + proposalCollectionSlotRelative: opt.ProposalCollectionSlotRelative, + earlyExitOnBlinded: opt.EarlyExitOnBlinded, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), } diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index 0d61703300..896c06f8f7 100644 --- a/beacon/goclient/goclient_test.go +++ b/beacon/goclient/goclient_test.go @@ -174,9 +174,10 @@ func runHealthyTest( CommonTimeout: commonTimeout, LongTimeout: longTimeout, SyncDistanceTolerance: syncDistanceTolerance, - // This multi-BN client defaults to the safe block-fetch path; a positive deadline is + // This multi-BN client uses the slot-relative (safe) collection; a positive deadline is // required to satisfy New's block-fetch precondition (unused by this sync-focused test). - ProposalSoftDeadline: 1450 * time.Millisecond, + ProposalCollectionSlotRelative: true, + ProposalSoftDeadline: 1450 * time.Millisecond, }) require.NoError(t, err) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 38aaaccaf7..c1989d6f56 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -1,7 +1,6 @@ package goclient import ( - "fmt" "time" "github.com/ssvlabs/ssv/networkconfig" @@ -15,39 +14,6 @@ const ( defaultLongTimeout = time.Second * 60 ) -// BlockFetchPath identifies which block-header fetch strategy the SSV node is using. -// Determined at startup from operator-provided config by cli/operator config resolution. -// -// 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. - 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)) - } -} - // Options defines beacon client options type Options struct { BeaconConfig *networkconfig.Beacon @@ -60,8 +26,8 @@ type Options struct { LongTimeout time.Duration `yaml:"LongTimeout" env:"WITH_LONG_TIMEOUT" env-description:"Specifies the long timeout for network operations"` // 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. + // fetch. Setting this (or ProposerDelay) selects the legacy relative-timeout collection. + // 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). Cannot be set lower than 500ms, to leave the Beacon node enough time to serve the block-fetch request. 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 @@ -72,8 +38,14 @@ type Options struct { // 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 cli/operator config resolution 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:"-"` + // ProposalCollectionSlotRelative and EarlyExitOnBlinded are the mechanical multi-BN + // proposal-collection knobs resolved by cli/operator config resolution (not configured + // directly by the operator): + // - ProposalCollectionSlotRelative: true -> collect until the slot-relative + // ProposalSoftDeadline; false -> collect for the relative ProposalSoftTimeout (legacy). + // - EarlyExitOnBlinded: stop collecting on the first blinded (MEV) response. Applies to + // the slot-relative collection; the legacy collection always early-exits internally. + // GoClient consumes these to dispatch block fetching. See docs/MEV_CONSIDERATIONS.md. + ProposalCollectionSlotRelative bool `yaml:"-"` + EarlyExitOnBlinded bool `yaml:"-"` } diff --git a/beacon/goclient/options_test.go b/beacon/goclient/options_test.go deleted file mode 100644 index 64d387b0e1..0000000000 --- a/beacon/goclient/options_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package goclient - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBlockFetchPath_String(t *testing.T) { - tests := []struct { - path BlockFetchPath - want string - }{ - {BlockFetchPathSafe, "safe"}, - {BlockFetchPathLegacy, "legacy"}, - {BlockFetchPathMEVOptimized, "mev-optimized"}, - {BlockFetchPath(99), "unknown(99)"}, - } - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - assert.Equal(t, tt.want, tt.path.String()) - }) - } -} diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index e467926210..9a8c0fef15 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -106,18 +106,15 @@ func (gc *GoClient) GetBeaconBlock( return nil, nil, err } } else { - // For multiple clients, dispatch to the selected block-fetch path. - // Safe and MEV-optimized share an implementation differing only in whether - // to early-exit on the first blinded response. See docs/MEV_CONSIDERATIONS.md. - switch gc.blockFetchPath { - case BlockFetchPathSafe: - beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, true /* earlyExitOnBlinded */) - case BlockFetchPathLegacy: + // For multiple clients, race them in parallel. Two mechanical knobs resolved from + // operator config by cli/operator drive the strategy: proposalCollectionSlotRelative + // selects the collection-window timing (slot-relative deadline vs legacy relative + // timeout), and earlyExitOnBlinded whether to stop on the first blinded (MEV) response. + // See docs/MEV_CONSIDERATIONS.md. + if gc.proposalCollectionSlotRelative { + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, gc.earlyExitOnBlinded) + } else { beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) - case BlockFetchPathMEVOptimized: - beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, false /* earlyExitOnBlinded */) - default: - return nil, nil, fmt.Errorf("unknown block-fetch path %d", gc.blockFetchPath) } if err != nil { return nil, nil, err diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index 791d60de32..51d41c0872 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -14,46 +14,53 @@ import ( "github.com/ssvlabs/ssv/observability/log" ) -// Tests for the block-fetch path dispatch (BlockFetchPathSafe / Legacy / MEVOptimized). -// See docs/MEV_CONSIDERATIONS.md for path semantics. - -// TestNew_StoresBlockFetchPath verifies that the selected path and its associated -// timing field (proposalSoftDeadline / proposalSoftTimeout) get propagated from -// Options into the resulting GoClient. -func TestNew_StoresBlockFetchPath(t *testing.T) { - for _, path := range []BlockFetchPath{BlockFetchPathSafe, BlockFetchPathLegacy, BlockFetchPathMEVOptimized} { - t.Run(path.String(), func(t *testing.T) { +// Tests for multi-BN proposal-collection dispatch: the slot-relative-deadline strategy +// (with/without early-exit on blinded) and the legacy relative-timeout strategy. +// See docs/MEV_CONSIDERATIONS.md for the semantics. + +// TestNew_StoresProposalFetchConfig verifies that the mechanical multi-BN proposal-collection +// knobs (proposalCollectionSlotRelative / earlyExitOnBlinded) and their associated timing field +// (proposalSoftDeadline / proposalSoftTimeout) get propagated from Options into the GoClient. +// In production these resolved values come from cli/operator config resolution. +func TestNew_StoresProposalFetchConfig(t *testing.T) { + tests := []struct { + name string + opts Options // block-fetch knobs only; transport fields are filled in below + }{ + { + name: "safe-equivalent (slot-relative, early-exit)", + opts: Options{ProposalCollectionSlotRelative: true, EarlyExitOnBlinded: true, ProposalSoftDeadline: 1100 * time.Millisecond}, + }, + { + name: "mev-equivalent (slot-relative, no early-exit)", + opts: Options{ProposalCollectionSlotRelative: true, EarlyExitOnBlinded: false, ProposalSoftDeadline: 1100 * time.Millisecond}, + }, + { + name: "legacy-equivalent (relative timeout)", + opts: Options{ProposalCollectionSlotRelative: false, ProposalSoftTimeout: 1800 * time.Millisecond}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { server, _ := createProposalBeaconServer(t, beaconProposalServerOptions{}) defer server.Close() - // In production these resolved values come from cli/operator config resolution; - // here we set them explicitly and verify New propagates them onto the GoClient. - base := Options{ - BeaconNodeAddr: server.URL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - BlockFetchPath: path, - } - switch path { - case BlockFetchPathSafe, BlockFetchPathMEVOptimized: - base.ProposalSoftDeadline = 1100 * time.Millisecond - case BlockFetchPathLegacy: - base.ProposalSoftTimeout = 1800 * time.Millisecond - } + base := tt.opts + base.BeaconNodeAddr = server.URL + base.CommonTimeout = time.Second * 2 + base.LongTimeout = time.Second * 5 client, err := New(t.Context(), log.TestLogger(t), base) require.NoError(t, err) - assert.Equal(t, path, client.blockFetchPath, "GoClient.blockFetchPath should reflect opt.BlockFetchPath") - - switch path { - case BlockFetchPathSafe, BlockFetchPathMEVOptimized: - assert.Equal(t, 1100*time.Millisecond, client.proposalSoftDeadline, - "New should propagate ProposalSoftDeadline") - case BlockFetchPathLegacy: - assert.Equal(t, 1800*time.Millisecond, client.proposalSoftTimeout, - "New should propagate ProposalSoftTimeout") - } + assert.Equal(t, tt.opts.ProposalCollectionSlotRelative, client.proposalCollectionSlotRelative, + "New should propagate ProposalCollectionSlotRelative") + assert.Equal(t, tt.opts.EarlyExitOnBlinded, client.earlyExitOnBlinded, + "New should propagate EarlyExitOnBlinded") + assert.Equal(t, tt.opts.ProposalSoftDeadline, client.proposalSoftDeadline, + "New should propagate ProposalSoftDeadline") + assert.Equal(t, tt.opts.ProposalSoftTimeout, client.proposalSoftTimeout, + "New should propagate ProposalSoftTimeout") }) } } @@ -76,7 +83,7 @@ func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1500*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL, true /* earlyExitOnBlinded */, 1500*time.Millisecond) // Use a slot starting in the near future so the slot-relative deadline lands // well after both BN responses (we want to observe the early-exit on blinded, @@ -113,7 +120,7 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL, false /* earlyExitOnBlinded */, 1500*time.Millisecond) slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 @@ -150,7 +157,7 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *te }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathMEVOptimized, 1500*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL, false /* earlyExitOnBlinded */, 1500*time.Millisecond) slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 @@ -182,7 +189,7 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, BlockFetchPathSafe, 1000*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL, true /* earlyExitOnBlinded */, 1000*time.Millisecond) // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative // deadline = slotStart + 1000ms is also in the past, so softCtx is already @@ -290,18 +297,19 @@ func TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid(t *t "should NOT have waited for the slower BN2 (~500ms); took %v", elapsed) } -// setupMultiBNClient builds a GoClient connected to two test BN servers via -// semicolon-separated URLs, with the given block-fetch path and deadline. Used by -// the per-path behavior tests. -func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, path BlockFetchPath, deadline time.Duration) *GoClient { +// setupMultiBNClient builds a GoClient connected to two test BN servers via semicolon-separated +// URLs, on the slot-relative-deadline strategy with the given early-exit-on-blinded setting and +// deadline. Used by the safe (earlyExit=true) / MEV-optimized (earlyExit=false) behavior tests. +func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, earlyExitOnBlinded bool, deadline time.Duration) *GoClient { t.Helper() client, err := New(t.Context(), log.TestLogger(t), Options{ - BeaconNodeAddr: bn1URL + ";" + bn2URL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - ProposalSoftDeadline: deadline, - BlockFetchPath: path, + BeaconNodeAddr: bn1URL + ";" + bn2URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalSoftDeadline: deadline, + ProposalCollectionSlotRelative: true, + EarlyExitOnBlinded: earlyExitOnBlinded, }) require.NoError(t, err) return client @@ -318,7 +326,7 @@ func setupMultiBNLegacyClient(t *testing.T, bn1URL, bn2URL string, softTimeout t CommonTimeout: time.Second * 2, LongTimeout: time.Second * 5, ProposalSoftTimeout: softTimeout, - BlockFetchPath: BlockFetchPathLegacy, + // Legacy = relative-timeout collection (ProposalCollectionSlotRelative defaults to false). }) require.NoError(t, err) return client diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index 3a863f7346..065e6d70cd 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -707,10 +707,11 @@ func TestProposalPreparationReconnectLogic_SkipsOnNilProvider(t *testing.T) { func createClientForProposerTest(t *testing.T, serverURL string) (*GoClient, error) { return New(t.Context(), log.TestLogger(t), Options{ - BeaconNodeAddr: serverURL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - BlockFetchPath: BlockFetchPathSafe, + BeaconNodeAddr: serverURL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalCollectionSlotRelative: true, + EarlyExitOnBlinded: true, // safe-path slot-relative deadline (config resolution defaults this in production). ProposalSoftDeadline: 1450 * time.Millisecond, }) diff --git a/cli/operator/config.go b/cli/operator/config.go index af8b853d48..c6075f90e5 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -180,7 +180,7 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { } switch path { - case goclient.BlockFetchPathLegacy: + case blockFetchPathLegacy: if err := validateProposerDelay(proposerDelay, c.AllowDangerousProposerDelay); err != nil { return err } @@ -196,29 +196,37 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { softTimeout = minProposalSoftTimeout } c.ConsensusClient.ProposalSoftTimeout = softTimeout - case goclient.BlockFetchPathMEVOptimized: + // Legacy collects for a relative timeout and always early-exits on the first blinded. + c.ConsensusClient.ProposalCollectionSlotRelative = false + c.ConsensusClient.EarlyExitOnBlinded = true + case blockFetchPathMEVOptimized: if err := validateProposalSoftDeadline(rawSoftDeadline); err != nil { return err } - case goclient.BlockFetchPathSafe: + // Slot-relative collection that keeps collecting past the first blinded to compare bids. + c.ConsensusClient.ProposalCollectionSlotRelative = true + c.ConsensusClient.EarlyExitOnBlinded = false + case blockFetchPathSafe: // The safe path is selected only when no deadline was set, so resolve the unset deadline // to the safe-path default. c.ConsensusClient.ProposalSoftDeadline = defaultProposalSoftDeadline + // Slot-relative collection with early-exit on the first blinded (MEV) response. + c.ConsensusClient.ProposalCollectionSlotRelative = true + c.ConsensusClient.EarlyExitOnBlinded = true } - c.ConsensusClient.BlockFetchPath = path logger.Info("block-fetch path selected", zap.String("path", path.String())) // Advisory warnings — emitted after validation, so they never precede a validation error. switch path { - case goclient.BlockFetchPathLegacy: + case blockFetchPathLegacy: if proposerDelay > maxSafeProposerDelay { // Reachable only after validateProposerDelay passed (AllowDangerousProposerDelay set). logger.Warn("Using dangerous ProposerDelay value that may cause missed block proposals", zap.Int64("proposer_delay_ms", proposerDelay.Milliseconds()), zap.Int64("max_safe_proposer_delay_ms", maxSafeProposerDelay.Milliseconds())) } - case goclient.BlockFetchPathMEVOptimized: + case blockFetchPathMEVOptimized: if rawSoftDeadline > safeMaxProposalSoftDeadline { logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ @@ -229,17 +237,50 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { zap.Int64("proposal_soft_deadline_ms", rawSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", safeMaxProposalSoftDeadline.Milliseconds())) } - case goclient.BlockFetchPathSafe: + case blockFetchPathSafe: // Safe path has no advisory warning — its default deadline sits at the safe-max. } return nil } +// blockFetchPath is the operator-facing block-fetch strategy selected at startup from config. +// It is policy vocabulary owned by the config layer; resolveBlockFetch translates it into the +// mechanical knobs goclient consumes (ProposalCollectionSlotRelative / EarlyExitOnBlinded). +// Documented end-to-end in docs/MEV_CONSIDERATIONS.md. +type blockFetchPath int + +const ( + // blockFetchPathSafe is the default: slot-relative collection with early-exit on the first + // blinded response (deadline defaults to safeMaxProposalSoftDeadline). + blockFetchPathSafe blockFetchPath = iota + // blockFetchPathLegacy preserves the original ProposerDelay / ProposalSoftTimeout behavior + // (relative-timeout collection); selected when an operator sets either legacy knob. + blockFetchPathLegacy + // blockFetchPathMEVOptimized is opt-in: slot-relative collection 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)) + } +} + // determineBlockFetchPath selects the block-fetch path from the operator's raw timing knobs. // Negative durations and combining legacy knobs (ProposerDelay/ProposalSoftTimeout) with the // MEV-optimized ProposalSoftDeadline are rejected. -func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposerDelay time.Duration) (goclient.BlockFetchPath, error) { +func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposerDelay time.Duration) (blockFetchPath, error) { if proposerDelay < 0 { return 0, fmt.Errorf("ProposerDelay must be non-negative, got %v", proposerDelay) } @@ -259,11 +300,11 @@ func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposer switch { case legacySet: - return goclient.BlockFetchPathLegacy, nil + return blockFetchPathLegacy, nil case deadlineSet: - return goclient.BlockFetchPathMEVOptimized, nil + return blockFetchPathMEVOptimized, nil default: - return goclient.BlockFetchPathSafe, nil + return blockFetchPathSafe, nil } } diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index 2f4c0c048c..da88956d3e 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -12,7 +12,6 @@ import ( "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" - "github.com/ssvlabs/ssv/beacon/goclient" "github.com/ssvlabs/ssv/exporter" "github.com/ssvlabs/ssv/networkconfig" operatorstorage "github.com/ssvlabs/ssv/operator/storage" @@ -193,13 +192,13 @@ func TestDetermineBlockFetchPath(t *testing.T) { proposalSoftTimeout time.Duration proposalSoftDeadline time.Duration proposerDelay time.Duration - want goclient.BlockFetchPath + want blockFetchPath wantErr string }{ - {name: "nothing set -> safe", want: goclient.BlockFetchPathSafe}, - {name: "ProposerDelay -> legacy", proposerDelay: 300 * time.Millisecond, want: goclient.BlockFetchPathLegacy}, - {name: "ProposalSoftTimeout -> legacy", proposalSoftTimeout: 1500 * time.Millisecond, want: goclient.BlockFetchPathLegacy}, - {name: "ProposalSoftDeadline -> mev-optimized", proposalSoftDeadline: 1100 * time.Millisecond, want: goclient.BlockFetchPathMEVOptimized}, + {name: "nothing set -> safe", want: blockFetchPathSafe}, + {name: "ProposerDelay -> legacy", proposerDelay: 300 * time.Millisecond, want: blockFetchPathLegacy}, + {name: "ProposalSoftTimeout -> legacy", proposalSoftTimeout: 1500 * time.Millisecond, want: blockFetchPathLegacy}, + {name: "ProposalSoftDeadline -> mev-optimized", proposalSoftDeadline: 1100 * time.Millisecond, want: blockFetchPathMEVOptimized}, {name: "legacy + deadline -> conflict", proposalSoftDeadline: 1100 * time.Millisecond, proposerDelay: 300 * time.Millisecond, wantErr: "conflicts with legacy"}, {name: "negative ProposerDelay -> error", proposerDelay: -1, wantErr: "ProposerDelay must be non-negative"}, {name: "negative ProposalSoftTimeout -> error", proposalSoftTimeout: -1, wantErr: "ProposalSoftTimeout must be non-negative"}, @@ -219,6 +218,23 @@ func TestDetermineBlockFetchPath(t *testing.T) { } } +func Test_blockFetchPath_String(t *testing.T) { + tests := []struct { + path blockFetchPath + want string + }{ + {blockFetchPathSafe, "safe"}, + {blockFetchPathLegacy, "legacy"}, + {blockFetchPathMEVOptimized, "mev-optimized"}, + {blockFetchPath(99), "unknown(99)"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + require.Equal(t, tt.want, tt.path.String()) + }) + } +} + func TestValidateProposalSoftDeadline(t *testing.T) { tests := []struct { name string @@ -247,10 +263,11 @@ func TestValidateProposalSoftDeadline(t *testing.T) { } func Test_resolveBlockFetch_defaults(t *testing.T) { - t.Run("safe path defaults deadline to 1450ms and sets path", func(t *testing.T) { + t.Run("safe path defaults deadline to 1450ms and sets slot-relative early-exit knobs", func(t *testing.T) { c := config{} require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.Equal(t, goclient.BlockFetchPathSafe, c.ConsensusClient.BlockFetchPath) + require.True(t, c.ConsensusClient.ProposalCollectionSlotRelative) + require.True(t, c.ConsensusClient.EarlyExitOnBlinded) require.Equal(t, 1450*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) }) @@ -258,7 +275,8 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { c := config{} c.ProposerDelay = 300 * time.Millisecond require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.Equal(t, goclient.BlockFetchPathLegacy, c.ConsensusClient.BlockFetchPath) + require.False(t, c.ConsensusClient.ProposalCollectionSlotRelative) + require.True(t, c.ConsensusClient.EarlyExitOnBlinded) require.Equal(t, 1500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) }) @@ -270,11 +288,12 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { require.Equal(t, 500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) }) - t.Run("mev-optimized path keeps operator deadline and sets path", func(t *testing.T) { + t.Run("mev-optimized path keeps operator deadline and sets slot-relative no-early-exit knobs", func(t *testing.T) { c := config{} c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.Equal(t, goclient.BlockFetchPathMEVOptimized, c.ConsensusClient.BlockFetchPath) + require.True(t, c.ConsensusClient.ProposalCollectionSlotRelative) + require.False(t, c.ConsensusClient.EarlyExitOnBlinded) require.Equal(t, 1850*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) }) diff --git a/cli/operator/node.go b/cli/operator/node.go index 5351bb7cce..6de4468b68 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -80,8 +80,9 @@ func runNode(ctx context.Context, cfg *config, logger *zap.Logger) error { zap.Bool("with_parallel_submissions", cfg.ConsensusClient.WithParallelSubmissions), ) - // goclient consumes the block-fetch values (BlockFetchPath / ProposalSoftTimeout / - // ProposalSoftDeadline) that resolveAndValidate already resolved onto cfg.ConsensusClient. + // goclient consumes the block-fetch values (ProposalCollectionSlotRelative / EarlyExitOnBlinded + // / ProposalSoftTimeout / ProposalSoftDeadline) that resolveAndValidate already resolved onto + // cfg.ConsensusClient. consensusClient, err := goclient.New(ctx, logger, cfg.ConsensusClient) if err != nil { return startupError{ From 64876f8155efc6a830b1a43c5dd1a6836ff90031 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 7 Jun 2026 14:11:53 +0300 Subject: [PATCH 04/16] add disclaimer note --- docs/MEV_CONSIDERATIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 64d2c22c53..7ef71bd57e 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -11,6 +11,7 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without - if you run multiple Beacon nodes, set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` - see [Multi-BN setup](#multi-bn-setup) for details, single-BN operators can skip that section entirely - restart SSV node to apply - set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#configuration-knobs) for details +- it is desirable for all SSV nodes in the same cluster to run the same/similar configuration (very large differences may lead to missed duties) ## Definitions and typical values From 24a7b7da5296fa4686fbcb2c2742955b633b576b Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 7 Jun 2026 21:36:19 +0300 Subject: [PATCH 05/16] beacon/goclient, cli/operator: uniform ProposalSoftDeadline floor, drop safe path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProposalSoftDeadline (the MEV-optimized path) is now a slot-relative floor applied identically to single- and multi-BN setups: the fetched block is held until slot_start + ProposalSoftDeadline before QBFT starts, so every operator in the cluster starts QBFT at the same slot-relative time — aligning their proposer round timers and improving consensus convergence. Previously single-BN ignored the deadline entirely and multi-BN early-exited on the first blinded block, so neither delivered a cluster-aligned QBFT start. Multi-BN MEV-optimized no longer early-exits; it collects to the deadline and proposes the best-scored bid (bailing early only if every BN failed). Remove the `safe` block-fetch path: the default reverts to `legacy` (the original relative-timeout behavior). safe's early-exit meant it never aligned QBFT start, so it added a path and a knob without the benefit — operators who want MEV/alignment opt into ProposalSoftDeadline, the rest stay on legacy. Remove the now-redundant EarlyExitOnBlinded and ProposalCollectionSlotRelative Options knobs; goclient derives the path from ProposalSoftDeadline > 0 (useSlotRelativeFetch). Update tests (incl. new single-BN floor coverage) and docs/MEV_CONSIDERATIONS.md + config.example.yaml. --- beacon/goclient/attest_test.go | 17 +- beacon/goclient/goclient.go | 42 +-- beacon/goclient/goclient_test.go | 7 +- beacon/goclient/options.go | 26 +- beacon/goclient/proposer.go | 104 ++++--- .../goclient/proposer_path_dispatch_test.go | 271 ++++++++++-------- beacon/goclient/proposer_test.go | 22 +- cli/operator/config.go | 71 ++--- cli/operator/config_test.go | 18 +- cli/operator/node.go | 5 +- config/config.example.yaml | 29 +- docs/MEV_CONSIDERATIONS.md | 39 +-- 12 files changed, 326 insertions(+), 325 deletions(-) diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index b233b2a90d..133a49f53d 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -507,15 +507,14 @@ func createClient( beaconServerURL string, withWeightedAttestationData bool) (*GoClient, error) { return New(ctx, zap.NewNop(), Options{ - BeaconNodeAddr: beaconServerURL, - CommonTimeout: defaultHardTimeout, - LongTimeout: time.Second, - WithWeightedAttestationData: withWeightedAttestationData, - ProposalCollectionSlotRelative: true, - EarlyExitOnBlinded: true, - // Safe-path deadline (config resolution defaults this in production); required for the - // multi-BN variants of this helper to satisfy New's block-fetch precondition. - ProposalSoftDeadline: 1450 * time.Millisecond, + BeaconNodeAddr: beaconServerURL, + CommonTimeout: defaultHardTimeout, + LongTimeout: time.Second, + WithWeightedAttestationData: withWeightedAttestationData, + // Legacy (default) block-fetch path: relative-timeout collection, no slot-relative floor. + // Multi-BN variants of this helper need a positive ProposalSoftTimeout to satisfy New's + // block-fetch precondition. + ProposalSoftTimeout: 1800 * time.Millisecond, }) } diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index e2aa293e9b..f574482178 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -140,18 +140,13 @@ type GoClient struct { // proposalSoftDeadline instead. proposalSoftTimeout time.Duration - // proposalSoftDeadline is the slot-relative deadline (ms into slot) for the slot-relative - // collection (getProposalParallelByDeadline). See docs/MEV_CONSIDERATIONS.md. + // proposalSoftDeadline is the slot-relative deadline (ms into slot) for the MEV-optimized + // block-fetch path. A positive value both selects that path (see useSlotRelativeFetch) and + // bounds it: multi-BN collection runs until the deadline, and the fetched block is held until + // the deadline before QBFT starts. Zero selects the legacy relative-timeout path. See + // docs/MEV_CONSIDERATIONS.md. proposalSoftDeadline time.Duration - // proposalCollectionSlotRelative selects how GetBeaconBlock collects proposals across - // multiple BNs: true -> slot-relative deadline (getProposalParallelByDeadline), false -> - // legacy relative timeout (getProposalParallelLegacy). earlyExitOnBlinded stops the - // slot-relative collection on the first blinded (MEV) response. Both are resolved from - // operator config by cli/operator; see docs/MEV_CONSIDERATIONS.md. - proposalCollectionSlotRelative bool - earlyExitOnBlinded bool - // 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, @@ -200,8 +195,7 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error } // Apply mechanical network-timeout defaults (previously done by NewOptions, now removed). - // Block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline / - // ProposalCollectionSlotRelative / EarlyExitOnBlinded) arrive pre-resolved from + // Block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline) arrive pre-resolved from // cli/operator config resolution. if opt.CommonTimeout == 0 { opt.CommonTimeout = defaultCommonTimeout @@ -212,21 +206,13 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error beaconAddrList := strings.Split(opt.BeaconNodeAddr, ";") - // Defensive precondition for the multi-BN block-fetch paths: each path's collection - // window is driven by a timing knob that must be positive, otherwise the window is already - // expired on entry and the path silently degrades to "return the first valid response". - // These values arrive pre-resolved/pre-validated from cli/operator config resolution; this - // guard only catches a future caller that constructs Options directly without resolving them. - // Single-BN clients fetch directly (GetBeaconBlock) and never consult these knobs, so they - // are exempt. - if len(beaconAddrList) > 1 { - if opt.ProposalCollectionSlotRelative { - if opt.ProposalSoftDeadline <= 0 { - return nil, fmt.Errorf("slot-relative proposal collection requires a positive ProposalSoftDeadline, got %v", opt.ProposalSoftDeadline) - } - } else if opt.ProposalSoftTimeout <= 0 { - return nil, fmt.Errorf("legacy (relative) proposal collection requires a positive ProposalSoftTimeout, got %v", opt.ProposalSoftTimeout) - } + // Defensive precondition: multi-BN legacy collection needs a positive ProposalSoftTimeout, else + // its window is already expired on entry and it silently degrades to "return the first valid + // response". (The MEV-optimized path is keyed off a positive ProposalSoftDeadline, so it can't + // hit this; a single-BN legacy client fetches directly and needs neither knob.) Pre-resolved by + // cli/operator config; this guard only catches a future caller that builds Options directly. + if opt.ProposalSoftDeadline <= 0 && len(beaconAddrList) > 1 && opt.ProposalSoftTimeout <= 0 { + return nil, fmt.Errorf("multi-BN legacy proposal collection requires a positive ProposalSoftTimeout, got %v", opt.ProposalSoftTimeout) } client := &GoClient{ @@ -241,8 +227,6 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error weightedAttestationDataHardTimeout: opt.CommonTimeout, proposalSoftTimeout: opt.ProposalSoftTimeout, proposalSoftDeadline: opt.ProposalSoftDeadline, - proposalCollectionSlotRelative: opt.ProposalCollectionSlotRelative, - earlyExitOnBlinded: opt.EarlyExitOnBlinded, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), } diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index 896c06f8f7..00dbe4b293 100644 --- a/beacon/goclient/goclient_test.go +++ b/beacon/goclient/goclient_test.go @@ -174,10 +174,9 @@ func runHealthyTest( CommonTimeout: commonTimeout, LongTimeout: longTimeout, SyncDistanceTolerance: syncDistanceTolerance, - // This multi-BN client uses the slot-relative (safe) collection; a positive deadline is - // required to satisfy New's block-fetch precondition (unused by this sync-focused test). - ProposalCollectionSlotRelative: true, - ProposalSoftDeadline: 1450 * time.Millisecond, + // This multi-BN client uses the MEV-optimized (slot-relative) path; the positive deadline + // both selects it and satisfies New's block-fetch precondition (unused by this sync test). + ProposalSoftDeadline: 1450 * time.Millisecond, }) require.NoError(t, err) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index c1989d6f56..5a56e98e1c 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -30,22 +30,12 @@ type Options struct { // 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). Cannot be set lower than 500ms, to leave the Beacon node enough time to serve the block-fetch request. 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 the safe-path default deadline. - // - Set explicitly -> MEV-optimized path. - // 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."` - - // ProposalCollectionSlotRelative and EarlyExitOnBlinded are the mechanical multi-BN - // proposal-collection knobs resolved by cli/operator config resolution (not configured - // directly by the operator): - // - ProposalCollectionSlotRelative: true -> collect until the slot-relative - // ProposalSoftDeadline; false -> collect for the relative ProposalSoftTimeout (legacy). - // - EarlyExitOnBlinded: stop collecting on the first blinded (MEV) response. Applies to - // the slot-relative collection; the legacy collection always early-exits internally. - // GoClient consumes these to dispatch block fetching. See docs/MEV_CONSIDERATIONS.md. - ProposalCollectionSlotRelative bool `yaml:"-"` - EarlyExitOnBlinded bool `yaml:"-"` + // ProposalSoftDeadline is the slot-relative deadline (in ms-into-slot) for the MEV-optimized + // proposal-collection window. + // - Unset (zero) -> legacy (default) relative-timeout path. + // - Set explicitly -> MEV-optimized path: collect proposals until this slot-relative + // deadline (no early-exit), then start QBFT at it. Applies to single- and multi-BN setups + // alike, so all operators in the cluster start QBFT at the same slot-relative time. + // 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 MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) 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."` } diff --git a/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index 9a8c0fef15..472734b77a 100644 --- a/beacon/goclient/proposer.go +++ b/beacon/goclient/proposer.go @@ -77,6 +77,13 @@ func (gc *GoClient) fetchProposal( return resp.Data, nil } +// useSlotRelativeFetch reports whether the operator opted into the MEV-optimized slot-relative +// block-fetch path, signaled by a positive ProposalSoftDeadline. Otherwise the legacy +// relative-timeout path is used. +func (gc *GoClient) useSlotRelativeFetch() bool { + return gc.proposalSoftDeadline > 0 +} + // GetBeaconBlock implements ProposerCalls.GetBeaconBlock func (gc *GoClient) GetBeaconBlock( ctx context.Context, @@ -99,20 +106,27 @@ func (gc *GoClient) GetBeaconBlock( var beaconBlock *api.VersionedProposal var err error - // For single client, use direct call to avoid multi-client overhead + // For single client, use direct call to avoid multi-client overhead. if len(gc.clients) == 1 { beaconBlock, err = gc.fetchProposal(ctx, gc.clients[0], slot, sig, graffiti) if err != nil { return nil, nil, err } + // On the MEV-optimized path, hold the fetched block until the slot-relative deadline so a + // single-BN operator starts QBFT at the same slot time as multi-BN operators in the cluster + // (which reach the same deadline via their collection window). The legacy path has no such + // floor. See docs/MEV_CONSIDERATIONS.md. + if gc.useSlotRelativeFetch() { + if err = gc.waitUntilProposalSoftDeadline(ctx, slot); err != nil { + return nil, nil, err + } + } } else { - // For multiple clients, race them in parallel. Two mechanical knobs resolved from - // operator config by cli/operator drive the strategy: proposalCollectionSlotRelative - // selects the collection-window timing (slot-relative deadline vs legacy relative - // timeout), and earlyExitOnBlinded whether to stop on the first blinded (MEV) response. - // See docs/MEV_CONSIDERATIONS.md. - if gc.proposalCollectionSlotRelative { - beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti, gc.earlyExitOnBlinded) + // For multiple clients, race them in parallel. useSlotRelativeFetch selects the strategy: + // the MEV-optimized slot-relative window (collect to the deadline, pick the best bid), or + // the legacy relative timeout. + if gc.useSlotRelativeFetch() { + beaconBlock, err = gc.getProposalParallelByDeadline(ctx, logger, slot, sig, graffiti) } else { beaconBlock, err = gc.getProposalParallelLegacy(ctx, logger, slot, sig, graffiti) } @@ -325,7 +339,7 @@ type proposalFetchResult struct { // spawnProposalFetchers starts a goroutine per beacon-node client; each goroutine // fetches a proposal and writes its result to the returned channel. Used by the -// safe and MEV-optimized block-fetch implementations. +// MEV-optimized block-fetch implementation. // // The channel is buffered to `len(gc.clients)` so each goroutine can write without // blocking even if the consumer has already returned. @@ -350,9 +364,9 @@ func (gc *GoClient) spawnProposalFetchers( } // waitForFirstValidProposal returns the first valid proposal received from the -// remaining in-flight fetchers. Used by the safe and MEV-optimized paths as the -// fallback after the soft-deadline collection window has elapsed without producing -// a usable best proposal. Bounded by the parent context's slot deadline. +// remaining in-flight fetchers. Used by the MEV-optimized path as the fallback +// after the soft-deadline collection window has elapsed without producing a usable +// best proposal. Bounded by the parent context's slot deadline. func (gc *GoClient) waitForFirstValidProposal( ctx context.Context, logger *zap.Logger, @@ -389,32 +403,46 @@ func (gc *GoClient) waitForFirstValidProposal( return nil, fmt.Errorf("all %d clients failed to get proposal for slot %d, encountered errors: %w", len(gc.clients), slot, errs) } -// getProposalParallelByDeadline implements the slot-relative-deadline parallel -// block-fetch shared by the safe and MEV-optimized paths. +// waitUntilProposalSoftDeadline blocks until the slot-relative proposal soft deadline +// (slot_start + gc.proposalSoftDeadline) for the given slot, or until ctx is canceled. Returns +// immediately if the deadline has already passed (e.g. after a slow block fetch). Used by the +// single-BN MEV-optimized path to align QBFT start with multi-BN operators. See docs/MEV_CONSIDERATIONS.md. +func (gc *GoClient) waitUntilProposalSoftDeadline(ctx context.Context, slot phase0.Slot) error { + deadline := gc.getBeaconConfig().SlotStartTime(slot).Add(gc.proposalSoftDeadline) + wait := time.Until(deadline) + if wait <= 0 { + return nil + } + select { + case <-time.After(wait): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// getProposalParallelByDeadline implements the MEV-optimized slot-relative-deadline parallel +// block-fetch (multi-BN). // -// Spawns a per-BN fetch in parallel; collects responses until the slot-relative -// ProposalSoftDeadline (slot_start + gc.proposalSoftDeadline) fires. The -// earlyExitOnBlinded flag distinguishes the two paths: -// - true (safe path): stops collecting on the first blinded response and -// returns the best seen so far (treats blinded == MEV). -// - false (MEV-optimized path): keeps collecting after blinded so the -// highest-value bid across BNs can be selected. +// Spawns a per-BN fetch in parallel and collects responses until the slot-relative +// ProposalSoftDeadline (slot_start + gc.proposalSoftDeadline) fires — deliberately *without* +// early-exiting on the first blinded response, so that (a) the best-scored bid across BNs can be +// selected and (b) QBFT starts at the same slot-relative time across the cluster. It bails out +// before the deadline only if every BN has responded and none produced a usable proposal (waiting +// out the deadline cannot then conjure one). // -// After the deadline (or early-exit), returns the best-scored proposal collected -// so far, or falls through to waitForFirstValidProposal if nothing usable arrived. -// The parent ctx serves as the hard deadline. +// After the deadline, returns the best-scored proposal collected so far, or falls through to +// waitForFirstValidProposal if nothing usable arrived. The parent ctx serves as the hard deadline. // -// Note: in-flight BN fetches are spawned with the parent ctx (not softCtx), so a -// slow BN's HTTP call may keep running after we return — until the duty's slot -// deadline cancels ctx. Matches legacy behavior; the fetchProposal call's own -// HTTP timeouts bound the worst case. +// Note: in-flight BN fetches are spawned with the parent ctx (not softCtx), so a slow BN's HTTP +// call may keep running after we return — until the duty's slot deadline cancels ctx. The +// fetchProposal call's own HTTP timeouts bound the worst case. func (gc *GoClient) getProposalParallelByDeadline( ctx context.Context, logger *zap.Logger, slot phase0.Slot, sig phase0.BLSSignature, graffiti [32]byte, - earlyExitOnBlinded bool, ) (*api.VersionedProposal, error) { // Slot-relative deadline: fires at slot_start + ProposalSoftDeadline regardless // of when this function is invoked. @@ -432,13 +460,19 @@ func (gc *GoClient) getProposalParallelByDeadline( startCollect := time.Now() pendingClients := len(gc.clients) collect: - for pendingClients > 0 { + for { select { case res := <-resultCh: pendingClients-- if res.err != nil { errs = errors.Join(errs, res.err) + // If every client has responded and none produced a usable block, stop now — + // waiting out the deadline cannot conjure a proposal. (With a usable block in + // hand we keep waiting until the deadline below, to align QBFT start.) + if pendingClients == 0 && bestProposal == nil { + break collect + } continue } @@ -461,13 +495,9 @@ collect: bestClient = res.client } - if earlyExitOnBlinded && res.proposal.Blinded { - // Safe path: treat blinded == MEV and stop collecting. We return the - // best seen so far — usually this blinded one, but a higher-scored - // proposal that already arrived wins. - // MEV-optimized path keeps collecting to compare bids across BNs. - break collect - } + // No early-exit on blinded: we keep collecting until the slot-relative deadline even + // once we hold a (blinded/MEV) block, to compare bids across BNs and to align QBFT + // start across the cluster. See docs/MEV_CONSIDERATIONS.md. case <-softCtx.Done(): break collect diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index 51d41c0872..405a15b80e 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -14,30 +14,27 @@ import ( "github.com/ssvlabs/ssv/observability/log" ) -// Tests for multi-BN proposal-collection dispatch: the slot-relative-deadline strategy -// (with/without early-exit on blinded) and the legacy relative-timeout strategy. -// See docs/MEV_CONSIDERATIONS.md for the semantics. - -// TestNew_StoresProposalFetchConfig verifies that the mechanical multi-BN proposal-collection -// knobs (proposalCollectionSlotRelative / earlyExitOnBlinded) and their associated timing field -// (proposalSoftDeadline / proposalSoftTimeout) get propagated from Options into the GoClient. -// In production these resolved values come from cli/operator config resolution. +// Tests for proposal-collection dispatch: the MEV-optimized slot-relative-deadline strategy and the +// legacy relative-timeout strategy. See docs/MEV_CONSIDERATIONS.md for the semantics. + +// TestNew_StoresProposalFetchConfig verifies that the block-fetch timing fields (proposalSoftDeadline +// / proposalSoftTimeout) propagate from Options into the GoClient, and that useSlotRelativeFetch +// derives the path from them. In production these resolved values come from cli/operator config. func TestNew_StoresProposalFetchConfig(t *testing.T) { tests := []struct { - name string - opts Options // block-fetch knobs only; transport fields are filled in below + name string + opts Options // block-fetch timing field only; transport fields are filled in below + wantSlotRelative bool }{ { - name: "safe-equivalent (slot-relative, early-exit)", - opts: Options{ProposalCollectionSlotRelative: true, EarlyExitOnBlinded: true, ProposalSoftDeadline: 1100 * time.Millisecond}, - }, - { - name: "mev-equivalent (slot-relative, no early-exit)", - opts: Options{ProposalCollectionSlotRelative: true, EarlyExitOnBlinded: false, ProposalSoftDeadline: 1100 * time.Millisecond}, + name: "mev-optimized (ProposalSoftDeadline set)", + opts: Options{ProposalSoftDeadline: 1100 * time.Millisecond}, + wantSlotRelative: true, }, { - name: "legacy-equivalent (relative timeout)", - opts: Options{ProposalCollectionSlotRelative: false, ProposalSoftTimeout: 1800 * time.Millisecond}, + name: "legacy (ProposalSoftTimeout set)", + opts: Options{ProposalSoftTimeout: 1800 * time.Millisecond}, + wantSlotRelative: false, }, } for _, tt := range tests { @@ -53,60 +50,21 @@ func TestNew_StoresProposalFetchConfig(t *testing.T) { client, err := New(t.Context(), log.TestLogger(t), base) require.NoError(t, err) - assert.Equal(t, tt.opts.ProposalCollectionSlotRelative, client.proposalCollectionSlotRelative, - "New should propagate ProposalCollectionSlotRelative") - assert.Equal(t, tt.opts.EarlyExitOnBlinded, client.earlyExitOnBlinded, - "New should propagate EarlyExitOnBlinded") assert.Equal(t, tt.opts.ProposalSoftDeadline, client.proposalSoftDeadline, "New should propagate ProposalSoftDeadline") assert.Equal(t, tt.opts.ProposalSoftTimeout, client.proposalSoftTimeout, "New should propagate ProposalSoftTimeout") + assert.Equal(t, tt.wantSlotRelative, client.useSlotRelativeFetch(), + "useSlotRelativeFetch should reflect a positive ProposalSoftDeadline") }) } } -// TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded verifies the safe path's -// early-exit-on-first-blinded behavior. With one fast and one slow BN both returning -// blinded proposals, the safe path should return quickly after the fast BN responds, -// without waiting for the slow one. -func TestGetBeaconBlock_MultiBN_SafePath_EarlyExitOnBlinded(t *testing.T) { - bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 10 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllOnes(), - }) - defer bn1.Close() - bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 500 * time.Millisecond, - BlindedProposal: true, - FeeRecipient: feeRecipientAllTwos(), - }) - defer bn2.Close() - - client := setupMultiBNClient(t, bn1.URL, bn2.URL, true /* earlyExitOnBlinded */, 1500*time.Millisecond) - - // Use a slot starting in the near future so the slot-relative deadline lands - // well after both BN responses (we want to observe the early-exit on blinded, - // not the deadline firing). - slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 - - start := time.Now() - _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) - elapsed := time.Since(start) - require.NoError(t, err) - - // Safe path should early-exit on BN1's blinded response (~10ms) and NOT wait for - // BN2 (~500ms). The 350ms ceiling sits well below BN2's response time while - // tolerating HTTP / goroutine / loaded-CI overhead. - assert.Less(t, elapsed, 350*time.Millisecond, - "safe path should early-exit on first blinded; took %v", elapsed) -} - -// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit verifies that the MEV-optimized -// path does NOT early-exit on the first blinded response — it keeps collecting until all -// BNs respond (or the soft deadline fires). With the same setup as the safe-path test, -// the MEV-optimized path should wait for the slow BN. -func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { +// TestGetBeaconBlock_MultiBN_MEVOptimized_WaitsUntilDeadline verifies that the MEV-optimized path +// does NOT early-exit on the first blinded response: even with fast blinded BNs it keeps collecting +// until the slot-relative deadline before returning, so QBFT starts at a cluster-aligned slot time. +// (Contrast the legacy path, which early-exits on the first blinded.) +func TestGetBeaconBlock_MultiBN_MEVOptimized_WaitsUntilDeadline(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 10 * time.Millisecond, BlindedProposal: true, @@ -114,34 +72,34 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_NoEarlyExit(t *testing.T) { }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 500 * time.Millisecond, + ProposalResponseDuration: 50 * time.Millisecond, BlindedProposal: true, FeeRecipient: feeRecipientAllTwos(), }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, false /* earlyExitOnBlinded */, 1500*time.Millisecond) - - slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1500*time.Millisecond) + const deadlineFromNow = 600 * time.Millisecond + slot := armProposalDeadline(t, client, deadlineFromNow) start := time.Now() _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) elapsed := time.Since(start) require.NoError(t, err) - // MEV-optimized path should NOT early-exit; it waits for BN2's response at ~500ms - // before returning the best-scored proposal. The 400ms floor tolerates clock jitter. - assert.GreaterOrEqual(t, elapsed, 400*time.Millisecond, - "MEV-optimized path should wait for the slower BN; took %v", elapsed) + // Both BNs respond within ~50ms, but the floor holds the result until the deadline (~600ms). + // The lower bound (generous for clock jitter / scheduling) proves we did not early-exit; the + // upper bound guards against a regression that waits on the wrong (e.g. far-future) deadline. + assert.GreaterOrEqual(t, elapsed, 450*time.Millisecond, + "MEV-optimized path should wait until the slot-relative deadline, not early-exit; took %v", elapsed) + assert.Less(t, elapsed, 1200*time.Millisecond, + "MEV-optimized path should return at the deadline (~600ms); took %v", elapsed) } -// TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins verifies that -// when multiple BNs return blinded proposals within the collection window, the -// MEV-optimized path selects the one with the highest scoreProposal value (sum of -// ConsensusValue and ExecutionValue) rather than the first-arriving one. BN1 returns -// a fast low-value blinded; BN2 returns a slow high-value blinded — the function must -// return BN2's proposal. -func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *testing.T) { +// TestGetBeaconBlock_MultiBN_MEVOptimized_HighestScoringBlindedWins verifies that when multiple BNs +// return blinded proposals within the collection window, the MEV-optimized path selects the +// highest-scoring one (sum of ConsensusValue + ExecutionValue), not the first-arriving. +func TestGetBeaconBlock_MultiBN_MEVOptimized_HighestScoringBlindedWins(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 10 * time.Millisecond, BlindedProposal: true, @@ -150,16 +108,15 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *te }) defer bn1.Close() bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ - ProposalResponseDuration: 300 * time.Millisecond, + ProposalResponseDuration: 200 * time.Millisecond, BlindedProposal: true, FeeRecipient: feeRecipientAllTwos(), ExecutionValue: big.NewInt(5_000_000), // high bid (must win) }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, false /* earlyExitOnBlinded */, 1500*time.Millisecond) - - slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 + client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1500*time.Millisecond) + slot := armProposalDeadline(t, client, 500*time.Millisecond) versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) require.NoError(t, err) @@ -171,11 +128,11 @@ func TestGetBeaconBlock_MultiBN_MEVOptimizedPath_HighestScoringBlindedWins(t *te "MEV-optimized path should select the higher-value blinded (BN2's), not the first-arriving (BN1's)") } -// TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid verifies that -// when the slot-relative soft deadline has already fired before any BN responds, -// the parallel-fetch path falls through to waitForFirstValidProposal and returns -// the first valid BN response. Uses a slot in the past so the deadline is past. -func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testing.T) { +// TestGetBeaconBlock_MultiBN_MEVOptimized_DeadlinePast_FallsBackToFirstValid verifies that when the +// slot-relative soft deadline has already fired before any BN responds, the path falls through to +// waitForFirstValidProposal and returns the first valid BN response. Uses a slot in the past so the +// deadline is already past on entry. +func TestGetBeaconBlock_MultiBN_MEVOptimized_DeadlinePast_FallsBackToFirstValid(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 200 * time.Millisecond, BlindedProposal: true, @@ -189,11 +146,10 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, true /* earlyExitOnBlinded */, 1000*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1000*time.Millisecond) - // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative - // deadline = slotStart + 1000ms is also in the past, so softCtx is already - // done when the collection loop starts. + // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative deadline = + // slotStart + 1000ms is also in the past, so softCtx is already done when collection starts. pastSlot := phase0.Slot(1) start := time.Now() @@ -202,28 +158,71 @@ func TestGetBeaconBlock_MultiBN_SoftDeadlineFires_FallsBackToFirstValid(t *testi require.NoError(t, err, "fallback to first-valid should return successfully") require.NotNil(t, versionedProposal) - // Primary assertion: BN1's fee recipient confirms we returned with the first - // valid response (BN1 at ~200ms), not the slower BN2 (~500ms). This is robust - // against timing jitter on busy CI runners. + // BN1's fee recipient confirms we returned the first valid response (BN1 at ~200ms), not the + // slower BN2 (~500ms). Robust against timing jitter on busy CI runners. actualFeeRecipient, err := versionedProposal.FeeRecipient() require.NoError(t, err) assert.Equal(t, feeRecipientAllOnes(), actualFeeRecipient, "waitForFirstValidProposal should return BN1's response (first valid), not BN2's") - - // Sanity check on elapsed: must be at least BN1's response time, and the upper - // bound just confirms we didn't end up waiting for BN2. Margins kept generous - // for CI scheduling overhead. assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, "should have waited for first BN response (~200ms); took %v", elapsed) assert.Less(t, elapsed, 450*time.Millisecond, "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) } +// TestGetBeaconBlock_SingleBN_MEVOptimized_WaitsUntilDeadline verifies the single-BN deadline floor: +// even though the lone BN responds quickly, GetBeaconBlock holds the block until the slot-relative +// deadline so a single-BN operator starts QBFT at the same slot time as multi-BN operators. +func TestGetBeaconBlock_SingleBN_MEVOptimized_WaitsUntilDeadline(t *testing.T) { + bn, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn.Close() + + client := setupSingleBNClient(t, bn.URL, true /* slotRelative */, 1500*time.Millisecond) + const deadlineFromNow = 500 * time.Millisecond + slot := armProposalDeadline(t, client, deadlineFromNow) + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + assert.GreaterOrEqual(t, elapsed, 350*time.Millisecond, + "single-BN MEV-optimized path should hold the block until the slot-relative deadline; took %v", elapsed) + assert.Less(t, elapsed, 1100*time.Millisecond, + "single-BN MEV-optimized path should return at the deadline (~500ms); took %v", elapsed) +} + +// TestGetBeaconBlock_SingleBN_Legacy_NoFloor verifies that a single-BN legacy client returns as soon +// as its BN responds — the deadline floor applies only to the slot-relative (MEV-optimized) path. +func TestGetBeaconBlock_SingleBN_Legacy_NoFloor(t *testing.T) { + bn, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 10 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn.Close() + + client := setupSingleBNClient(t, bn.URL, false /* slotRelative */, 0) + slot := client.getBeaconConfig().EstimatedCurrentSlot() + + start := time.Now() + _, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + require.NoError(t, err) + + assert.Less(t, elapsed, 300*time.Millisecond, + "single-BN legacy path should return promptly, without a deadline floor; took %v", elapsed) +} + // TestGetBeaconBlock_MultiBN_LegacyPath_EarlyExitOnBlinded drives the legacy block-fetch path -// (getProposalParallelLegacy) end-to-end. Like the safe path, legacy early-exits on the first -// blinded response: with one fast and one slow blinded BN it must return shortly after the fast -// BN without waiting for the slow one. Legacy uses a *relative* collection timeout (unlike the -// safe/MEV slot-relative deadline), so slot timing is irrelevant here. +// (getProposalParallelLegacy) end-to-end. Legacy early-exits on the first blinded response: with one +// fast and one slow blinded BN it must return shortly after the fast BN without waiting for the slow +// one. Legacy uses a *relative* collection timeout (unlike the MEV-optimized slot-relative +// deadline), so slot timing is irrelevant here. func TestGetBeaconBlock_MultiBN_LegacyPath_EarlyExitOnBlinded(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 10 * time.Millisecond, @@ -256,12 +255,12 @@ func TestGetBeaconBlock_MultiBN_LegacyPath_EarlyExitOnBlinded(t *testing.T) { "legacy path should early-exit on first blinded; took %v", elapsed) } -// TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid exercises the legacy -// path's *relative* collection timeout (gc.proposalSoftTimeout, measured from when the fetch -// starts — the key behavioral difference from the safe/MEV slot-relative deadline) and its -// fallback. With a 50ms relative timeout and no BN responding that fast, the collection window -// expires before any proposal arrives, so the path falls through to waitForFirstValidProposal -// and returns the first valid response (BN1's, ~200ms). +// TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid exercises the legacy path's +// *relative* collection timeout (gc.proposalSoftTimeout, measured from when the fetch starts — the +// key behavioral difference from the MEV-optimized slot-relative deadline) and its fallback. With a +// 50ms relative timeout and no BN responding that fast, the collection window expires before any +// proposal arrives, so the path falls through to waitForFirstValidProposal and returns the first +// valid response (BN1's, ~200ms). func TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid(t *testing.T) { bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 200 * time.Millisecond, @@ -297,27 +296,56 @@ func TestGetBeaconBlock_MultiBN_LegacyPath_SoftTimeoutFallsBackToFirstValid(t *t "should NOT have waited for the slower BN2 (~500ms); took %v", elapsed) } +// armProposalDeadline sets client.proposalSoftDeadline so the slot-relative deadline for the +// returned (current) slot lands approximately `fromNow` in the future, regardless of where "now" +// sits within the slot. This keeps floor-based collection tests fast and deterministic: the +// MEV-optimized path always waits until slot_start + proposalSoftDeadline, so a test must place +// that point a short, known time ahead. (White-box: these tests share the goclient package.) +func armProposalDeadline(t *testing.T, client *GoClient, fromNow time.Duration) phase0.Slot { + t.Helper() + slot := client.getBeaconConfig().EstimatedCurrentSlot() + slotStart := client.getBeaconConfig().SlotStartTime(slot) + client.proposalSoftDeadline = time.Since(slotStart) + fromNow + return slot +} + // setupMultiBNClient builds a GoClient connected to two test BN servers via semicolon-separated -// URLs, on the slot-relative-deadline strategy with the given early-exit-on-blinded setting and -// deadline. Used by the safe (earlyExit=true) / MEV-optimized (earlyExit=false) behavior tests. -func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, earlyExitOnBlinded bool, deadline time.Duration) *GoClient { +// URLs, on the MEV-optimized slot-relative-deadline strategy with the given deadline. Tests that +// need the deadline to land a short, known time from now should call armProposalDeadline after. +func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, deadline time.Duration) *GoClient { t.Helper() client, err := New(t.Context(), log.TestLogger(t), Options{ - BeaconNodeAddr: bn1URL + ";" + bn2URL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - ProposalSoftDeadline: deadline, - ProposalCollectionSlotRelative: true, - EarlyExitOnBlinded: earlyExitOnBlinded, + BeaconNodeAddr: bn1URL + ";" + bn2URL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + ProposalSoftDeadline: deadline, // positive deadline selects the MEV-optimized path }) require.NoError(t, err) return client } +// setupSingleBNClient builds a GoClient connected to a single test BN server. slotRelative selects +// the MEV-optimized slot-relative path (with the given deadline) vs the legacy direct fetch. +func setupSingleBNClient(t *testing.T, bnURL string, slotRelative bool, deadline time.Duration) *GoClient { + t.Helper() + + opts := Options{ + BeaconNodeAddr: bnURL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + } + if slotRelative { + opts.ProposalSoftDeadline = deadline // positive deadline selects the MEV-optimized path + } + client, err := New(t.Context(), log.TestLogger(t), opts) + require.NoError(t, err) + return client +} + // setupMultiBNLegacyClient builds a GoClient connected to two test BN servers on the legacy -// block-fetch path, with the given relative ProposalSoftTimeout. Mirrors setupMultiBNClient -// (which covers the safe / MEV-optimized slot-relative-deadline paths). +// block-fetch path, with the given relative ProposalSoftTimeout. Mirrors setupMultiBNClient (which +// covers the MEV-optimized slot-relative-deadline path). func setupMultiBNLegacyClient(t *testing.T, bn1URL, bn2URL string, softTimeout time.Duration) *GoClient { t.Helper() @@ -325,8 +353,7 @@ func setupMultiBNLegacyClient(t *testing.T, bn1URL, bn2URL string, softTimeout t BeaconNodeAddr: bn1URL + ";" + bn2URL, CommonTimeout: time.Second * 2, LongTimeout: time.Second * 5, - ProposalSoftTimeout: softTimeout, - // Legacy = relative-timeout collection (ProposalCollectionSlotRelative defaults to false). + ProposalSoftTimeout: softTimeout, // selects the legacy path (no ProposalSoftDeadline set) }) require.NoError(t, err) return client diff --git a/beacon/goclient/proposer_test.go b/beacon/goclient/proposer_test.go index 065e6d70cd..a8127cbb1f 100644 --- a/beacon/goclient/proposer_test.go +++ b/beacon/goclient/proposer_test.go @@ -411,10 +411,8 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { feeRecipient2 := bellatrix.ExecutionAddress{0x22} feeRecipient3 := bellatrix.ExecutionAddress{0x33} - // Responses are generated per-request from the URL slot (rather than - // pre-generated) so the safe path's slot-relative deadline can be set against - // a future slot below — pre-baking a fixed slot would trip go-eth2-client's - // "expected slot N" response check. + // Responses are generated per-request from the requested slot so any slot works + // (pre-baking a fixed slot would trip go-eth2-client's "expected slot N" check). server1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ ProposalResponseDuration: 500 * time.Millisecond, FeeRecipient: feeRecipient1, @@ -440,9 +438,8 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { graffiti := []byte(testGraffiti) randao := getTestRANDAO() - // Use a future slot so the safe path's slot-relative ProposalSoftDeadline doesn't - // fire before the collection loop starts — otherwise this test would exercise - // waitForFirstValidProposal instead of the multi-BN scoring/racing logic. + // Legacy collection uses a relative timeout (not slot-relative), so slot timing + // is irrelevant here; any valid slot works. slot := client.getBeaconConfig().EstimatedCurrentSlot() + 2 startTime := time.Now() @@ -706,13 +703,10 @@ func TestProposalPreparationReconnectLogic_SkipsOnNilProvider(t *testing.T) { } func createClientForProposerTest(t *testing.T, serverURL string) (*GoClient, error) { + // Single-BN client: block fetch is a direct call, so no block-fetch path knobs are needed. return New(t.Context(), log.TestLogger(t), Options{ - BeaconNodeAddr: serverURL, - CommonTimeout: time.Second * 2, - LongTimeout: time.Second * 5, - ProposalCollectionSlotRelative: true, - EarlyExitOnBlinded: true, - // safe-path slot-relative deadline (config resolution defaults this in production). - ProposalSoftDeadline: 1450 * time.Millisecond, + BeaconNodeAddr: serverURL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, }) } diff --git a/cli/operator/config.go b/cli/operator/config.go index c6075f90e5..dee8cc5e9c 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -65,10 +65,11 @@ const ( // acknowledge the risk via AllowDangerousProposerDelay. maxSafeProposerDelay = 1000 * time.Millisecond - // ProposalSoftDeadline bounds (slot-relative). safeMaxProposalSoftDeadline is the - // startup-warning threshold; the safe path defaults to it. + // ProposalSoftDeadline bounds (slot-relative), used by the MEV-optimized path. + // [minProposalSoftDeadline, maxProposalSoftDeadline] is the hard accepted range; + // safeMaxProposalSoftDeadline is the startup-warning threshold above which a round-2 QBFT + // fallback may not fit within the slot. safeMaxProposalSoftDeadline = 1450 * time.Millisecond - defaultProposalSoftDeadline = safeMaxProposalSoftDeadline minProposalSoftDeadline = 1000 * time.Millisecond maxProposalSoftDeadline = 3600 * time.Millisecond @@ -160,11 +161,9 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { // path-specific knobs, resolves their defaults onto c.ConsensusClient (consumed by goclient at // runtime), and emits advisory warnings. A returned error is fatal. // -// Must run exactly once. It reads the raw operator inputs once, up front, then writes the -// resolved values back onto c.ConsensusClient — including, on the safe path, a default -// ProposalSoftDeadline. Because that field is also one of the inputs path selection snapshots, a -// second invocation would observe the resolved default and silently flip safe → mev-optimized. -// resolveAndValidate (the sole caller) runs once at startup. +// Must run exactly once (resolveAndValidate, the sole caller, runs it at startup): the legacy +// path resolves ProposalSoftTimeout in place (1800ms reduced by ProposerDelay, floored), so a +// second pass would reduce it twice. func (c *config) resolveBlockFetch(logger *zap.Logger) error { // Raw operator inputs, snapshotted before any resolution writes below — so path selection and // defaulting never observe a value that this function itself produced. @@ -195,24 +194,15 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { if softTimeout < minProposalSoftTimeout { softTimeout = minProposalSoftTimeout } + // goclient keys the legacy (relative-timeout) path off ProposalSoftTimeout > 0. c.ConsensusClient.ProposalSoftTimeout = softTimeout - // Legacy collects for a relative timeout and always early-exits on the first blinded. - c.ConsensusClient.ProposalCollectionSlotRelative = false - c.ConsensusClient.EarlyExitOnBlinded = true case blockFetchPathMEVOptimized: + // The operator-set ProposalSoftDeadline is validated and passed through unchanged; goclient + // keys the MEV-optimized (slot-relative) path off it. Applies to single- and multi-BN + // setups alike. See docs/MEV_CONSIDERATIONS.md. if err := validateProposalSoftDeadline(rawSoftDeadline); err != nil { return err } - // Slot-relative collection that keeps collecting past the first blinded to compare bids. - c.ConsensusClient.ProposalCollectionSlotRelative = true - c.ConsensusClient.EarlyExitOnBlinded = false - case blockFetchPathSafe: - // The safe path is selected only when no deadline was set, so resolve the unset deadline - // to the safe-path default. - c.ConsensusClient.ProposalSoftDeadline = defaultProposalSoftDeadline - // Slot-relative collection with early-exit on the first blinded (MEV) response. - c.ConsensusClient.ProposalCollectionSlotRelative = true - c.ConsensusClient.EarlyExitOnBlinded = true } logger.Info("block-fetch path selected", zap.String("path", path.String())) @@ -237,28 +227,25 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { zap.Int64("proposal_soft_deadline_ms", rawSoftDeadline.Milliseconds()), zap.Int64("safe_max_ms", safeMaxProposalSoftDeadline.Milliseconds())) } - case blockFetchPathSafe: - // Safe path has no advisory warning — its default deadline sits at the safe-max. } return nil } // blockFetchPath is the operator-facing block-fetch strategy selected at startup from config. -// It is policy vocabulary owned by the config layer; resolveBlockFetch translates it into the -// mechanical knobs goclient consumes (ProposalCollectionSlotRelative / EarlyExitOnBlinded). +// It is policy vocabulary owned by the config layer; resolveBlockFetch resolves it into the timing +// field goclient keys off (ProposalSoftDeadline for MEV-optimized, ProposalSoftTimeout for legacy). // Documented end-to-end in docs/MEV_CONSIDERATIONS.md. type blockFetchPath int const ( - // blockFetchPathSafe is the default: slot-relative collection with early-exit on the first - // blinded response (deadline defaults to safeMaxProposalSoftDeadline). - blockFetchPathSafe blockFetchPath = iota - // blockFetchPathLegacy preserves the original ProposerDelay / ProposalSoftTimeout behavior - // (relative-timeout collection); selected when an operator sets either legacy knob. - blockFetchPathLegacy - // blockFetchPathMEVOptimized is opt-in: slot-relative collection without early-exit, returns - // the best-scored response collected by ProposalSoftDeadline. Selected when an operator sets + // blockFetchPathLegacy is the default: relative-timeout collection that early-exits on the + // first blinded response. Selected when neither ProposalSoftDeadline nor the legacy knobs are + // set, or when an operator sets ProposerDelay / ProposalSoftTimeout explicitly. + blockFetchPathLegacy blockFetchPath = iota + // blockFetchPathMEVOptimized is opt-in: slot-relative collection (no early-exit) that returns + // the best-scored response collected by ProposalSoftDeadline and starts QBFT at that + // slot-relative deadline (single- and multi-BN setups alike). Selected when an operator sets // ProposalSoftDeadline explicitly. blockFetchPathMEVOptimized ) @@ -266,8 +253,6 @@ const ( // 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: @@ -277,9 +262,10 @@ func (p blockFetchPath) String() string { } } -// determineBlockFetchPath selects the block-fetch path from the operator's raw timing knobs. -// Negative durations and combining legacy knobs (ProposerDelay/ProposalSoftTimeout) with the -// MEV-optimized ProposalSoftDeadline are rejected. +// determineBlockFetchPath selects the block-fetch path from the operator's raw timing knobs: +// ProposalSoftDeadline set -> MEV-optimized; otherwise (nothing set, or ProposerDelay / +// ProposalSoftTimeout set) -> legacy (the default). Negative durations, and combining the legacy +// knobs with ProposalSoftDeadline, are rejected. func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposerDelay time.Duration) (blockFetchPath, error) { if proposerDelay < 0 { return 0, fmt.Errorf("ProposerDelay must be non-negative, got %v", proposerDelay) @@ -298,14 +284,11 @@ func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposer 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: + if deadlineSet { return blockFetchPathMEVOptimized, nil - default: - return blockFetchPathSafe, nil } + // Default (nothing set) and the explicit legacy knobs both resolve to the legacy path. + return blockFetchPathLegacy, nil } // validateProposalSoftDeadline ensures an operator-set ProposalSoftDeadline (MEV-optimized path) diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index da88956d3e..42ecd6df3b 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -195,7 +195,7 @@ func TestDetermineBlockFetchPath(t *testing.T) { want blockFetchPath wantErr string }{ - {name: "nothing set -> safe", want: blockFetchPathSafe}, + {name: "nothing set -> legacy (default)", want: blockFetchPathLegacy}, {name: "ProposerDelay -> legacy", proposerDelay: 300 * time.Millisecond, want: blockFetchPathLegacy}, {name: "ProposalSoftTimeout -> legacy", proposalSoftTimeout: 1500 * time.Millisecond, want: blockFetchPathLegacy}, {name: "ProposalSoftDeadline -> mev-optimized", proposalSoftDeadline: 1100 * time.Millisecond, want: blockFetchPathMEVOptimized}, @@ -223,7 +223,6 @@ func Test_blockFetchPath_String(t *testing.T) { path blockFetchPath want string }{ - {blockFetchPathSafe, "safe"}, {blockFetchPathLegacy, "legacy"}, {blockFetchPathMEVOptimized, "mev-optimized"}, {blockFetchPath(99), "unknown(99)"}, @@ -263,21 +262,19 @@ func TestValidateProposalSoftDeadline(t *testing.T) { } func Test_resolveBlockFetch_defaults(t *testing.T) { - t.Run("safe path defaults deadline to 1450ms and sets slot-relative early-exit knobs", func(t *testing.T) { + t.Run("default (nothing set) resolves to the legacy relative-timeout path", func(t *testing.T) { c := config{} require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.True(t, c.ConsensusClient.ProposalCollectionSlotRelative) - require.True(t, c.ConsensusClient.EarlyExitOnBlinded) - require.Equal(t, 1450*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + require.Equal(t, defaultProposalSoftTimeout, c.ConsensusClient.ProposalSoftTimeout) + require.Zero(t, c.ConsensusClient.ProposalSoftDeadline) }) t.Run("legacy path defaults soft timeout to 1800ms - delay", func(t *testing.T) { c := config{} c.ProposerDelay = 300 * time.Millisecond require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.False(t, c.ConsensusClient.ProposalCollectionSlotRelative) - require.True(t, c.ConsensusClient.EarlyExitOnBlinded) require.Equal(t, 1500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) + require.Zero(t, c.ConsensusClient.ProposalSoftDeadline) }) t.Run("legacy path floors soft timeout at 500ms", func(t *testing.T) { @@ -288,13 +285,12 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { require.Equal(t, 500*time.Millisecond, c.ConsensusClient.ProposalSoftTimeout) }) - t.Run("mev-optimized path keeps operator deadline and sets slot-relative no-early-exit knobs", func(t *testing.T) { + t.Run("mev-optimized path keeps the operator deadline", func(t *testing.T) { c := config{} c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.True(t, c.ConsensusClient.ProposalCollectionSlotRelative) - require.False(t, c.ConsensusClient.EarlyExitOnBlinded) require.Equal(t, 1850*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + require.Zero(t, c.ConsensusClient.ProposalSoftTimeout) }) t.Run("mev-optimized out-of-range deadline -> error", func(t *testing.T) { diff --git a/cli/operator/node.go b/cli/operator/node.go index 6de4468b68..29d2e6ce5a 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -80,9 +80,8 @@ func runNode(ctx context.Context, cfg *config, logger *zap.Logger) error { zap.Bool("with_parallel_submissions", cfg.ConsensusClient.WithParallelSubmissions), ) - // goclient consumes the block-fetch values (ProposalCollectionSlotRelative / EarlyExitOnBlinded - // / ProposalSoftTimeout / ProposalSoftDeadline) that resolveAndValidate already resolved onto - // cfg.ConsensusClient. + // goclient consumes the block-fetch values (ProposalSoftTimeout / ProposalSoftDeadline) that + // resolveAndValidate already resolved onto cfg.ConsensusClient. consensusClient, err := goclient.New(ctx, logger, cfg.ConsensusClient) if err != nil { return startupError{ diff --git a/config/config.example.yaml b/config/config.example.yaml index 9e1fb692eb..bef5ff07fc 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,23 +19,22 @@ eth2: # HTTP URL of the Beacon node to connect to. BeaconNodeAddr: http://example.url:5052 - # Block-fetch tuning. The SSV node selects between the safe (default), MEV-optimized, - # and legacy paths at startup based on the settings below; see + # Block-fetch tuning. The SSV node selects between the legacy (default) and MEV-optimized + # block-fetch paths at startup based on the settings below; see # docs/MEV_CONSIDERATIONS.md for the full model. # - # ProposalSoftDeadline opts the SSV node into the MEV-optimized path: SSV waits for all - # multi-BN responses until this slot-relative deadline (ms into slot) and returns the - # highest-value bid received, without early-exiting on the first MEV block. Match this - # to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, - # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not - # fit within the slot for typical clusters). Cannot be combined with ProposerDelay or - # ProposalSoftTimeout. - # Only relevant with multiple Beacon nodes — with a single Beacon node SSV fetches the - # block directly and this setting has no effect. - # Leave unset to use the default safe path (early-exit on first MEV block, deadline 1450ms). - # Note: setting ProposalSoftDeadline = 1450ms is *not* a no-op — it opts into the - # MEV-optimized path at the same numeric deadline the safe path uses by default - # (the difference is behavioral: no early-exit on first blinded, best-bid wins). + # ProposalSoftDeadline opts the SSV node into the MEV-optimized path. It is a slot-relative + # deadline (ms into slot) that applies to single- and multi-Beacon-node setups alike: + # - multi-BN: SSV collects responses from all BNs until this deadline (no early-exit on the + # first MEV block) and proposes the highest-value bid; + # - single- and multi-BN: SSV holds the block until this deadline before starting QBFT, so + # every operator in the cluster starts QBFT at the same slot-relative time (aligned round + # timers, better convergence). + # Match this to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, + # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not fit within + # the slot for typical clusters). Cannot be combined with ProposerDelay or ProposalSoftTimeout. + # Leave unset to use the default legacy path (relative-timeout collection, early-exit on the + # first MEV block; single-BN fetches directly with no slot-relative floor). # ProposalSoftDeadline: 1100ms # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 7ef71bd57e..04c74d6d98 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -2,13 +2,13 @@ ## TL;DR -To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the multi-BN bid scoring described in [Multi-BN setup](#multi-bn-setup). +To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the MEV-optimized block fetch described in [MEV-optimized block fetch](#mev-optimized-block-fetch). If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. **Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: - configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout` -- if you run multiple Beacon nodes, set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` - see [Multi-BN setup](#multi-bn-setup) for details, single-BN operators can skip that section entirely +- set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#mev-optimized-block-fetch) for details (applies to both single- and multi-Beacon-node setups) - restart SSV node to apply - set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#configuration-knobs) for details - it is desirable for all SSV nodes in the same cluster to run the same/similar configuration (very large differences may lead to missed duties) @@ -123,7 +123,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (multi-BN setups only — see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): +**SSV-side** (opts into MEV-optimized block fetch — see [MEV-optimized block fetch](#mev-optimized-block-fetch); applies to single- and multi-BN setups): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -172,7 +172,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (multi-BN setups only — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [Multi-BN setup](#multi-bn-setup); single-BN operators skip this): +**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#mev-optimized-block-fetch); applies to single- and multi-BN setups): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport @@ -186,7 +186,7 @@ The example configs are starting points. Production tuning requires measuring yo Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: -- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, giving a strict bound of `late_in_slot_time_ms ≲ ~1450ms`. **Recommended:** stay at `late_in_slot_time_ms ≲ ~1400ms` to keep a 50ms buffer for latency variance — this also matches SSV's startup-warning threshold (`SafeMaxProposalSoftDeadline = 1450ms` SSV-side, which equals `~1400ms` PBS-side plus the `~50ms` BN→SSV transport). +- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, giving a strict bound of `late_in_slot_time_ms ≲ ~1450ms`. **Recommended:** stay at `late_in_slot_time_ms ≲ ~1400ms` to keep a 50ms buffer for latency variance — this also matches SSV's startup-warning threshold (`safeMaxProposalSoftDeadline = 1450ms` SSV-side, which equals `~1400ms` PBS-side plus the `~50ms` BN→SSV transport). - **Cutoffs above ~1400ms** consume the variance buffer; SSV emits a startup warning. **Cutoffs above ~1450ms** are past the strict bound and accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. - **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~3000ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. @@ -207,40 +207,41 @@ Useful signals to baseline before tuning, by data source: **End-to-end** — submission round-trip from the signed block leaving SSV through the relay payload-reveal step (visible from PBS and relay logs). -## Multi-BN setup +## MEV-optimized block fetch -> Single-BN operators can skip this section — SSV bypasses parallel fetch entirely and calls the single BN directly regardless of which knobs are set below. - -With multiple Beacon nodes, SSV races them in parallel for the block proposal. The recommended action is to set `ProposalSoftDeadline`: +Setting `ProposalSoftDeadline` opts into the **MEV-optimized** block-fetch path: ```yaml eth2: ProposalSoftDeadline: ``` -This makes SSV wait for all BN responses up to that slot-relative deadline and return the highest-scored bid. Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. +`ProposalSoftDeadline` is a **slot-relative** deadline (measured from slot start). It does two things, and applies to single- and multi-Beacon-node setups alike: -### Default behavior (if you don't set `ProposalSoftDeadline`) +1. **Bid collection (multi-BN).** With multiple Beacon nodes, SSV races them in parallel and keeps collecting until the deadline — *without* early-exiting on the first blinded response — then proposes the highest-scored bid across all BNs. (With a single BN there is nothing to compare, so this step just fetches from that one BN.) +2. **QBFT start alignment (single- and multi-BN).** SSV holds the fetched block until the deadline before starting QBFT consensus, even when the block is already in hand. Because the deadline is slot-relative, every operator in the cluster starts QBFT at the same point in the slot — which keeps their QBFT round timers aligned and improves consensus convergence (round-change timing matches across operators). -SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid. If no blinded response arrives by the default slot-relative deadline (1450ms — the largest safest deadline for typical clusters; see [Tuning guidance](#tuning-guidance--measurement-methodology)), SSV returns the best non-blinded response collected so far, waiting for the first valid response if nothing usable arrived. +So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). -This default is faster but doesn't compare bid *values* across BNs — the first BN to return blinded wins regardless of bid quality. Fine for multi-BN setups run primarily for redundancy. +Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (see [Tuning guidance](#tuning-guidance--measurement-methodology)). + +### Default behavior (if you don't set `ProposalSoftDeadline`) -### Legacy approach +The default is the **legacy** block-fetch path (relative-timeout collection). With multiple Beacon nodes, SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid — falling back to the best response collected within `ProposalSoftTimeout` (a *relative* window, default 1800ms) if no blinded one arrives. With a single Beacon node, SSV fetches from it directly and starts QBFT as soon as the block arrives (no slot-relative floor). -Setting `ProposerDelay` or `ProposalSoftTimeout` selects legacy block-fetch behavior (preserved bit-for-bit) — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). SSV logs a startup warning suggesting migration. +The legacy default is faster in the common case but doesn't compare bid *values* across BNs, and doesn't align QBFT start across the cluster. It's fine for setups run primarily for redundancy; set `ProposalSoftDeadline` if you want cross-BN bid scoring and/or cluster-aligned QBFT start. The legacy `ProposerDelay` knob is also available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). ### Interaction -The approaches are mutually exclusive. Selection at startup: +`ProposalSoftDeadline` (MEV-optimized) and the legacy knobs (`ProposerDelay` / `ProposalSoftTimeout`) are mutually exclusive. Selection at startup: ``` if ProposerDelay > 0 || ProposalSoftTimeout is set: - -> legacy approach (see Appendix A) + -> legacy path (see Appendix A) elif ProposalSoftDeadline is set: - -> new approach (waits for all BN responses, picks highest-scored) + -> MEV-optimized path (collect to the slot-relative deadline, pick highest-scored, aligned QBFT start) else: - -> new approach default (returns first blinded response) + -> legacy path (the default) ``` Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one approach. From 6856c5698c6629aab3f9fac9557a6fe5ff6801b2 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 7 Jun 2026 22:23:09 +0300 Subject: [PATCH 06/16] adjustmets --- .../goclient/proposer_path_dispatch_test.go | 67 +++++++++++++++---- docs/MEV_CONSIDERATIONS.md | 6 +- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/beacon/goclient/proposer_path_dispatch_test.go b/beacon/goclient/proposer_path_dispatch_test.go index 405a15b80e..fba4f1e61e 100644 --- a/beacon/goclient/proposer_path_dispatch_test.go +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -78,7 +78,7 @@ func TestGetBeaconBlock_MultiBN_MEVOptimized_WaitsUntilDeadline(t *testing.T) { }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1500*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL) const deadlineFromNow = 600 * time.Millisecond slot := armProposalDeadline(t, client, deadlineFromNow) @@ -115,7 +115,7 @@ func TestGetBeaconBlock_MultiBN_MEVOptimized_HighestScoringBlindedWins(t *testin }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1500*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL) slot := armProposalDeadline(t, client, 500*time.Millisecond) versionedProposal, _, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) @@ -146,10 +146,11 @@ func TestGetBeaconBlock_MultiBN_MEVOptimized_DeadlinePast_FallsBackToFirstValid( }) defer bn2.Close() - client := setupMultiBNClient(t, bn1.URL, bn2.URL, 1000*time.Millisecond) + client := setupMultiBNClient(t, bn1.URL, bn2.URL) - // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative deadline = - // slotStart + 1000ms is also in the past, so softCtx is already done when collection starts. + // Slot 1 is in the past (mainnet genesis is in 2020). The slot-relative deadline + // (slotStart + ProposalSoftDeadline) is therefore also in the past, so softCtx is already done + // when collection starts. pastSlot := phase0.Slot(1) start := time.Now() @@ -170,6 +171,38 @@ func TestGetBeaconBlock_MultiBN_MEVOptimized_DeadlinePast_FallsBackToFirstValid( "should NOT have waited for the slowest BN (~500ms); took %v", elapsed) } +// TestGetBeaconBlock_MultiBN_MEVOptimized_AllFail verifies that when every BN fails on the +// MEV-optimized path, GetBeaconBlock returns the aggregated "all clients failed" error as soon as +// the last BN errors — it does NOT idle until the (future) slot-relative deadline, since once every +// BN has responded, waiting it out cannot conjure a proposal. +func TestGetBeaconBlock_MultiBN_MEVOptimized_AllFail(t *testing.T) { + bn1, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + WithProposalEndpointError: true, + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + WithProposalEndpointError: true, + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL) + // Deadline set far in the future: had the loop waited it out instead of bailing on total + // failure, elapsed would be ~2s. The early-bail returns as soon as the last BN errors. + const deadlineFromNow = 2 * time.Second + slot := armProposalDeadline(t, client, deadlineFromNow) + + start := time.Now() + versionedProposal, marshaledBlk, err := client.GetBeaconBlock(context.Background(), slot, []byte("test"), getTestRANDAO()) + elapsed := time.Since(start) + + require.Error(t, err) + require.Contains(t, err.Error(), "all 2 clients failed") + require.Nil(t, versionedProposal) + require.Nil(t, marshaledBlk) + assert.Less(t, elapsed, 500*time.Millisecond, + "should bail as soon as all BNs fail, not wait out the ~2s deadline; took %v", elapsed) +} + // TestGetBeaconBlock_SingleBN_MEVOptimized_WaitsUntilDeadline verifies the single-BN deadline floor: // even though the lone BN responds quickly, GetBeaconBlock holds the block until the slot-relative // deadline so a single-BN operator starts QBFT at the same slot time as multi-BN operators. @@ -181,7 +214,7 @@ func TestGetBeaconBlock_SingleBN_MEVOptimized_WaitsUntilDeadline(t *testing.T) { }) defer bn.Close() - client := setupSingleBNClient(t, bn.URL, true /* slotRelative */, 1500*time.Millisecond) + client := setupSingleBNClient(t, bn.URL, true /* slotRelative */) const deadlineFromNow = 500 * time.Millisecond slot := armProposalDeadline(t, client, deadlineFromNow) @@ -206,7 +239,7 @@ func TestGetBeaconBlock_SingleBN_Legacy_NoFloor(t *testing.T) { }) defer bn.Close() - client := setupSingleBNClient(t, bn.URL, false /* slotRelative */, 0) + client := setupSingleBNClient(t, bn.URL, false /* slotRelative */) slot := client.getBeaconConfig().EstimatedCurrentSlot() start := time.Now() @@ -309,25 +342,31 @@ func armProposalDeadline(t *testing.T, client *GoClient, fromNow time.Duration) return slot } +// pathSelectingSoftDeadline is any positive ProposalSoftDeadline — enough for New to select the +// MEV-optimized (slot-relative) path. Tests that care about *when* the deadline fires set it +// explicitly via armProposalDeadline (relative to now), or drive a slot whose deadline is already +// in the past; the value New sees is irrelevant to them beyond being positive. +const pathSelectingSoftDeadline = 1500 * time.Millisecond + // setupMultiBNClient builds a GoClient connected to two test BN servers via semicolon-separated -// URLs, on the MEV-optimized slot-relative-deadline strategy with the given deadline. Tests that -// need the deadline to land a short, known time from now should call armProposalDeadline after. -func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string, deadline time.Duration) *GoClient { +// URLs, on the MEV-optimized slot-relative-deadline path. Tests that need the deadline to land a +// short, known time from now should call armProposalDeadline after. +func setupMultiBNClient(t *testing.T, bn1URL, bn2URL string) *GoClient { t.Helper() client, err := New(t.Context(), log.TestLogger(t), Options{ BeaconNodeAddr: bn1URL + ";" + bn2URL, CommonTimeout: time.Second * 2, LongTimeout: time.Second * 5, - ProposalSoftDeadline: deadline, // positive deadline selects the MEV-optimized path + ProposalSoftDeadline: pathSelectingSoftDeadline, // positive value selects the MEV-optimized path }) require.NoError(t, err) return client } // setupSingleBNClient builds a GoClient connected to a single test BN server. slotRelative selects -// the MEV-optimized slot-relative path (with the given deadline) vs the legacy direct fetch. -func setupSingleBNClient(t *testing.T, bnURL string, slotRelative bool, deadline time.Duration) *GoClient { +// the MEV-optimized slot-relative path vs the legacy direct fetch. +func setupSingleBNClient(t *testing.T, bnURL string, slotRelative bool) *GoClient { t.Helper() opts := Options{ @@ -336,7 +375,7 @@ func setupSingleBNClient(t *testing.T, bnURL string, slotRelative bool, deadline LongTimeout: time.Second * 5, } if slotRelative { - opts.ProposalSoftDeadline = deadline // positive deadline selects the MEV-optimized path + opts.ProposalSoftDeadline = pathSelectingSoftDeadline // positive value selects the MEV-optimized path } client, err := New(t.Context(), log.TestLogger(t), opts) require.NoError(t, err) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 04c74d6d98..0007a7d756 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -225,13 +225,13 @@ So the deadline effectively *defines the QBFT instance start time*: QBFT starts Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (see [Tuning guidance](#tuning-guidance--measurement-methodology)). -### Default behavior (if you don't set `ProposalSoftDeadline`) +## Default (legacy) block fetch -The default is the **legacy** block-fetch path (relative-timeout collection). With multiple Beacon nodes, SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid — falling back to the best response collected within `ProposalSoftTimeout` (a *relative* window, default 1800ms) if no blinded one arrives. With a single Beacon node, SSV fetches from it directly and starts QBFT as soon as the block arrives (no slot-relative floor). +If you don't set `ProposalSoftDeadline`, the default is the **legacy** block-fetch path (relative-timeout collection). With multiple Beacon nodes, SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid — falling back to the best response collected within `ProposalSoftTimeout` (a *relative* window, default 1800ms) if no blinded one arrives. With a single Beacon node, SSV fetches from it directly and starts QBFT as soon as the block arrives (no slot-relative floor). The legacy default is faster in the common case but doesn't compare bid *values* across BNs, and doesn't align QBFT start across the cluster. It's fine for setups run primarily for redundancy; set `ProposalSoftDeadline` if you want cross-BN bid scoring and/or cluster-aligned QBFT start. The legacy `ProposerDelay` knob is also available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). -### Interaction +## Path selection `ProposalSoftDeadline` (MEV-optimized) and the legacy knobs (`ProposerDelay` / `ProposalSoftTimeout`) are mutually exclusive. Selection at startup: From 2fd240c3292b12b3ae196701b2a25892bb074332 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 12:08:28 +0300 Subject: [PATCH 07/16] cleanup --- docs/MEV_CONSIDERATIONS.md | 109 +++++++++---------------------------- 1 file changed, 27 insertions(+), 82 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 0007a7d756..318248c95d 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -11,11 +11,11 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without - set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#mev-optimized-block-fetch) for details (applies to both single- and multi-Beacon-node setups) - restart SSV node to apply - set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#configuration-knobs) for details -- it is desirable for all SSV nodes in the same cluster to run the same/similar configuration (very large differences may lead to missed duties) +- it is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties ## Definitions and typical values -The variables below name the stages of the SSV proposer-duty timeline. The values are typical/average for a healthy mainnet SSV cluster — real-world variance is significant, and operators should baseline their own latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)) before treating them as hard numbers. +The variables below name the stages of the SSV proposer-duty timeline. The values are typical/average for a healthy mainnet SSV cluster — real-world variance is significant. | Variable | Typical | Description | |---|---|---| @@ -40,17 +40,7 @@ You must budget for the worst case: in the common case round 1 succeeds quickly Where `(auction window)` sits in the slot is what determines MEV capture — bid value grows as the slot ages, so later auction windows yield higher-value bids on average, subject to staying within this deadline. -## PBS-side timing games (recommended) - -The PBS layer implements "timing games" — proactively polling relays at intervals defined in its own config, decoupling *when* the auction happens from *when* SSV asks for the block. SSV asks once and receives whatever bid the PBS has selected by its slot-relative cutoff. - -Preferred over `ProposerDelay` because: -- The PBS polls each relay multiple times within the auction window (`target_first_request_ms` + `frequency_get_header_ms`), capturing higher-value bids than a single `getHeader` per relay. `ProposerDelay` + single `getHeader` gets one bid per relay at one point in time; PBS-side timing games sample several and keep the best. -- The auction cutoff is slot-relative (`late_in_slot_time_ms`), so QBFT round 1 starts at a predictable point in the slot. With `ProposerDelay`, QBFT starts at `ProposerDelay + variable relay-response time`, which makes the post-auction budget harder to size. -- The PBS handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. -- Configuration is concentrated in the PBS rather than split across SSV-side and PBS-side knobs. - -### Configuration knobs +## PBS-side configuration Both mev-boost and commit-boost expose the same five knobs: @@ -78,9 +68,27 @@ Upstream references: - mev-boost: [github.com/flashbots/mev-boost/blob/main/docs/timing-games.md](https://github.com/flashbots/mev-boost/blob/main/docs/timing-games.md) - commit-boost: [commit-boost.github.io/commit-boost-client](https://commit-boost.github.io/commit-boost-client/) +## SSV-side configuration + +Setting `ProposalSoftDeadline` opts into the **MEV-optimized** block-fetch path: + +```yaml +eth2: + ProposalSoftDeadline: +``` + +`ProposalSoftDeadline` is a **slot-relative** deadline (measured from slot start). It does two things, and applies to single- and multi-Beacon-node setups alike: + +1. **Bid collection (multi-BN).** With multiple Beacon nodes, SSV races them in parallel and keeps collecting until the deadline — *without* early-exiting on the first blinded response — then proposes the highest-scored bid across all BNs. (With a single BN there is nothing to compare, so this step just fetches from that one BN.) +2. **QBFT start alignment (single- and multi-BN).** SSV holds the fetched block until the deadline before starting QBFT consensus, even when the block is already in hand. Because the deadline is slot-relative, every operator in the cluster starts QBFT at the same point in the slot — which keeps their QBFT round timers aligned and improves consensus convergence (round-change timing matches across operators). + +So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). + +Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. + ## Configuration examples -Two scenarios shown for both PBSes. The numbers are starting points for a healthy mainnet cluster — operators should validate against their own measured latencies (see [Tuning guidance](#tuning-guidance--measurement-methodology)). +Two scenarios shown for both PBSes. The numbers are starting points for a healthy mainnet cluster — operators should validate against their own measured latencies. ### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) @@ -178,76 +186,13 @@ eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport ``` -## Tuning guidance & measurement methodology - -The example configs are starting points. Production tuning requires measuring your own stack — relay RTTs, QBFT consensus times, and submission latencies vary enough between operators that a single recommended value isn't optimal for everyone. - -### Where the auction window should land - -Bid value grows through the slot, so the auction cutoff should be as late as possible, subject to: - -- **Round-2 fallback should fit:** `QBFT + PostConsensusSigning + BlockSubmission < 4000ms − late_in_slot_time_ms − ~50ms` (the ~50ms covers BN→SSV transport between the PBS cutoff and SSV receiving the header). Using the typical values from [Definitions](#definitions-and-typical-values), the post-cutoff budget needed is ~2500ms, giving a strict bound of `late_in_slot_time_ms ≲ ~1450ms`. **Recommended:** stay at `late_in_slot_time_ms ≲ ~1400ms` to keep a 50ms buffer for latency variance — this also matches SSV's startup-warning threshold (`safeMaxProposalSoftDeadline = 1450ms` SSV-side, which equals `~1400ms` PBS-side plus the `~50ms` BN→SSV transport). -- **Cutoffs above ~1400ms** consume the variance buffer; SSV emits a startup warning. **Cutoffs above ~1450ms** are past the strict bound and accept that round 1 must succeed — if round 1 fails, the slot may be missed (depending on your cluster's QBFT + submission latencies). Example B (1800ms) sits in this regime. -- **Round-1-only variance buffer:** even in the round-1-must-succeed regime, cutoffs much beyond ~3000ms tighten the slot enough that occasional latency spikes risk missing the deadline even when round 1 succeeds. - -### What to measure - -Useful signals to baseline before tuning, by data source: - -**On the SSV side** — metrics on Grafana (if export is enabled) and structured logs: -- **RANDAO completion time** — pre-consensus duration. -- **QBFT round-1 completion distribution** — consensus duration. -- `"got beacon block proposal"` log with `took` duration. -- `"received proposal"` debug log with `score`, `latency`, `blinded`, `pending` fields — emitted per BN response in multi-BN setups. -- `"successfully finished duty processing"` log with pre-consensus, consensus, and post-consensus splits. - -**On the PBS side** — PBS logs: -- BN → PBS RTT — typically same machine, well under 10ms. -- Per-relay RTT distribution (p50/p95/p99) — logged per `getHeader` call. - -**End-to-end** — submission round-trip from the signed block leaving SSV through the relay payload-reveal step (visible from PBS and relay logs). - -## MEV-optimized block fetch - -Setting `ProposalSoftDeadline` opts into the **MEV-optimized** block-fetch path: - -```yaml -eth2: - ProposalSoftDeadline: -``` - -`ProposalSoftDeadline` is a **slot-relative** deadline (measured from slot start). It does two things, and applies to single- and multi-Beacon-node setups alike: - -1. **Bid collection (multi-BN).** With multiple Beacon nodes, SSV races them in parallel and keeps collecting until the deadline — *without* early-exiting on the first blinded response — then proposes the highest-scored bid across all BNs. (With a single BN there is nothing to compare, so this step just fetches from that one BN.) -2. **QBFT start alignment (single- and multi-BN).** SSV holds the fetched block until the deadline before starting QBFT consensus, even when the block is already in hand. Because the deadline is slot-relative, every operator in the cluster starts QBFT at the same point in the slot — which keeps their QBFT round timers aligned and improves consensus convergence (round-change timing matches across operators). - -So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). - -Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (see [Tuning guidance](#tuning-guidance--measurement-methodology)). - -## Default (legacy) block fetch - -If you don't set `ProposalSoftDeadline`, the default is the **legacy** block-fetch path (relative-timeout collection). With multiple Beacon nodes, SSV returns as soon as one BN delivers a blinded (MEV) block — treating the first blinded response as the chosen MEV bid — falling back to the best response collected within `ProposalSoftTimeout` (a *relative* window, default 1800ms) if no blinded one arrives. With a single Beacon node, SSV fetches from it directly and starts QBFT as soon as the block arrives (no slot-relative floor). - -The legacy default is faster in the common case but doesn't compare bid *values* across BNs, and doesn't align QBFT start across the cluster. It's fine for setups run primarily for redundancy; set `ProposalSoftDeadline` if you want cross-BN bid scoring and/or cluster-aligned QBFT start. The legacy `ProposerDelay` knob is also available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). - -## Path selection - -`ProposalSoftDeadline` (MEV-optimized) and the legacy knobs (`ProposerDelay` / `ProposalSoftTimeout`) are mutually exclusive. Selection at startup: - -``` -if ProposerDelay > 0 || ProposalSoftTimeout is set: - -> legacy path (see Appendix A) -elif ProposalSoftDeadline is set: - -> MEV-optimized path (collect to the slot-relative deadline, pick highest-scored, aligned QBFT start) -else: - -> legacy path (the default) -``` - -Setting `ProposalSoftDeadline` together with either legacy knob (`ProposerDelay` or `ProposalSoftTimeout`) is rejected at startup with a clear error — pick one approach. - ## Appendix A — Legacy `ProposerDelay` approach +The PBS timing games approach is preferred over `ProposerDelay` because: +- The PBS polls each relay multiple times within the auction window (`target_first_request_ms` + `frequency_get_header_ms`), capturing higher-value bids than a single `getHeader` per relay. `ProposerDelay` + single `getHeader` gets one bid per relay at one point in time; PBS-side timing games sample several and keep the best. +- The auction cutoff is slot-relative (`late_in_slot_time_ms`), so QBFT round 1 starts at a predictable point in the slot. With `ProposerDelay`, QBFT starts at `ProposerDelay + variable relay-response time`, which makes the post-auction budget harder to size. +- The PBS handles auction-timing risk internally. If all polls time out or no relay responds, the PBS falls back to a local block (vanilla), not a missed slot. + `ProposerDelay` remains supported. It is the right tool when: - Your PBS does not support timing games (mev-boost < v1.11, or any PBS without the feature). - You are running mev-boost without `-config ` and don't want to introduce a YAML config file. From 55c6f9f421c87330a62f8939099ab1ccde809e69 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 12:33:43 +0300 Subject: [PATCH 08/16] clarify --- docs/MEV_CONSIDERATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 318248c95d..86a7569898 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -84,7 +84,7 @@ eth2: So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). -Valid range `[1000ms, 3600ms]`; values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed. +Valid range `[1000ms, 3600ms]`; values below 1000ms don't make much sense to use as you'd be leaving MEV opportunity on the table that's safe to extract (falling back to a locally built block); values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed; values above 3600ms don't make much sense to use as they leave no room for even 1 QBFT round. ## Configuration examples From d7e9d58b4ec3fa340a5cbd503f4e6c0bce3aa250 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 13:39:48 +0300 Subject: [PATCH 09/16] docs: clarify MEV deadline math and transport term, fix internal anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - simplify the per-request deadline to a single min() expression - explain the ~50ms BN→SSV transport margin and distinguish it from BlockSubmission - retarget 6 broken internal anchors to existing sections --- docs/MEV_CONSIDERATIONS.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 86a7569898..22ef5fb9a1 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -2,15 +2,15 @@ ## TL;DR -To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the MEV-optimized block fetch described in [MEV-optimized block fetch](#mev-optimized-block-fetch). +To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the MEV-optimized block fetch described in [MEV-optimized block fetch](#ssv-side-configuration). If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. **Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: - configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout` -- set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#mev-optimized-block-fetch) for details (applies to both single- and multi-Beacon-node setups) +- set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#ssv-side-configuration) for details (applies to both single- and multi-Beacon-node setups) - restart SSV node to apply -- set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#configuration-knobs) for details +- set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#pbs-side-configuration) for details - it is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties ## Definitions and typical values @@ -20,7 +20,7 @@ The variables below name the stages of the SSV proposer-duty timeline. The value | Variable | Typical | Description | |---|---|---| | `RANDAO` | ~50ms | Pre-consensus phase: SSV operators build the RANDAO signature used in the block-fetch request. | -| `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-timing-games-recommended). | +| `(auction window)` | varies | PBS-side relay polling. Configurable; see [PBS-side timing games](#pbs-side-configuration). | | `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | | `QBFT` | ~2350ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~100ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~250ms successful round 2). | | `PostConsensusSigning` | ~50ms | Operators reconstruct the validator BLS signature from partial signatures. | @@ -50,12 +50,11 @@ Both mev-boost and commit-boost expose the same five knobs: - `target_first_request_ms` — when the first poll for this relay fires, measured from slot start. - `frequency_get_header_ms` — interval between subsequent polls. -The effective per-request deadline is: +A single `getHeader` poll waits up to `timeout_get_header_ms`, but the PBS never lets it run past the `late_in_slot_time_ms` cutoff. So a poll fired at `ms_into_slot` returns by: ``` -max_timeout_ms = min(timeout_get_header_ms, late_in_slot_time_ms - ms_into_slot) -slot-relative cutoff = ms_into_slot + max_timeout_ms +min(ms_into_slot + timeout_get_header_ms, late_in_slot_time_ms) ``` -When the PBS receives the request early in the slot, `timeout_get_header_ms` tends to bind; when asked later, `late_in_slot_time_ms - ms_into_slot` binds. +Early in the slot the per-request timeout binds; closer to the cutoff, `late_in_slot_time_ms` binds and the poll is cut short. ### PBS-specific notes @@ -77,6 +76,8 @@ eth2: ProposalSoftDeadline: ``` +The `+ ~50ms BN→SSV transport` term is the inbound fetch hop: after the PBS returns its winning bid at `late_in_slot_time_ms`, the beacon node assembles the blinded block and forwards it to SSV. Adding it to the PBS cutoff estimates when SSV actually holds the block — the point the deadline should track. This is *not* the same as `BlockSubmission` in the [Definitions table](#definitions-and-typical-values): that is the outbound end of the flow (SSV → BN → relay reveal → propagation), and shares only the single BN↔SSV network hop with this term — `BlockSubmission` adds relay reveal and propagation on top, which is why it is the larger figure. + `ProposalSoftDeadline` is a **slot-relative** deadline (measured from slot start). It does two things, and applies to single- and multi-Beacon-node setups alike: 1. **Bid collection (multi-BN).** With multiple Beacon nodes, SSV races them in parallel and keeps collecting until the deadline — *without* early-exiting on the first blinded response — then proposes the highest-scored bid across all BNs. (With a single BN there is nothing to compare, so this step just fetches from that one BN.) @@ -131,7 +132,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (opts into MEV-optimized block fetch — see [MEV-optimized block fetch](#mev-optimized-block-fetch); applies to single- and multi-BN setups): +**SSV-side** (opts into MEV-optimized block fetch — see [MEV-optimized block fetch](#ssv-side-configuration); applies to single- and multi-BN setups): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -180,7 +181,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#mev-optimized-block-fetch); applies to single- and multi-BN setups): +**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#ssv-side-configuration); applies to single- and multi-BN setups): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport From 81dee8ce9fd33fdc9e631734b30aef399a1ac2fc Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 13:43:57 +0300 Subject: [PATCH 10/16] cleanup --- docs/MEV_CONSIDERATIONS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 22ef5fb9a1..c6055ebad5 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -7,11 +7,11 @@ To get the most out of MEV opportunities, configure `timing games on the PBS lay If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. **Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: -- configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout` -- set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#ssv-side-configuration) for details (applies to both single- and multi-Beacon-node setups) -- restart SSV node to apply -- set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#pbs-side-configuration) for details -- it is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties +- Configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout`. +- Set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#ssv-side-configuration) for details. +- Restart SSV node to apply. +- Set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#pbs-side-configuration) for details. +- It is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties. ## Definitions and typical values @@ -132,7 +132,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (opts into MEV-optimized block fetch — see [MEV-optimized block fetch](#ssv-side-configuration); applies to single- and multi-BN setups): +**SSV-side** (opts into MEV-optimized block fetch — see [SSV-side configuration](#ssv-side-configuration): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -181,7 +181,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#ssv-side-configuration); applies to single- and multi-BN setups): +**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#ssv-side-configuration)): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport From 892bd1b69fa141b3d7ef10bf30e3ac93fafcfab5 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 14:04:01 +0300 Subject: [PATCH 11/16] docs: drop redundant timeout_get_payload_ms from examples, unify SSV-side links - remove timeout_get_payload_ms = 4000 (just restates both PBSes' default) - close unbalanced paren in Example A SSV-side caption - standardize #ssv-side-configuration link text to "SSV-side configuration" --- docs/MEV_CONSIDERATIONS.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index c6055ebad5..b37dd521ba 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -2,13 +2,13 @@ ## TL;DR -To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the MEV-optimized block fetch described in [MEV-optimized block fetch](#ssv-side-configuration). +To get the most out of MEV opportunities, configure `timing games on the PBS layer` — either mev-boost v1.11+ launched with `-config ` (and optionally `-watch-config` for hot reload), or commit-boost. With PBS-side timing games configured, SSV's `ProposerDelay` should stay at its default value of `0` — operators relying on `ProposerDelay` can't also use the MEV-optimized block fetch described in [SSV-side configuration](#ssv-side-configuration). If your PBS does not support timing games (mev-boost < v1.11, mev-boost without `-config `, or any other PBS lacking the feature), SSV's `ProposerDelay` is still available — see [Appendix A](#appendix-a--legacy-proposerdelay-approach). PBS-side timing games are preferred because the PBS polls each relay multiple times within a precise slot-relative auction window — yielding higher-value bids than a single `getHeader` call after an SSV-side `ProposerDelay` sleep. **Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: - Configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout`. -- Set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [MEV-optimized block fetch](#ssv-side-configuration) for details. +- Set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [SSV-side configuration](#ssv-side-configuration) for details. - Restart SSV node to apply. - Set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#pbs-side-configuration) for details. - It is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties. @@ -102,7 +102,6 @@ The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = [pbs] late_in_slot_time_ms = 1050 timeout_get_header_ms = 1030 # must be < late_in_slot_time_ms in commit-boost -timeout_get_payload_ms = 4000 [[relays]] url = "https://@relay-1.example" @@ -132,7 +131,7 @@ relays: frequency_get_header_ms: 150 ``` -**SSV-side** (opts into MEV-optimized block fetch — see [SSV-side configuration](#ssv-side-configuration): +**SSV-side** (opts into MEV-optimized block fetch — see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport @@ -151,7 +150,6 @@ Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intr [pbs] late_in_slot_time_ms = 1800 timeout_get_header_ms = 1780 # must be < late_in_slot_time_ms in commit-boost -timeout_get_payload_ms = 4000 [[relays]] url = "https://@relay-1.example" @@ -181,7 +179,7 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [MEV-optimized block fetch](#ssv-side-configuration)): +**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport From bff5f74726d2d8706445e4a0ab36e7b9da608996 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 14:26:01 +0300 Subject: [PATCH 12/16] =?UTF-8?q?docs:=20widen=20BN=E2=86=92SSV=20transpor?= =?UTF-8?q?t=20guidance=20to=20~50=E2=80=93100ms,=20bump=20examples=20to?= =?UTF-8?q?=20100ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - express the margin as a measured ~50–100ms range; note it's BN block-assembly + one-way network and to round up (under-shooting costs MEV, not slots) - set examples to the conservative 100ms end: ProposalSoftDeadline 1150ms / 1900ms, with header-arrival and slot-budget figures updated to match --- docs/MEV_CONSIDERATIONS.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index b37dd521ba..5c4a1269bb 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -8,7 +8,7 @@ If your PBS does not support timing games (mev-boost < v1.11, mev-boost without **Do NOT apply both**: `timing games on the PBS layer` configuration + SSV's `ProposerDelay / ProposalSoftTimeout` - only one of these is supposed to run at any given time. Here are the recommended configuration steps, to avoid any sort of undesirable downtime during transition: - Configure SSV node first to remove/unset any of `ProposerDelay / ProposalSoftTimeout`. -- Set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50ms BN→SSV transport` to opt into MEV-optimized block fetch - see [SSV-side configuration](#ssv-side-configuration) for details. +- Set `ProposalSoftDeadline = your PBS late_in_slot_time_ms + ~50–100ms BN→SSV transport` to opt into MEV-optimized block fetch - see [SSV-side configuration](#ssv-side-configuration) for details. - Restart SSV node to apply. - Set/update mev/commit-boost configuration settings to enable `timing games on the PBS layer` - see [PBS configuration settings](#pbs-side-configuration) for details. - It is recommended that all SSV nodes in the same cluster use the same or similar configuration, as significant differences may lead to missed duties. @@ -73,10 +73,10 @@ Setting `ProposalSoftDeadline` opts into the **MEV-optimized** block-fetch path: ```yaml eth2: - ProposalSoftDeadline: + ProposalSoftDeadline: ``` -The `+ ~50ms BN→SSV transport` term is the inbound fetch hop: after the PBS returns its winning bid at `late_in_slot_time_ms`, the beacon node assembles the blinded block and forwards it to SSV. Adding it to the PBS cutoff estimates when SSV actually holds the block — the point the deadline should track. This is *not* the same as `BlockSubmission` in the [Definitions table](#definitions-and-typical-values): that is the outbound end of the flow (SSV → BN → relay reveal → propagation), and shares only the single BN↔SSV network hop with this term — `BlockSubmission` adds relay reveal and propagation on top, which is why it is the larger figure. +The `+ ~50–100ms BN→SSV transport` term is the inbound fetch hop: after the PBS returns its winning bid at `late_in_slot_time_ms`, the beacon node assembles the blinded block and forwards it to SSV. Adding it to the PBS cutoff estimates when SSV actually holds the block — the point the deadline should track. It covers BN-side block assembly plus a one-way BN→SSV hop, so it grows with remote or loaded beacon nodes — measure your own (SSV logs per-proposal arrival latency) and round up: under-shooting trims the multi-BN bid-collection window (costing MEV, not slots), while over-shooting costs only a few ms of slot budget. It is *not* the same as `BlockSubmission` in the [Definitions table](#definitions-and-typical-values): that is the outbound end of the flow (SSV → BN → relay reveal → propagation), sharing only the single BN↔SSV hop with this term — `BlockSubmission` adds relay reveal and propagation on top, which is why it is the larger figure. `ProposalSoftDeadline` is a **slot-relative** deadline (measured from slot start). It does two things, and applies to single- and multi-Beacon-node setups alike: @@ -93,7 +93,7 @@ Two scenarios shown for both PBSes. The numbers are starting points for a health ### Example A — bid-sample equivalent of legacy `ProposerDelay ≈ 1000ms` (recommended starting point) -Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1100ms (1050ms PBS cutoff + ~50ms BN→SSV) instead of legacy's ~1300–2000ms (depending on relay response speed), leaving more slot budget for QBFT and submission. +Lands the last relay poll at ~1000ms, matching when legacy `ProposerDelay = 1000ms` would have queried the relays. Useful as a migration baseline — same bid quality, but the header arrives at SSV at ~1150ms (1050ms PBS cutoff + ~100ms BN→SSV) instead of legacy's ~1300–2000ms (depending on relay response speed), leaving more slot budget for QBFT and submission. The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms. @@ -134,16 +134,16 @@ relays: **SSV-side** (opts into MEV-optimized block fetch — see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: - ProposalSoftDeadline: 1100ms # = PBS late_in_slot_time_ms (1050ms) + ~50ms BN→SSV transport + ProposalSoftDeadline: 1150ms # = PBS late_in_slot_time_ms (1050ms) + ~100ms BN→SSV transport ``` ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -Pushes the PBS-side cutoff to `1800ms` — past the ~1450ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1850ms. +Pushes the PBS-side cutoff to `1800ms` — past the ~1450ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1900ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. -Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2900ms to ~2150ms — below the ~2500ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2850ms to ~2100ms — below the ~2500ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -179,10 +179,10 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1850ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [SSV-side configuration](#ssv-side-configuration)): +**SSV-side** (opts into MEV-optimized block fetch — 1900ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: - ProposalSoftDeadline: 1850ms # = PBS late_in_slot_time_ms (1800ms) + ~50ms BN→SSV transport + ProposalSoftDeadline: 1900ms # = PBS late_in_slot_time_ms (1800ms) + ~100ms BN→SSV transport ``` ## Appendix A — Legacy `ProposerDelay` approach From 9ea2020d9a1255b2520df4de51b7cf06bbc728c2 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 15:12:16 +0300 Subject: [PATCH 13/16] cli/operator: gate ProposalSoftDeadline above safe-max behind AllowDangerousProposalSoftDeadline - ProposalSoftDeadline > 1450ms now fails startup unless AllowDangerousProposalSoftDeadline is set (then allowed up to the 3600ms hard max), mirroring AllowDangerousProposerDelay - add the flag to goclient.Options (eth2) + ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE env - keep the safe-max WARN (now reached only once the flag is set) - update tests + docs (Example B now requires the flag) + config.example.yaml --- beacon/goclient/options.go | 9 +++++- cli/operator/config.go | 23 ++++++++++---- cli/operator/config_test.go | 60 ++++++++++++++++++++++++++----------- config/config.example.yaml | 9 ++++-- docs/MEV_CONSIDERATIONS.md | 7 +++-- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 5a56e98e1c..1ca0fd7916 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -37,5 +37,12 @@ type Options struct { // deadline (no early-exit), then start QBFT at it. Applies to single- and multi-BN setups // alike, so all operators in the cluster start QBFT at the same slot-relative time. // 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 MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) 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."` + ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"WITH_PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 1450ms]; higher values up to 3600ms require AllowDangerousProposalSoftDeadline). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + + // AllowDangerousProposalSoftDeadline lifts the ProposalSoftDeadline safe-max cap (~1450ms) up + // to the hard maximum (3600ms). Without it, a ProposalSoftDeadline above the safe-max is + // rejected at startup, because the worst-case 2-round QBFT scenario may not fit within the slot + // (an explicit "round 1 must succeed" configuration). Mirrors AllowDangerousProposerDelay. + // See docs/MEV_CONSIDERATIONS.md. + AllowDangerousProposalSoftDeadline bool `yaml:"AllowDangerousProposalSoftDeadline" env:"ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE" env-description:"Allow ProposalSoftDeadline values above the safe-max (~1450ms) up to the hard maximum (3600ms). Dangerous: the worst-case 2-round QBFT fallback may not fit within the slot, risking missed proposals. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` } diff --git a/cli/operator/config.go b/cli/operator/config.go index dee8cc5e9c..13585002bf 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -66,9 +66,11 @@ const ( maxSafeProposerDelay = 1000 * time.Millisecond // ProposalSoftDeadline bounds (slot-relative), used by the MEV-optimized path. - // [minProposalSoftDeadline, maxProposalSoftDeadline] is the hard accepted range; - // safeMaxProposalSoftDeadline is the startup-warning threshold above which a round-2 QBFT - // fallback may not fit within the slot. + // [minProposalSoftDeadline, maxProposalSoftDeadline] is the hard accepted range. + // safeMaxProposalSoftDeadline is the largest value considered safe: above it the worst-case + // 2-round QBFT fallback may not fit within the slot, so the operator must explicitly + // acknowledge the risk via AllowDangerousProposalSoftDeadline (mirrors maxSafeProposerDelay / + // AllowDangerousProposerDelay). safeMaxProposalSoftDeadline = 1450 * time.Millisecond minProposalSoftDeadline = 1000 * time.Millisecond maxProposalSoftDeadline = 3600 * time.Millisecond @@ -200,7 +202,7 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { // The operator-set ProposalSoftDeadline is validated and passed through unchanged; goclient // keys the MEV-optimized (slot-relative) path off it. Applies to single- and multi-BN // setups alike. See docs/MEV_CONSIDERATIONS.md. - if err := validateProposalSoftDeadline(rawSoftDeadline); err != nil { + if err := validateProposalSoftDeadline(rawSoftDeadline, c.ConsensusClient.AllowDangerousProposalSoftDeadline); err != nil { return err } } @@ -218,6 +220,7 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { } case blockFetchPathMEVOptimized: if rawSoftDeadline > safeMaxProposalSoftDeadline { + // Reachable only after validateProposalSoftDeadline passed (AllowDangerousProposalSoftDeadline set). logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ "round-2 QBFT fallback may not fit within the slot deadline "+ @@ -292,14 +295,22 @@ func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposer } // validateProposalSoftDeadline ensures an operator-set ProposalSoftDeadline (MEV-optimized path) -// is within the acceptable range. The safe-max advisory warning is emitted separately. -func validateProposalSoftDeadline(d time.Duration) error { +// is within the hard [min, max] range, and rejects a value above safeMaxProposalSoftDeadline +// unless the operator explicitly acknowledges the risk via allowDangerous (mirrors +// validateProposerDelay). The safe-max WARN is emitted separately. +func validateProposalSoftDeadline(d time.Duration, allowDangerous bool) error { if d < minProposalSoftDeadline || d > maxProposalSoftDeadline { return fmt.Errorf("ProposalSoftDeadline value %dms is out of range [%dms, %dms]", d.Milliseconds(), minProposalSoftDeadline.Milliseconds(), maxProposalSoftDeadline.Milliseconds()) } + if d > safeMaxProposalSoftDeadline && !allowDangerous { + return fmt.Errorf("ProposalSoftDeadline value %dms exceeds maximum safe deadline of %dms. "+ + "This may cause missed block proposals. "+ + "If you understand the risks and want to proceed, set AllowDangerousProposalSoftDeadline to true or use the ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE environment variable", + d.Milliseconds(), safeMaxProposalSoftDeadline.Milliseconds()) + } return nil } diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index 42ecd6df3b..6f97a830da 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -236,27 +236,37 @@ func Test_blockFetchPath_String(t *testing.T) { func TestValidateProposalSoftDeadline(t *testing.T) { tests := []struct { - name string - value time.Duration - wantErr bool + name string + value time.Duration + allowDangerous bool + wantErr string // "" = no error }{ - {"at min 1000ms -> ok", 1000 * time.Millisecond, false}, - {"below min 999ms -> error", 999 * time.Millisecond, true}, - {"at safe-max 1450ms -> ok", 1450 * time.Millisecond, false}, - {"above safe-max 2500ms -> ok", 2500 * time.Millisecond, false}, - {"at max 3600ms -> ok", 3600 * time.Millisecond, false}, - {"above max 3601ms -> error", 3601 * time.Millisecond, true}, - {"zero -> error", 0, true}, + {name: "at min 1000ms -> ok", value: 1000 * time.Millisecond}, + {name: "below min 999ms -> error", value: 999 * time.Millisecond, wantErr: "out of range"}, + {name: "zero -> error", value: 0, wantErr: "out of range"}, + {name: "at safe-max 1450ms -> ok", value: 1450 * time.Millisecond}, + {name: "above safe-max 1451ms without flag -> error", value: 1451 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, + {name: "above safe-max 2500ms without flag -> error", value: 2500 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, + {name: "at max 3600ms without flag -> error", value: 3600 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, + {name: "above safe-max 2500ms with flag -> ok", value: 2500 * time.Millisecond, allowDangerous: true}, + {name: "at max 3600ms with flag -> ok", value: 3600 * time.Millisecond, allowDangerous: true}, + {name: "above max 3601ms with flag -> error", value: 3601 * time.Millisecond, allowDangerous: true, wantErr: "out of range"}, + {name: "above max 3601ms without flag -> error", value: 3601 * time.Millisecond, wantErr: "out of range"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateProposalSoftDeadline(tt.value) - if tt.wantErr { - require.Error(t, err) - require.Contains(t, err.Error(), "out of range") + err := validateProposalSoftDeadline(tt.value, tt.allowDangerous) + if tt.wantErr == "" { + require.NoError(t, err) return } - require.NoError(t, err) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + if tt.wantErr == "exceeds maximum safe deadline" { + // The dangerous-value error must point the operator at the override. + require.Contains(t, err.Error(), "AllowDangerousProposalSoftDeadline") + require.Contains(t, err.Error(), "ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE") + } }) } } @@ -287,9 +297,9 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { t.Run("mev-optimized path keeps the operator deadline", func(t *testing.T) { c := config{} - c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond + c.ConsensusClient.ProposalSoftDeadline = 1100 * time.Millisecond require.NoError(t, c.resolveBlockFetch(zap.NewNop())) - require.Equal(t, 1850*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + require.Equal(t, 1100*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) require.Zero(t, c.ConsensusClient.ProposalSoftTimeout) }) @@ -299,10 +309,24 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "out of range") }) - t.Run("mev-optimized above safe-max - warns with ms fields", func(t *testing.T) { + t.Run("mev-optimized above safe-max without flag -> error", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1450ms) + require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "exceeds maximum safe deadline") + }) + + t.Run("mev-optimized above max with flag still -> error", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 5000 * time.Millisecond + c.ConsensusClient.AllowDangerousProposalSoftDeadline = true + require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "out of range") + }) + + t.Run("mev-optimized above safe-max with flag - warns with ms fields", func(t *testing.T) { core, recorded := observer.New(zapcore.WarnLevel) c := config{} c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1450ms), within range + c.ConsensusClient.AllowDangerousProposalSoftDeadline = true require.NoError(t, c.resolveBlockFetch(zap.New(core))) logs := recorded.All() diff --git a/config/config.example.yaml b/config/config.example.yaml index bef5ff07fc..51785f95e5 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -30,12 +30,15 @@ eth2: # - single- and multi-BN: SSV holds the block until this deadline before starting QBFT, so # every operator in the cluster starts QBFT at the same slot-relative time (aligned round # timers, better convergence). - # Match this to your PBS late_in_slot_time_ms + ~50ms BN→SSV transport. Valid range: [1000ms, - # 3600ms]; values above 1450ms emit a startup warning (round-2 QBFT fallback may not fit within - # the slot for typical clusters). Cannot be combined with ProposerDelay or ProposalSoftTimeout. + # Match this to your PBS late_in_slot_time_ms + ~50–100ms BN→SSV transport. Valid range: + # [1000ms, 3600ms]; values above the safe-max of 1450ms are rejected at startup unless + # AllowDangerousProposalSoftDeadline is also set (then allowed up to 3600ms, with a startup WARN): + # for typical clusters the round-2 QBFT fallback may not fit within the slot. Cannot be combined + # with ProposerDelay or ProposalSoftTimeout. # Leave unset to use the default legacy path (relative-timeout collection, early-exit on the # first MEV block; single-BN fetches directly with no slot-relative floor). # ProposalSoftDeadline: 1100ms + # AllowDangerousProposalSoftDeadline: true # only needed when ProposalSoftDeadline exceeds the ~1450ms safe-max # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring # in the legacy block-fetch path. Setting this (or ProposerDelay below) opts into the diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 5c4a1269bb..88a88d64a3 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -85,7 +85,7 @@ The `+ ~50–100ms BN→SSV transport` term is the inbound fetch hop: after the So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). -Valid range `[1000ms, 3600ms]`; values below 1000ms don't make much sense to use as you'd be leaving MEV opportunity on the table that's safe to extract (falling back to a locally built block); values above ~1450ms emit a startup warning — for typical clusters, the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed; values above 3600ms don't make much sense to use as they leave no room for even 1 QBFT round. +Valid range `[1000ms, 3600ms]` — values outside it are rejected at startup. Below ~1000ms leaves safe-to-extract MEV on the table (the node would fall back to a locally built block). Above the safe-max of ~1450ms the node **refuses to start unless `AllowDangerousProposalSoftDeadline: true` is also set** (env `ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE`), mirroring `AllowDangerousProposerDelay`: for typical clusters the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (a startup WARN is logged once the flag is set). Even with the flag, values above 3600ms are rejected — they leave no room for even one QBFT round. ## Configuration examples @@ -179,10 +179,11 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1900ms triggers the safe-max startup warning since it exceeds the ~1450ms threshold; see [SSV-side configuration](#ssv-side-configuration)): +**SSV-side** (opts into MEV-optimized block fetch — 1900ms exceeds the ~1450ms safe-max, so it requires `AllowDangerousProposalSoftDeadline` and logs a startup WARN; see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: - ProposalSoftDeadline: 1900ms # = PBS late_in_slot_time_ms (1800ms) + ~100ms BN→SSV transport + ProposalSoftDeadline: 1900ms # = PBS late_in_slot_time_ms (1800ms) + ~100ms BN→SSV transport + AllowDangerousProposalSoftDeadline: true # required: 1900ms exceeds the ~1450ms safe-max ``` ## Appendix A — Legacy `ProposerDelay` approach From 305a4fdbb39f65371a0854e6a87845991d4333e8 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 15:21:27 +0300 Subject: [PATCH 14/16] cleanup --- beacon/goclient/options.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 1ca0fd7916..7def0097de 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -37,8 +37,7 @@ type Options struct { // deadline (no early-exit), then start QBFT at it. Applies to single- and multi-BN setups // alike, so all operators in the cluster start QBFT at the same slot-relative time. // 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 MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 1450ms]; higher values up to 3600ms require AllowDangerousProposalSoftDeadline). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` - + ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 1450ms]; higher values up to 3600ms require AllowDangerousProposalSoftDeadline). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` // AllowDangerousProposalSoftDeadline lifts the ProposalSoftDeadline safe-max cap (~1450ms) up // to the hard maximum (3600ms). Without it, a ProposalSoftDeadline above the safe-max is // rejected at startup, because the worst-case 2-round QBFT scenario may not fit within the slot From 2e11a253dace1c4ceaff5c825e4932b6a796bd4a Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 8 Jun 2026 15:47:33 +0300 Subject: [PATCH 15/16] more cleanup --- cli/operator/config.go | 17 ++++++++--------- cli/operator/config_test.go | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cli/operator/config.go b/cli/operator/config.go index 13585002bf..1ebc588a9d 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -67,12 +67,12 @@ const ( // ProposalSoftDeadline bounds (slot-relative), used by the MEV-optimized path. // [minProposalSoftDeadline, maxProposalSoftDeadline] is the hard accepted range. - // safeMaxProposalSoftDeadline is the largest value considered safe: above it the worst-case + // maxSafeProposalSoftDeadline is the largest value considered safe: above it the worst-case // 2-round QBFT fallback may not fit within the slot, so the operator must explicitly // acknowledge the risk via AllowDangerousProposalSoftDeadline (mirrors maxSafeProposerDelay / // AllowDangerousProposerDelay). - safeMaxProposalSoftDeadline = 1450 * time.Millisecond minProposalSoftDeadline = 1000 * time.Millisecond + maxSafeProposalSoftDeadline = 1450 * time.Millisecond maxProposalSoftDeadline = 3600 * time.Millisecond // Legacy-path soft-timeout defaulting (1800ms, reduced by ProposerDelay, floored at 500ms). @@ -219,16 +219,15 @@ func (c *config) resolveBlockFetch(logger *zap.Logger) error { zap.Int64("max_safe_proposer_delay_ms", maxSafeProposerDelay.Milliseconds())) } case blockFetchPathMEVOptimized: - if rawSoftDeadline > safeMaxProposalSoftDeadline { + if rawSoftDeadline > maxSafeProposalSoftDeadline { // Reachable only after validateProposalSoftDeadline passed (AllowDangerousProposalSoftDeadline set). logger.Warn( "ProposalSoftDeadline exceeds the safe-max threshold: "+ "round-2 QBFT fallback may not fit within the slot deadline "+ "for clusters with typical latencies, "+ - "so the slot may be missed when round 1 fails "+ - "(this is an explicit 'round 1 must succeed' configuration).", + "so the slot may be missed when round 1 fails", zap.Int64("proposal_soft_deadline_ms", rawSoftDeadline.Milliseconds()), - zap.Int64("safe_max_ms", safeMaxProposalSoftDeadline.Milliseconds())) + zap.Int64("safe_max_proposal_soft_deadline_ms", maxSafeProposalSoftDeadline.Milliseconds())) } } @@ -295,7 +294,7 @@ func determineBlockFetchPath(proposalSoftTimeout, proposalSoftDeadline, proposer } // validateProposalSoftDeadline ensures an operator-set ProposalSoftDeadline (MEV-optimized path) -// is within the hard [min, max] range, and rejects a value above safeMaxProposalSoftDeadline +// is within the hard [min, max] range, and rejects a value above maxSafeProposalSoftDeadline // unless the operator explicitly acknowledges the risk via allowDangerous (mirrors // validateProposerDelay). The safe-max WARN is emitted separately. func validateProposalSoftDeadline(d time.Duration, allowDangerous bool) error { @@ -305,11 +304,11 @@ func validateProposalSoftDeadline(d time.Duration, allowDangerous bool) error { minProposalSoftDeadline.Milliseconds(), maxProposalSoftDeadline.Milliseconds()) } - if d > safeMaxProposalSoftDeadline && !allowDangerous { + if d > maxSafeProposalSoftDeadline && !allowDangerous { return fmt.Errorf("ProposalSoftDeadline value %dms exceeds maximum safe deadline of %dms. "+ "This may cause missed block proposals. "+ "If you understand the risks and want to proceed, set AllowDangerousProposalSoftDeadline to true or use the ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE environment variable", - d.Milliseconds(), safeMaxProposalSoftDeadline.Milliseconds()) + d.Milliseconds(), maxSafeProposalSoftDeadline.Milliseconds()) } return nil } diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index 6f97a830da..fdf15a2239 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -342,7 +342,7 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { t.Run("mev-optimized at safe-max - no warning", func(t *testing.T) { core, recorded := observer.New(zapcore.WarnLevel) c := config{} - c.ConsensusClient.ProposalSoftDeadline = safeMaxProposalSoftDeadline // == safe-max, no warning + c.ConsensusClient.ProposalSoftDeadline = maxSafeProposalSoftDeadline // == safe-max, no warning require.NoError(t, c.resolveBlockFetch(zap.New(core))) require.Len(t, recorded.All(), 0) }) From 2fc48f27452e0fb3b46b4c52720fb20ec3619318 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 13 Jun 2026 12:08:45 +0300 Subject: [PATCH 16/16] mev docs: bump BlockSubmission estimate to 300ms, adjust derived budgets A ~300ms BlockSubmission better reflects reality than the prior ~100ms. The +200ms shift propagates through the slot-budget math: - ProposalSoftDeadline safe-max 1450ms -> 1250ms (maxSafeProposalSoftDeadline) - legacy ProposerDelay theoretical max 1250ms -> 1050ms, headroom 550ms -> 350ms - worst-case 2-round QBFT budget note 2500ms -> 2700ms Mirror the new safe-max across env-descriptions, config.example.yaml, and the boundary tests. Also fix a pre-existing test that asserted the wrong WARN field key (safe_max_ms -> safe_max_proposal_soft_deadline_ms). --- beacon/goclient/goclient_test.go | 2 +- beacon/goclient/options.go | 6 +++--- cli/operator/config.go | 2 +- cli/operator/config_test.go | 10 +++++----- config/config.example.yaml | 4 ++-- docs/MEV_CONSIDERATIONS.md | 14 +++++++------- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index 00dbe4b293..4dc1c0ba0c 100644 --- a/beacon/goclient/goclient_test.go +++ b/beacon/goclient/goclient_test.go @@ -176,7 +176,7 @@ func runHealthyTest( SyncDistanceTolerance: syncDistanceTolerance, // This multi-BN client uses the MEV-optimized (slot-relative) path; the positive deadline // both selects it and satisfies New's block-fetch precondition (unused by this sync test). - ProposalSoftDeadline: 1450 * time.Millisecond, + ProposalSoftDeadline: 1250 * time.Millisecond, }) require.NoError(t, err) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index 7def0097de..27116a7f13 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -37,11 +37,11 @@ type Options struct { // deadline (no early-exit), then start QBFT at it. Applies to single- and multi-BN setups // alike, so all operators in the cluster start QBFT at the same slot-relative time. // Cannot be combined with ProposerDelay or ProposalSoftTimeout (which select the legacy path). - ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 1450ms]; higher values up to 3600ms require AllowDangerousProposalSoftDeadline). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` - // AllowDangerousProposalSoftDeadline lifts the ProposalSoftDeadline safe-max cap (~1450ms) up + ProposalSoftDeadline time.Duration `yaml:"ProposalSoftDeadline" env:"PROPOSAL_SOFT_DEADLINE" env-description:"Slot-relative deadline (ms into slot) for the MEV-optimized proposal-collection window. Leave unset for the default (legacy relative-timeout) path; set explicitly to opt into the MEV-optimized path (value must be in [1000ms, 1250ms]; higher values up to 3600ms require AllowDangerousProposalSoftDeadline). Cannot be combined with ProposerDelay or ProposalSoftTimeout. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + // AllowDangerousProposalSoftDeadline lifts the ProposalSoftDeadline safe-max cap (~1250ms) up // to the hard maximum (3600ms). Without it, a ProposalSoftDeadline above the safe-max is // rejected at startup, because the worst-case 2-round QBFT scenario may not fit within the slot // (an explicit "round 1 must succeed" configuration). Mirrors AllowDangerousProposerDelay. // See docs/MEV_CONSIDERATIONS.md. - AllowDangerousProposalSoftDeadline bool `yaml:"AllowDangerousProposalSoftDeadline" env:"ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE" env-description:"Allow ProposalSoftDeadline values above the safe-max (~1450ms) up to the hard maximum (3600ms). Dangerous: the worst-case 2-round QBFT fallback may not fit within the slot, risking missed proposals. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` + AllowDangerousProposalSoftDeadline bool `yaml:"AllowDangerousProposalSoftDeadline" env:"ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE" env-description:"Allow ProposalSoftDeadline values above the safe-max (~1250ms) up to the hard maximum (3600ms). Dangerous: the worst-case 2-round QBFT fallback may not fit within the slot, risking missed proposals. See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md for details."` } diff --git a/cli/operator/config.go b/cli/operator/config.go index 1ebc588a9d..fbf6cc01a4 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -72,7 +72,7 @@ const ( // acknowledge the risk via AllowDangerousProposalSoftDeadline (mirrors maxSafeProposerDelay / // AllowDangerousProposerDelay). minProposalSoftDeadline = 1000 * time.Millisecond - maxSafeProposalSoftDeadline = 1450 * time.Millisecond + maxSafeProposalSoftDeadline = 1250 * time.Millisecond maxProposalSoftDeadline = 3600 * time.Millisecond // Legacy-path soft-timeout defaulting (1800ms, reduced by ProposerDelay, floored at 500ms). diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index fdf15a2239..9134f2ca90 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -244,8 +244,8 @@ func TestValidateProposalSoftDeadline(t *testing.T) { {name: "at min 1000ms -> ok", value: 1000 * time.Millisecond}, {name: "below min 999ms -> error", value: 999 * time.Millisecond, wantErr: "out of range"}, {name: "zero -> error", value: 0, wantErr: "out of range"}, - {name: "at safe-max 1450ms -> ok", value: 1450 * time.Millisecond}, - {name: "above safe-max 1451ms without flag -> error", value: 1451 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, + {name: "at safe-max 1250ms -> ok", value: 1250 * time.Millisecond}, + {name: "above safe-max 1251ms without flag -> error", value: 1251 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, {name: "above safe-max 2500ms without flag -> error", value: 2500 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, {name: "at max 3600ms without flag -> error", value: 3600 * time.Millisecond, wantErr: "exceeds maximum safe deadline"}, {name: "above safe-max 2500ms with flag -> ok", value: 2500 * time.Millisecond, allowDangerous: true}, @@ -311,7 +311,7 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { t.Run("mev-optimized above safe-max without flag -> error", func(t *testing.T) { c := config{} - c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1450ms) + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1250ms) require.ErrorContains(t, c.resolveBlockFetch(zap.NewNop()), "exceeds maximum safe deadline") }) @@ -325,7 +325,7 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { t.Run("mev-optimized above safe-max with flag - warns with ms fields", func(t *testing.T) { core, recorded := observer.New(zapcore.WarnLevel) c := config{} - c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1450ms), within range + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1250ms), within range c.ConsensusClient.AllowDangerousProposalSoftDeadline = true require.NoError(t, c.resolveBlockFetch(zap.New(core))) @@ -336,7 +336,7 @@ func Test_resolveBlockFetch_defaults(t *testing.T) { fields := logs[0].ContextMap() require.Equal(t, int64(1850), fields["proposal_soft_deadline_ms"]) - require.Equal(t, int64(1450), fields["safe_max_ms"]) + require.Equal(t, int64(1250), fields["safe_max_proposal_soft_deadline_ms"]) }) t.Run("mev-optimized at safe-max - no warning", func(t *testing.T) { diff --git a/config/config.example.yaml b/config/config.example.yaml index 51785f95e5..400a80b366 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -31,14 +31,14 @@ eth2: # every operator in the cluster starts QBFT at the same slot-relative time (aligned round # timers, better convergence). # Match this to your PBS late_in_slot_time_ms + ~50–100ms BN→SSV transport. Valid range: - # [1000ms, 3600ms]; values above the safe-max of 1450ms are rejected at startup unless + # [1000ms, 3600ms]; values above the safe-max of 1250ms are rejected at startup unless # AllowDangerousProposalSoftDeadline is also set (then allowed up to 3600ms, with a startup WARN): # for typical clusters the round-2 QBFT fallback may not fit within the slot. Cannot be combined # with ProposerDelay or ProposalSoftTimeout. # Leave unset to use the default legacy path (relative-timeout collection, early-exit on the # first MEV block; single-BN fetches directly with no slot-relative floor). # ProposalSoftDeadline: 1100ms - # AllowDangerousProposalSoftDeadline: true # only needed when ProposalSoftDeadline exceeds the ~1450ms safe-max + # AllowDangerousProposalSoftDeadline: true # only needed when ProposalSoftDeadline exceeds the ~1250ms safe-max # ProposalSoftTimeout (legacy): collection-period timeout for multi-BN proposal scoring # in the legacy block-fetch path. Setting this (or ProposerDelay below) opts into the diff --git a/docs/MEV_CONSIDERATIONS.md b/docs/MEV_CONSIDERATIONS.md index 88a88d64a3..a64dda29f4 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -24,7 +24,7 @@ The variables below name the stages of the SSV proposer-duty timeline. The value | `MEVBoostRelayTimeout` | ~200ms | *Legacy path only:* mev-boost's single getHeader call when SSV asks for a block. Replaced by `(auction window)` in PBS-timing-games setups. | | `QBFT` | ~2350ms worst case | QBFT consensus over the blinded block. Worst-case decomposes into `QBFTRound1Time` (~2000ms round-1 timer, fires if round 1 fails) + `QBFTRoundChange` (~100ms ROUND-CHANGE handshake) + `QBFTRound2Time` (~250ms successful round 2). | | `PostConsensusSigning` | ~50ms | Operators reconstruct the validator BLS signature from partial signatures. | -| `BlockSubmission` | ~100ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | +| `BlockSubmission` | ~300ms | Leader submits the signed blinded block to the BN; relay reveals the payload; block propagates. | The Ethereum slot-propagation deadline is **4000ms** after slot start. @@ -85,7 +85,7 @@ The `+ ~50–100ms BN→SSV transport` term is the inbound fetch hop: after the So the deadline effectively *defines the QBFT instance start time*: QBFT starts at `max(slot_start + ProposalSoftDeadline, block_arrival)`. In the common case every operator has a block by the deadline and they start together; an operator whose BN only responds after the deadline starts as soon as its block arrives (it cannot start earlier — the block is the consensus input). -Valid range `[1000ms, 3600ms]` — values outside it are rejected at startup. Below ~1000ms leaves safe-to-extract MEV on the table (the node would fall back to a locally built block). Above the safe-max of ~1450ms the node **refuses to start unless `AllowDangerousProposalSoftDeadline: true` is also set** (env `ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE`), mirroring `AllowDangerousProposerDelay`: for typical clusters the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (a startup WARN is logged once the flag is set). Even with the flag, values above 3600ms are rejected — they leave no room for even one QBFT round. +Valid range `[1000ms, 3600ms]` — values outside it are rejected at startup. Below ~1000ms leaves safe-to-extract MEV on the table (the node would fall back to a locally built block). Above the safe-max of ~1250ms the node **refuses to start unless `AllowDangerousProposalSoftDeadline: true` is also set** (env `ALLOW_DANGEROUS_PROPOSAL_SOFT_DEADLINE`), mirroring `AllowDangerousProposerDelay`: for typical clusters the worst-case 2-round QBFT scenario may no longer fit within the slot, so round 1 effectively has to succeed (a startup WARN is logged once the flag is set). Even with the flag, values above 3600ms are rejected — they leave no room for even one QBFT round. ## Configuration examples @@ -139,11 +139,11 @@ eth2: ### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -Pushes the PBS-side cutoff to `1800ms` — past the ~1450ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1900ms. +Pushes the PBS-side cutoff to `1800ms` — past the ~1250ms threshold where round-2 QBFT fallback may no longer fit within the slot for typical clusters. This accepts "round 1 must succeed" in exchange for capturing more intra-slot bid growth (clusters with measurably faster QBFT + submission may still leave room for round 2). Last relay poll at ~1600ms; header at SSV by ~1900ms. The polling pattern (`target_first_request_ms = 1000`, `frequency_get_header_ms = 200`) fires polls at 1000ms, 1200ms, 1400ms, 1600ms — four chances with ~200ms RTT margin. -Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2850ms to ~2100ms — below the ~2500ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. +Trade-off vs Example A: bid-sample time shifts ~600ms later, capturing more intra-slot bid growth, but the remaining slot budget for QBFT and submission shrinks from ~2850ms to ~2100ms — below the ~2700ms typically needed for the worst-case 2-round QBFT scenario. Example B accepts that round 1 must succeed; if round 1 fails, the slot may be missed (whether it's actually missed depends on your cluster's QBFT + submission latencies). Use only after baselining your stack's round-1 success rate. **commit-boost** (TOML): ```toml @@ -179,11 +179,11 @@ relays: frequency_get_header_ms: 200 ``` -**SSV-side** (opts into MEV-optimized block fetch — 1900ms exceeds the ~1450ms safe-max, so it requires `AllowDangerousProposalSoftDeadline` and logs a startup WARN; see [SSV-side configuration](#ssv-side-configuration)): +**SSV-side** (opts into MEV-optimized block fetch — 1900ms exceeds the ~1250ms safe-max, so it requires `AllowDangerousProposalSoftDeadline` and logs a startup WARN; see [SSV-side configuration](#ssv-side-configuration)): ```yaml eth2: ProposalSoftDeadline: 1900ms # = PBS late_in_slot_time_ms (1800ms) + ~100ms BN→SSV transport - AllowDangerousProposalSoftDeadline: true # required: 1900ms exceeds the ~1450ms safe-max + AllowDangerousProposalSoftDeadline: true # required: 1900ms exceeds the ~1250ms safe-max ``` ## Appendix A — Legacy `ProposerDelay` approach @@ -208,7 +208,7 @@ With `ProposerDelay` active, the slot-budget equation becomes: RANDAO + ProposerDelay + MEVBoostRelayTimeout + QBFT + PostConsensusSigning + BlockSubmission < 4000ms ``` -Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (50 + 200 + 2350 + 50 + 100) = 1250ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~550ms of headroom for variance. +Using the typical values from [Definitions](#definitions-and-typical-values), `ProposerDelay ≤ 4000ms − (50 + 200 + 2350 + 50 + 300) = 1050ms` is the theoretical maximum. In practice, latency variance can easily add several hundred ms — we consider **~700ms** the maximum reasonable value for `ProposerDelay` on Ethereum mainnet, leaving ~350ms of headroom for variance. We recommend starting with a small value such as 300ms and increasing gradually while monitoring miss rate.