Skip to content
23 changes: 19 additions & 4 deletions operator/duties/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ type SchedulerOptions struct {
// executing duties (e.g., validator registration). When true, scheduler
// still fetches/stores duties for all validators but does not execute them.
ExporterMode bool
// ArchiveMode indicates this is an archive-mode exporter that needs the Attester handler
// for duty tracing. Only meaningful when ExporterMode is true; standard-mode exporters skip
// the Attester handler to avoid fetching duties for all network validators.
ArchiveMode bool
}

type Scheduler struct {
Expand Down Expand Up @@ -165,19 +169,30 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler {

s.exporterMode = opts.ExporterMode

// These handlers fetch & record duties from the beacon node and are needed in both operator & exporter modes.
// When adding a new handler here, ensure it supports both modes.
// Proposer, SyncCommittee, and VoluntaryExit are needed in all modes.
// Proposer and SyncCommittee populate dutyStore entries consulted by message validation on all
// node types. VoluntaryExit populates the per-(slot,pk) duty count also consulted by validation;
// in exporter mode all exit descriptors have OwnValidator=false so no exit is ever executed.
s.dutyHandlers = append(s.dutyHandlers,
NewAttesterHandler(dutyStore.Attester, opts.ExporterMode),
NewProposerHandler(dutyStore.Proposer, opts.ExporterMode),
NewSyncCommitteeHandler(dutyStore.SyncCommittee, opts.ExporterMode),
NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh),

@momosh-ssv momosh-ssv Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth adding a symmetric guard for VoluntaryExit.

Proposer and SyncCommittee both short-circuit with if h.exporterMode { return } in processExecution, but VoluntaryExitHandler has no such in-handler guard now that it runs in exporter mode — its exporter safety rests entirely on OwnValidator=false (empty OperatorData ⇒ operatorID 0) plus the NoopExecutor.

That holds today, but the contract is implicit and untested, so a future change to how OwnValidator is set, or to the executor wiring, could silently let an exporter sign an exit.

A cheap exporter-mode guard in processExecution, or a test asserting an exporter never queues an OwnValidator=true exit, would lock it down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, added it. now has an field and guards before calling .

)

// Attester is needed for duty execution (operator) and duty tracing (archive exporter).
// Standard-mode exporter skips it: the attester store is not checked by message validation,
// and fetching duties for all network validators is expensive without the tracing benefit.
if !opts.ExporterMode || opts.ArchiveMode {
s.dutyHandlers = append(s.dutyHandlers,
NewAttesterHandler(dutyStore.Attester, opts.ExporterMode),
)
}

// These handlers only execute duties and are not needed in exporter mode.
if !opts.ExporterMode {
s.dutyHandlers = append(s.dutyHandlers,
NewCommitteeHandler(dutyStore.Attester, dutyStore.SyncCommittee),
NewValidatorRegistrationHandler(opts.ValidatorRegistrationCh),
NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh),
)
}
return s
Expand Down
58 changes: 58 additions & 0 deletions operator/duties/scheduler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,61 @@ func TestScheduler_HandleHeadEvent_DoesNotBlockWithoutReorgConsumer(t *testing.T
PreviousDutyDependentRootChanged: true,
}}, drainReorgEvents(s.reorgCh))
}

