Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions beacon/goclient/attest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 3 additions & 4 deletions beacon/goclient/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions beacon/goclient/goclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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{}),
Expand All @@ -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{}](),
}
Expand Down
3 changes: 3 additions & 0 deletions beacon/goclient/goclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
62 changes: 19 additions & 43 deletions beacon/goclient/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
}
Loading