diff --git a/app/app.go b/app/app.go index 4def8b8de..4794142a6 100644 --- a/app/app.go +++ b/app/app.go @@ -34,6 +34,7 @@ import ( "github.com/obolnetwork/charon/app/k1util" "github.com/obolnetwork/charon/app/lifecycle" "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/peerinfo" "github.com/obolnetwork/charon/app/privkeylock" "github.com/obolnetwork/charon/app/promauto" @@ -109,6 +110,8 @@ type Config struct { VCTLSCertFile string VCTLSKeyFile string BuilderRegOverridesFilePath string + PublishAddress string + PublishTimeout time.Duration TestConfig TestConfig } @@ -463,12 +466,25 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, builderRegistrations = append(builderRegistrations, builderRegistration) } + var obolClient *obolapi.Client + + if conf.PublishAddress != "" { + cl, err := obolapi.New(conf.PublishAddress, obolapi.WithTimeout(conf.PublishTimeout)) + if err != nil { + return err + } + + obolClient = &cl + } + builderRegSvc, err := NewBuilderRegistrationService( ctx, conf.BuilderRegOverridesFilePath, eth2p0.Version(lock.ForkVersion), builderRegistrations, feeRecipientAddrByCorePubkey, + obolClient, + lock.LockHash, ) if err != nil { return err diff --git a/app/builderregistration.go b/app/builderregistration.go index 21239cf81..e36fa283a 100644 --- a/app/builderregistration.go +++ b/app/builderregistration.go @@ -11,13 +11,17 @@ import ( "path/filepath" "strings" "sync" + "time" eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2spec "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/fsnotify/fsnotify" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/eth2util/registration" @@ -25,6 +29,13 @@ import ( "github.com/obolnetwork/charon/tbls/tblsconv" ) +const ( + // fetchIntervalIncomplete is the fetch interval when some validators have incomplete registrations. + fetchIntervalIncomplete = 1 * time.Hour + // fetchIntervalComplete is the fetch interval when all registrations are fully signed. + fetchIntervalComplete = 24 * time.Hour +) + // BuilderRegistrationService provides thread-safe access to current builder // registrations and fee recipient addresses with runtime override support. type BuilderRegistrationService interface { @@ -32,11 +43,107 @@ type BuilderRegistrationService interface { Registrations() []*eth2api.VersionedSignedValidatorRegistration // FeeRecipient returns the current fee recipient address for the given pubkey. FeeRecipient(pubkey core.PubKey) string - // Run watches the overrides file for changes and reloads when modified. - // It blocks until ctx is cancelled. + // Run watches the overrides file for changes, periodically fetches from the + // Obol API, and reloads when either source is updated. It blocks until ctx is cancelled. Run(ctx context.Context) } +// ValidatorCategories holds categorized validator public keys by registration status. +type ValidatorCategories struct { + Complete []string + Incomplete []string + NoReg []string +} + +// ProcessedValidators holds the results of processing the API response. +type ProcessedValidators struct { + AggregatedRegs []*eth2api.VersionedSignedValidatorRegistration + Categories ValidatorCategories + // PartialSigIndices maps validator pubkey to share indices that submitted. + PartialSigIndices map[string][]int + // QuorumMessages maps validator pubkey to the quorum registration message details. + QuorumMessages map[string]*eth2v1.ValidatorRegistration + // IncompleteMessages maps validator pubkey to the incomplete registration message + // with the most partial signatures. + IncompleteMessages map[string]*eth2v1.ValidatorRegistration +} + +// AggregatePartialSignatures converts partial signatures into a full aggregated signature. +func AggregatePartialSignatures(partialSigs []obolapi.FeeRecipientPartialSig, pubkey string) (eth2p0.BLSSignature, error) { + sigsMap := make(map[int]tbls.Signature) + + for _, ps := range partialSigs { + sigsMap[ps.ShareIndex] = ps.Signature + } + + fullSig, err := tbls.ThresholdAggregate(sigsMap) + if err != nil { + return eth2p0.BLSSignature{}, errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", pubkey)) + } + + return eth2p0.BLSSignature(fullSig), nil +} + +// ProcessValidators aggregates signatures for validators with quorum and categorizes all validators by status. +func ProcessValidators(validators []obolapi.FeeRecipientValidator) (ProcessedValidators, error) { + result := ProcessedValidators{ + PartialSigIndices: make(map[string][]int), + QuorumMessages: make(map[string]*eth2v1.ValidatorRegistration), + IncompleteMessages: make(map[string]*eth2v1.ValidatorRegistration), + } + + for _, val := range validators { + var hasQuorum, hasIncomplete bool + + for _, reg := range val.BuilderRegistrations { + if reg.Quorum { + hasQuorum = true + + fullSig, err := AggregatePartialSignatures(reg.PartialSignatures, val.Pubkey) + if err != nil { + return ProcessedValidators{}, err + } + + result.AggregatedRegs = append(result.AggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: reg.Message, + Signature: fullSig, + }, + }) + + result.QuorumMessages[val.Pubkey] = reg.Message + } else { + hasIncomplete = true + + if len(reg.PartialSignatures) > len(result.PartialSigIndices[val.Pubkey]) { + indices := make([]int, 0, len(reg.PartialSignatures)) + for _, ps := range reg.PartialSignatures { + indices = append(indices, ps.ShareIndex) + } + + result.PartialSigIndices[val.Pubkey] = indices + result.IncompleteMessages[val.Pubkey] = reg.Message + } + } + } + + if hasQuorum { + result.Categories.Complete = append(result.Categories.Complete, val.Pubkey) + } + + if hasIncomplete { + result.Categories.Incomplete = append(result.Categories.Incomplete, val.Pubkey) + } + + if !hasQuorum && !hasIncomplete { + result.Categories.NoReg = append(result.Categories.NoReg, val.Pubkey) + } + } + + return result, nil +} + // builderRegistrationService implements BuilderRegistrationService. type builderRegistrationService struct { mu sync.RWMutex @@ -46,27 +153,42 @@ type builderRegistrationService struct { baseFeeRecipients map[core.PubKey]string registrations []*eth2api.VersionedSignedValidatorRegistration feeRecipients map[core.PubKey]string + + // Fields for background API fetching. + obolClient *obolapi.Client + lockHash []byte + fileOverrides []*eth2api.VersionedSignedValidatorRegistration + apiOverrides []*eth2api.VersionedSignedValidatorRegistration } // NewBuilderRegistrationService creates a new service that manages builder registrations. // It loads and applies overrides from the given path on creation. +// When obolClient is non-nil, Run will periodically fetch registrations from the Obol API. func NewBuilderRegistrationService( ctx context.Context, path string, forkVersion eth2p0.Version, baseRegistrations []*eth2api.VersionedSignedValidatorRegistration, baseFeeRecipients map[core.PubKey]string, + obolClient *obolapi.Client, + lockHash []byte, ) (BuilderRegistrationService, error) { svc := &builderRegistrationService{ path: path, forkVersion: forkVersion, baseRegistrations: baseRegistrations, baseFeeRecipients: baseFeeRecipients, + obolClient: obolClient, + lockHash: lockHash, } - // Apply initial overrides. - if err := svc.reload(ctx); err != nil { - return nil, err + // Apply initial file overrides if configured, otherwise just compute base state. + if svc.path != "" { + if err := svc.reloadFromFile(ctx); err != nil { + return nil, err + } + } else { + svc.recompute(ctx) } return svc, nil @@ -88,26 +210,48 @@ func (s *builderRegistrationService) FeeRecipient(pubkey core.PubKey) string { return s.feeRecipients[pubkey] } -// Run watches the overrides file for changes and reloads when modified. -// It blocks until ctx is cancelled. +// Run watches the overrides file for changes, periodically fetches from the Obol API, +// and reloads when either source is updated. It blocks until ctx is cancelled. func (s *builderRegistrationService) Run(ctx context.Context) { - if s.path == "" { + if s.path == "" && s.obolClient == nil { return } - watcher, err := fsnotify.NewWatcher() - if err != nil { - log.Error(ctx, "Failed to create file watcher for builder registration overrides", err) - return + // Optional file watcher (nil channels if path == ""). + var ( + fileEvents <-chan fsnotify.Event + fileErrors <-chan error + ) + + if s.path != "" { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Error(ctx, "Failed to create file watcher for builder registration overrides", err) + return + } + defer watcher.Close() + + dir := filepath.Dir(s.path) + if err := watcher.Add(dir); err != nil { + log.Error(ctx, "Failed to watch directory for builder registration overrides", err, z.Str("dir", dir)) + return + } + + fileEvents = watcher.Events + fileErrors = watcher.Errors } - defer watcher.Close() - // Watch the parent directory to catch file creation events. - dir := filepath.Dir(s.path) - if err := watcher.Add(dir); err != nil { - log.Error(ctx, "Failed to watch directory for builder registration overrides", err, z.Str("dir", dir)) + // Optional API fetch timer (nil channel if obolClient == nil). + var ( + fetchTimer *time.Timer + fetchCh <-chan time.Time + ) - return + if s.obolClient != nil { + fetchTimer = time.NewTimer(0) // Fire immediately on first iteration. + defer fetchTimer.Stop() + + fetchCh = fetchTimer.C } baseName := filepath.Base(s.path) @@ -116,7 +260,8 @@ func (s *builderRegistrationService) Run(ctx context.Context) { select { case <-ctx.Done(): return - case event, ok := <-watcher.Events: + + case event, ok := <-fileEvents: if !ok { return } @@ -129,40 +274,107 @@ func (s *builderRegistrationService) Run(ctx context.Context) { continue } - if err := s.reload(ctx); err != nil { + if err := s.reloadFromFile(ctx); err != nil { log.Warn(ctx, "Failed to reload builder registration overrides", err) } else { log.Info(ctx, "Reloaded builder registration overrides from file", z.Str("path", s.path)) } - case err, ok := <-watcher.Errors: + + // File change also triggers an API fetch. + if fetchTimer != nil { + if !fetchTimer.Stop() { + select { + case <-fetchTimer.C: + default: + } + } + + fetchTimer.Reset(0) + } + + case err, ok := <-fileErrors: if !ok { return } log.Warn(ctx, "File watcher error for builder registration overrides", err) + + case <-fetchCh: + hasIncomplete := s.fetchFromAPI(ctx) + if hasIncomplete { + fetchTimer.Reset(fetchIntervalIncomplete) + } else { + fetchTimer.Reset(fetchIntervalComplete) + } } } } -// reload reads the overrides file and re-applies overrides against base state. -func (s *builderRegistrationService) reload(ctx context.Context) error { - feeRecipients := maps.Clone(s.baseFeeRecipients) +// reloadFromFile reads the overrides file and stores file overrides, then recomputes. +func (s *builderRegistrationService) reloadFromFile(ctx context.Context) error { + overrides, err := LoadBuilderRegistrationOverrides(s.path, s.forkVersion) + if err != nil { + return err + } - if s.path == "" { - s.mu.Lock() - defer s.mu.Unlock() + s.fileOverrides = overrides + s.recompute(ctx) - s.registrations = s.baseRegistrations - s.feeRecipients = feeRecipients + return nil +} - return nil +// fetchFromAPI calls the Obol API, processes the response, stores API overrides, +// and calls recompute. Returns true if any validators have incomplete registrations +// or if an error occurred (to retry sooner). +func (s *builderRegistrationService) fetchFromAPI(ctx context.Context) (hasIncomplete bool) { + resp, err := s.obolClient.PostFeeRecipientsFetch(ctx, s.lockHash, nil) + if err != nil { + log.Warn(ctx, "Failed to fetch builder registrations from Obol API", err) + return true } - overrides, err := LoadBuilderRegistrationOverrides(s.path, s.forkVersion) + pv, err := ProcessValidators(resp.Validators) if err != nil { - return err + log.Warn(ctx, "Failed to process fetched builder registrations", err) + return true } + if len(pv.AggregatedRegs) > 0 { + // Verify signatures on aggregated registrations. + if s.forkVersion != (eth2p0.Version{}) { + var verified []*eth2api.VersionedSignedValidatorRegistration + + for _, reg := range pv.AggregatedRegs { + if err := verifyRegistrationSignature(reg, s.forkVersion); err != nil { + log.Warn(ctx, "Skipping fetched builder registration with invalid signature", err) + continue + } + + verified = append(verified, reg) + } + + pv.AggregatedRegs = verified + } + + log.Info(ctx, "Fetched builder registrations from Obol API", + z.Int("fully_signed", len(pv.AggregatedRegs)), + z.Int("incomplete", len(pv.Categories.Incomplete)), + ) + } + + s.apiOverrides = pv.AggregatedRegs + s.recompute(ctx) + + return len(pv.Categories.Incomplete) > 0 +} + +// recompute merges file and API overrides, applies them against base registrations, +// and updates the effective registrations and fee recipients. +func (s *builderRegistrationService) recompute(ctx context.Context) { + feeRecipients := maps.Clone(s.baseFeeRecipients) + + overrides := mergeOverrides(s.fileOverrides, s.apiOverrides) + var regs []*eth2api.VersionedSignedValidatorRegistration if len(overrides) > 0 { regs = applyBuilderRegistrationOverrides(ctx, s.baseRegistrations, overrides, feeRecipients) @@ -175,8 +387,49 @@ func (s *builderRegistrationService) reload(ctx context.Context) error { s.registrations = regs s.feeRecipients = feeRecipients +} - return nil +// mergeOverrides combines two override slices, keeping the entry with the highest +// timestamp per pubkey. +func mergeOverrides(a, b []*eth2api.VersionedSignedValidatorRegistration) []*eth2api.VersionedSignedValidatorRegistration { + if len(a) == 0 { + return b + } + + if len(b) == 0 { + return a + } + + byPubkey := make(map[string]*eth2api.VersionedSignedValidatorRegistration) + + for _, reg := range a { + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) + byPubkey[key] = reg + } + + for _, reg := range b { + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) + + existing, ok := byPubkey[key] + if !ok || existing.V1 == nil || existing.V1.Message == nil || reg.V1.Message.Timestamp.After(existing.V1.Message.Timestamp) { + byPubkey[key] = reg + } + } + + result := make([]*eth2api.VersionedSignedValidatorRegistration, 0, len(byPubkey)) + for _, reg := range byPubkey { + result = append(result, reg) + } + + return result } // LoadBuilderRegistrationOverrides reads builder registration overrides from the given JSON file. diff --git a/app/builderregistration_internal_test.go b/app/builderregistration_internal_test.go index 69535d039..ee410b682 100644 --- a/app/builderregistration_internal_test.go +++ b/app/builderregistration_internal_test.go @@ -193,7 +193,7 @@ func TestBuilderRegistrationService(t *testing.T) { } t.Run("no overrides file", func(t *testing.T) { - svc, err := NewBuilderRegistrationService(ctx, "", eth2p0.Version{}, baseRegs, baseFeeRecipients) + svc, err := NewBuilderRegistrationService(ctx, "", eth2p0.Version{}, baseRegs, baseFeeRecipients, nil, nil) require.NoError(t, err) require.Equal(t, baseRegs, svc.Registrations()) @@ -206,7 +206,7 @@ func TestBuilderRegistrationService(t *testing.T) { t.Run("initial load with overrides", func(t *testing.T) { path := writeOverridesFile(t, overrides) - svc, err := NewBuilderRegistrationService(ctx, path, eth2p0.Version(lock.ForkVersion), baseRegs, baseFeeRecipients) + svc, err := NewBuilderRegistrationService(ctx, path, eth2p0.Version(lock.ForkVersion), baseRegs, baseFeeRecipients, nil, nil) require.NoError(t, err) regs := svc.Registrations() @@ -223,7 +223,7 @@ func TestBuilderRegistrationService(t *testing.T) { path := filepath.Join(dir, "overrides.json") // Start without overrides file. - svc, err := NewBuilderRegistrationService(ctx, path, eth2p0.Version(lock.ForkVersion), baseRegs, baseFeeRecipients) + svc, err := NewBuilderRegistrationService(ctx, path, eth2p0.Version(lock.ForkVersion), baseRegs, baseFeeRecipients, nil, nil) require.NoError(t, err) require.Equal(t, baseRegs, svc.Registrations()) diff --git a/cmd/cmd_internal_test.go b/cmd/cmd_internal_test.go index e11c0764b..a78412005 100644 --- a/cmd/cmd_internal_test.go +++ b/cmd/cmd_internal_test.go @@ -84,6 +84,8 @@ func TestCmdFlags(t *testing.T) { BeaconNodeTimeout: 2 * time.Second, BeaconNodeSubmitTimeout: 2 * time.Second, BuilderRegOverridesFilePath: ".charon/builder_registrations_overrides.json", + PublishAddress: "https://api.obol.tech/v1", + PublishTimeout: 5 * time.Minute, }, }, { @@ -136,6 +138,8 @@ func TestCmdFlags(t *testing.T) { BeaconNodeTimeout: 2 * time.Second, BeaconNodeSubmitTimeout: 2 * time.Second, BuilderRegOverridesFilePath: ".charon/builder_registrations_overrides.json", + PublishAddress: "https://api.obol.tech/v1", + PublishTimeout: 5 * time.Minute, TestConfig: app.TestConfig{ P2PFuzz: true, }, diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index f8dee81b0..807d34ca5 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -9,17 +9,14 @@ import ( "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" - eth2v1 "github.com/attestantio/go-eth2-client/api/v1" - eth2spec "github.com/attestantio/go-eth2-client/spec" - eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" + "github.com/obolnetwork/charon/app" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" - "github.com/obolnetwork/charon/tbls" ) type feerecipientFetchConfig struct { @@ -48,103 +45,8 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf return cmd } -// validatorCategories holds categorized validator public keys by registration status. -type validatorCategories struct { - Complete []string - Incomplete []string - NoReg []string -} - -// aggregatePartialSignatures converts partial signatures into a full aggregated signature. -func aggregatePartialSignatures(partialSigs []obolapi.FeeRecipientPartialSig, pubkey string) (eth2p0.BLSSignature, error) { - sigsMap := make(map[int]tbls.Signature) - - for _, ps := range partialSigs { - sigsMap[ps.ShareIndex] = ps.Signature - } - - fullSig, err := tbls.ThresholdAggregate(sigsMap) - if err != nil { - return eth2p0.BLSSignature{}, errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", pubkey)) - } - - return eth2p0.BLSSignature(fullSig), nil -} - -// processedValidators holds the results of processing the API response. -type processedValidators struct { - AggregatedRegs []*eth2api.VersionedSignedValidatorRegistration - Categories validatorCategories - PartialSigIndices map[string][]int - // QuorumMessages maps validator pubkey to the quorum registration message details. - QuorumMessages map[string]*eth2v1.ValidatorRegistration - // IncompleteMessages maps validator pubkey to the incomplete registration message - // with the most partial signatures. - IncompleteMessages map[string]*eth2v1.ValidatorRegistration -} - -// processValidators aggregates signatures for validators with quorum and categorizes all validators by status. -func processValidators(validators []obolapi.FeeRecipientValidator) (processedValidators, error) { - result := processedValidators{ - PartialSigIndices: make(map[string][]int), - QuorumMessages: make(map[string]*eth2v1.ValidatorRegistration), - IncompleteMessages: make(map[string]*eth2v1.ValidatorRegistration), - } - - for _, val := range validators { - var hasQuorum, hasIncomplete bool - - for _, reg := range val.BuilderRegistrations { - if reg.Quorum { - hasQuorum = true - - fullSig, err := aggregatePartialSignatures(reg.PartialSignatures, val.Pubkey) - if err != nil { - return processedValidators{}, err - } - - result.AggregatedRegs = append(result.AggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ - Version: eth2spec.BuilderVersionV1, - V1: ð2v1.SignedValidatorRegistration{ - Message: reg.Message, - Signature: fullSig, - }, - }) - - result.QuorumMessages[val.Pubkey] = reg.Message - } else { - hasIncomplete = true - - if len(reg.PartialSignatures) > len(result.PartialSigIndices[val.Pubkey]) { - indices := make([]int, 0, len(reg.PartialSignatures)) - for _, ps := range reg.PartialSignatures { - indices = append(indices, ps.ShareIndex) - } - - result.PartialSigIndices[val.Pubkey] = indices - result.IncompleteMessages[val.Pubkey] = reg.Message - } - } - } - - if hasQuorum { - result.Categories.Complete = append(result.Categories.Complete, val.Pubkey) - } - - if hasIncomplete { - result.Categories.Incomplete = append(result.Categories.Incomplete, val.Pubkey) - } - - if !hasQuorum && !hasIncomplete { - result.Categories.NoReg = append(result.Categories.NoReg, val.Pubkey) - } - } - - return result, nil -} - // logValidatorStatus logs categorized validators with their current registration status. -func logValidatorStatus(ctx context.Context, pv processedValidators) { +func logValidatorStatus(ctx context.Context, pv app.ProcessedValidators) { cats := pv.Categories if len(cats.Complete) > 0 { @@ -212,7 +114,7 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e return errors.Wrap(err, "fetch builder registrations from Obol API") } - pv, err := processValidators(resp.Validators) + pv, err := app.ProcessValidators(resp.Validators) if err != nil { return err } diff --git a/cmd/run.go b/cmd/run.go index 9b348da2b..1ce6242b3 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -109,6 +109,8 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) { cmd.Flags().StringVar(&config.VCTLSCertFile, "vc-tls-cert-file", "", "The path to the TLS certificate file used by charon for the validator client API endpoint.") cmd.Flags().StringVar(&config.VCTLSKeyFile, "vc-tls-key-file", "", "The path to the TLS private key file associated with the provided TLS certificate.") cmd.Flags().StringVar(&config.BuilderRegOverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") + cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech/v1", "The URL of the remote API for background fee recipient fetching.") + cmd.Flags().DurationVar(&config.PublishTimeout, "publish-timeout", 5*time.Minute, "Timeout for accessing the remote API.") wrapPreRunE(cmd, func(cc *cobra.Command, _ []string) error { if len(config.BeaconNodeAddrs) == 0 && !config.SimnetBMock { diff --git a/docs/configuration.md b/docs/configuration.md index f8fb10b63..c7b35a156 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,6 +199,8 @@ Flags: --private-key-file string The path to the charon enr private key file. (default ".charon/charon-enr-private-key") --private-key-file-lock Enables private key locking to prevent multiple instances using the same key. --proc-directory string Directory to look into in order to detect other stack components running on the host. + --publish-address string The URL of the remote API for background fee recipient fetching. (default "https://api.obol.tech/v1") + --publish-timeout duration Timeout for accessing the remote API. (default 5m0s) --simnet-beacon-mock Enables an internal mock beacon node for running a simnet. --simnet-beacon-mock-fuzz Configures simnet beaconmock to return fuzzed responses. --simnet-slot-duration duration Configures slot duration in simnet beacon mock. (default 1s)