func TestNewScheduler_HandlerRegistration(t *testing.T) {

@momosh-ssv momosh-ssv Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about extending this to cover the safety invariant, not just registration?

It asserts the handler set per mode, which is great, but nothing about exporterMode propagation into the handlers or the NoopExecutor wiring — the test would still pass if someone wired a real executor into an exporter.

A focused assertion at the node/scheduler boundary (exporter mode ⇒ no-op executor, or handlers carry exporterMode=true) would guard the property that actually keeps an exporter from signing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended the test to assert exporterMode propagates into each handler type and that Scheduler.exporterMode is set correctly.

t.Parallel()

tests := []struct {
name string
exporterMode bool
archiveMode bool
wantHandlers []string
}{
{
name: "operator mode",
exporterMode: false,
archiveMode: false,
wantHandlers: []string{"PROPOSER", "SYNC_COMMITTEE", "VOLUNTARY_EXIT", "ATTESTER", "CLUSTER", "VALIDATOR_REGISTRATION"},
},
{
name: "standard exporter",
exporterMode: true,
archiveMode: false,
// VoluntaryExit is included so the dutyStore is populated for message validation;
// Attester is excluded because the standard exporter does not trace duties.
wantHandlers: []string{"PROPOSER", "SYNC_COMMITTEE", "VOLUNTARY_EXIT"},
},
{
name: "archive exporter",
exporterMode: true,
archiveMode: true,
// Archive exporter needs Attester for full duty tracing but still skips
// execution-only handlers (CLUSTER, VALIDATOR_REGISTRATION).
wantHandlers: []string{"PROPOSER", "SYNC_COMMITTEE", "VOLUNTARY_EXIT", "ATTESTER"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

s := NewScheduler(zap.NewNop(), &SchedulerOptions{
ExporterMode: tc.exporterMode,
ArchiveMode: tc.archiveMode,
SlotTickerProvider: func() slotticker.SlotTicker {
return NewMockSlotTicker(ctx)
},
})

got := make([]string, 0, len(s.dutyHandlers))
for _, h := range s.dutyHandlers {
got = append(got, h.Name())
}

require.ElementsMatch(t, tc.wantHandlers, got)
})
}
}
11 changes: 5 additions & 6 deletions operator/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ type Node struct {
exporterRead *exporter2.Exporter
}

func shouldRunDutyScheduler(exporterOpts exporter.Options) bool {
return !exporterOpts.Enabled || exporterOpts.Mode == exporter.ModeArchive
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 shouldRunDutyScheduler is now vestigial — it always returns true and ignores its parameter. The surrounding if shouldRunDutyScheduler(exporterOpts) guard therefore adds no logic; the block always executes. The function name implies conditional behaviour that no longer exists, which can mislead future readers. Consider either inlining the block unconditionally or, if the guard is intentionally kept as an extension point, making that explicit with a comment.

Suggested change
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}
// shouldRunDutyScheduler is retained as an extension point.
// All node modes now run the duty scheduler; exporters use AllShares,
// PrefetchingBeacon, and NoopExecutor so no duty is ever executed.
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


// New is the constructor of Node
Expand All @@ -105,8 +105,8 @@ func New(logger *zap.Logger, opts Options, exporterOpts exporter.Options, slotTi

var dutyScheduler *duties.Scheduler
if shouldRunDutyScheduler(exporterOpts) {
// Prepare scheduler wiring; in exporter archive mode we swap to AllShares provider,
// a prefetching beacon adapter, and a no-op executor.
// Prepare scheduler wiring; in exporter mode (both standard and archive) swap to AllShares
// provider, a prefetching beacon adapter, and a no-op executor.
schedulerBeacon := duties.BeaconNode(opts.BeaconNode)
validatorProvider := duties.ValidatorProvider(selfValidatorStore)
dutyExecutor := duties.DutyExecutor(opts.ValidatorController)
Expand All @@ -132,6 +132,7 @@ func New(logger *zap.Logger, opts Options, exporterOpts exporter.Options, slotTi
SlotTickerProvider: slotTickerProvider,
P2PNetwork: opts.P2PNetwork,
ExporterMode: exporterOpts.Enabled,
ArchiveMode: exporterOpts.Mode == exporter.ModeArchive,
})
}

Expand Down Expand Up @@ -179,8 +180,6 @@ func (n *Node) Start(ctx context.Context) error {
if err := n.dutyScheduler.Start(ctx); err != nil {
return fmt.Errorf("failed to run duty scheduler: %w", err)
}
} else {
n.logger.Info("exporter standard mode: skipping duty scheduler")
}

n.validatorsCtrl.StartNetworkHandlers()
Expand Down
2 changes: 1 addition & 1 deletion operator/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestShouldRunDutyScheduler(t *testing.T) {
Enabled: true,
Mode: exporter.ModeStandard,
},
expected: false,
expected: true,
},
{
name: "exporter archive",
Expand Down