diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index d230c13d75..133a49f53d 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -506,18 +506,16 @@ 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, + // 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, + }) } type beaconServerResponseOptions struct { diff --git a/beacon/goclient/events_test.go b/beacon/goclient/events_test.go index 05b3aa6b9c..56c6249444 100644 --- a/beacon/goclient/events_test.go +++ b/beacon/goclient/events_test.go @@ -251,10 +251,9 @@ 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, + }) require.NoError(t, err) return server diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index de823542c1..f574482178 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -135,12 +135,18 @@ 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 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 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 + // 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,8 +194,27 @@ 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) 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, ";") + // 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{ log: logger.Named(log.NameConsensusClient), beaconConfigInit: make(chan struct{}), @@ -201,6 +226,7 @@ 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, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), } diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index 700e309ee1..4dc1c0ba0c 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 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: 1250 * time.Millisecond, }) require.NoError(t, err) diff --git a/beacon/goclient/options.go b/beacon/goclient/options.go index fae1208e58..27116a7f13 100644 --- a/beacon/goclient/options.go +++ b/beacon/goclient/options.go @@ -25,47 +25,23 @@ 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 - } - - // 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. - - return options, nil + // ProposalSoftTimeout is the legacy collection-period timeout in multi-BN parallel + // 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 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:"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 (~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/beacon/goclient/proposer.go b/beacon/goclient/proposer.go index a072c476f0..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,15 +106,30 @@ 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 for the fastest response - beaconBlock, err = gc.getProposalParallel(ctx, logger, slot, sig, graffiti) + // 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) + } if err != nil { return nil, nil, err } @@ -155,25 +177,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 +330,199 @@ 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 +// 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. +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 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, + 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) +} + +// 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 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, 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. 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, +) (*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 { + 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 + } + + 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 + } + + // 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 + } + } + + 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..fba4f1e61e --- /dev/null +++ b/beacon/goclient/proposer_path_dispatch_test.go @@ -0,0 +1,418 @@ +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 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 timing field only; transport fields are filled in below + wantSlotRelative bool + }{ + { + name: "mev-optimized (ProposalSoftDeadline set)", + opts: Options{ProposalSoftDeadline: 1100 * time.Millisecond}, + wantSlotRelative: true, + }, + { + name: "legacy (ProposalSoftTimeout set)", + opts: Options{ProposalSoftTimeout: 1800 * time.Millisecond}, + wantSlotRelative: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, _ := createProposalBeaconServer(t, beaconProposalServerOptions{}) + defer server.Close() + + 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, 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_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, + FeeRecipient: feeRecipientAllOnes(), + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + ProposalResponseDuration: 50 * time.Millisecond, + BlindedProposal: true, + FeeRecipient: feeRecipientAllTwos(), + }) + defer bn2.Close() + + client := setupMultiBNClient(t, bn1.URL, bn2.URL) + 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) + + // 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_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, + FeeRecipient: feeRecipientAllOnes(), + ExecutionValue: big.NewInt(1_000_000), // low bid + }) + defer bn1.Close() + bn2, _ := createProposalBeaconServer(t, beaconProposalServerOptions{ + 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) + slot := armProposalDeadline(t, client, 500*time.Millisecond) + + 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_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, + 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) + + // 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() + 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) + + // 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") + 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_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. +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 */) + 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 */) + 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. 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, + 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 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, + 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) +} + +// 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 +} + +// 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 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: 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 vs the legacy direct fetch. +func setupSingleBNClient(t *testing.T, bnURL string, slotRelative bool) *GoClient { + t.Helper() + + opts := Options{ + BeaconNodeAddr: bnURL, + CommonTimeout: time.Second * 2, + LongTimeout: time.Second * 5, + } + if slotRelative { + opts.ProposalSoftDeadline = pathSelectingSoftDeadline // positive value 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 MEV-optimized slot-relative-deadline path). +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, // selects the legacy path (no ProposalSoftDeadline set) + }) + 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..a8127cbb1f 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,23 @@ 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 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, - 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 +438,12 @@ func TestGetProposalParallel_MultiClient(t *testing.T) { graffiti := []byte(testGraffiti) randao := getTestRANDAO() + // 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() - 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 +703,10 @@ 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) + // 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, + }) } diff --git a/cli/operator/config.go b/cli/operator/config.go index b82c16b026..fbf6cc01a4 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"` @@ -57,10 +57,28 @@ 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), used by the MEV-optimized path. + // [minProposalSoftDeadline, maxProposalSoftDeadline] is the hard accepted range. + // 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). + minProposalSoftDeadline = 1000 * time.Millisecond + maxSafeProposalSoftDeadline = 1250 * 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 +123,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 +143,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 +159,168 @@ 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. +// +// 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. + 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 blockFetchPathLegacy: + if err := validateProposerDelay(proposerDelay, c.AllowDangerousProposerDelay); err != nil { + return err + } + // Default the legacy soft timeout: 1800ms reduced by ProposerDelay, floored at 500ms. + softTimeout := rawSoftTimeout + if softTimeout == 0 { + softTimeout = defaultProposalSoftTimeout + if proposerDelay > 0 { + softTimeout -= proposerDelay + } + } + if softTimeout < minProposalSoftTimeout { + softTimeout = minProposalSoftTimeout + } + // goclient keys the legacy (relative-timeout) path off ProposalSoftTimeout > 0. + c.ConsensusClient.ProposalSoftTimeout = softTimeout + 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, c.ConsensusClient.AllowDangerousProposalSoftDeadline); err != nil { + return err + } + } + + 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 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 blockFetchPathMEVOptimized: + 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", + zap.Int64("proposal_soft_deadline_ms", rawSoftDeadline.Milliseconds()), + zap.Int64("safe_max_proposal_soft_deadline_ms", maxSafeProposalSoftDeadline.Milliseconds())) + } + } + + 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 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 ( + // 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 +) + +// String returns a human-readable label for logging. +func (p blockFetchPath) String() string { + switch p { + 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: +// 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) + } + if proposalSoftTimeout < 0 { + return 0, fmt.Errorf("ProposalSoftTimeout must be non-negative, got %v", proposalSoftTimeout) + } + if proposalSoftDeadline < 0 { + return 0, fmt.Errorf("ProposalSoftDeadline must be non-negative, got %v", proposalSoftDeadline) + } + + 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") + } + + if deadlineSet { + return blockFetchPathMEVOptimized, 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) +// 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 { + 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 > 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(), maxSafeProposalSoftDeadline.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..9134f2ca90 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -42,7 +42,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 +61,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 +186,168 @@ func Test_validateProposerDelay(t *testing.T) { } } +func TestDetermineBlockFetchPath(t *testing.T) { + tests := []struct { + name string + proposalSoftTimeout time.Duration + proposalSoftDeadline time.Duration + proposerDelay time.Duration + want blockFetchPath + wantErr string + }{ + {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}, + {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"}, + {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.proposalSoftTimeout, tt.proposalSoftDeadline, 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 Test_blockFetchPath_String(t *testing.T) { + tests := []struct { + path blockFetchPath + want string + }{ + {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 + value time.Duration + allowDangerous bool + wantErr string // "" = no error + }{ + {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 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}, + {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, tt.allowDangerous) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + 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") + } + }) + } +} + +func Test_resolveBlockFetch_defaults(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.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.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) { + 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 the operator deadline", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 1100 * time.Millisecond + require.NoError(t, c.resolveBlockFetch(zap.NewNop())) + require.Equal(t, 1100*time.Millisecond, c.ConsensusClient.ProposalSoftDeadline) + require.Zero(t, c.ConsensusClient.ProposalSoftTimeout) + }) + + 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") + }) + + t.Run("mev-optimized above safe-max without flag -> error", func(t *testing.T) { + c := config{} + c.ConsensusClient.ProposalSoftDeadline = 1850 * time.Millisecond // > safe-max (1250ms) + 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 (1250ms), within range + c.ConsensusClient.AllowDangerousProposalSoftDeadline = true + 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(1250), fields["safe_max_proposal_soft_deadline_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 = maxSafeProposalSoftDeadline // == safe-max, no warning + require.NoError(t, c.resolveBlockFetch(zap.New(core))) + require.Len(t, recorded.All(), 0) + }) +} + 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..29d2e6ce5a 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 (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..400a80b366 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,6 +19,32 @@ eth2: # HTTP URL of the Beacon node to connect to. BeaconNodeAddr: http://example.url:5052 + # 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. 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 + ~50–100ms BN→SSV transport. Valid range: + # [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 ~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 + # legacy path. New operators should leave this unset and use ProposalSoftDeadline above. + # ProposalSoftTimeout: 1800ms + ValidatorOptions: eth1: @@ -37,13 +63,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..a64dda29f4 100644 --- a/docs/MEV_CONSIDERATIONS.md +++ b/docs/MEV_CONSIDERATIONS.md @@ -1,112 +1,229 @@ -## 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 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 + ~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. + +## 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. + +| 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-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. | +| `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. + +## 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 ``` -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. +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. -### Important Safety Limitation +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. -**The SSV node will refuse to start if ProposerDelay is set higher than 1s without explicit confirmation.** +## PBS-side configuration -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: +Both mev-boost and commit-boost expose the same five knobs: -```yaml -ProposerDelay: 2000ms -AllowDangerousProposerDelay: true +- `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. + +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: +``` +min(ms_into_slot + timeout_get_header_ms, late_in_slot_time_ms) ``` +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. -Or using environment variables: -```bash -PROPOSER_DELAY=2000ms ALLOW_DANGEROUS_PROPOSER_DELAY=true ./bin/ssvnode start-node +### 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/) + +## SSV-side configuration + +Setting `ProposalSoftDeadline` opts into the **MEV-optimized** block-fetch path: + +```yaml +eth2: + ProposalSoftDeadline: ``` -**Warning:** Using ProposerDelay values higher than 1s significantly increases the risk of missing block proposals, -which can result in penalties and lost rewards. +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: + +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 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 + +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) -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. +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. -## MEV considerations & SSV proposer-duty flow background +The polling pattern (`target_first_request_ms = 700`, `frequency_get_header_ms = 150`) fires polls at 700ms, 850ms, and 1000ms. -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`) +**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 -and so this means for the best SSV cluster operations we want the following condition to always hold true: +[[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 +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 ``` -RANDAOTime + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s + +**SSV-side** (opts into MEV-optimized block fetch — see [SSV-side configuration](#ssv-side-configuration)): +```yaml +eth2: + ProposalSoftDeadline: 1150ms # = PBS late_in_slot_time_ms (1050ms) + ~100ms BN→SSV transport ``` -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): +### Example B — aggressive: PBS-side cutoff at 1800ms (round 1 must succeed) -### approach 1 +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. -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 +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. -^ 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) +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. -thus an alternative approach would be: +**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 -### approach 2 +[[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 -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: +[[relays]] +url = "https://@relay-2.example" +enable_timing_games = true +target_first_request_ms = 1000 +frequency_get_header_ms = 200 ``` -RANDAOTime + ProposerDelay + MEVBoostRelayTimeout + QBFTTime + BlockSubmissionTime + MiscellaneousTime < 4s + +**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 ``` -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 + +**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 ~1250ms safe-max ``` -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 + +## 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. +- 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 ``` -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. +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 + 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. + +**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 +``` + +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 }