From d2cd4995f4815ccd7e34a2fd12e0b05e3855b977 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Tue, 17 Mar 2026 13:00:30 +0300 Subject: [PATCH 1/3] *: charon runtime using overrides --- app/app.go | 26 +- app/builderregistration.go | 292 +++++++++++++++++++ app/builderregistration_internal_test.go | 340 +++++++++++++++++++++++ app/lifecycle/order.go | 1 + app/lifecycle/orderstart_string.go | 18 +- app/lifecycle/orderstop_string.go | 5 +- cmd/cmd.go | 1 + cmd/cmd_internal_test.go | 54 ++-- cmd/feerecipientfetch.go | 8 +- cmd/feerecipientlist.go | 166 +++++++++++ cmd/feerecipientlist_internal_test.go | 295 ++++++++++++++++++++ cmd/feerecipientsign.go | 25 +- cmd/run.go | 1 + core/scheduler/scheduler.go | 16 +- core/scheduler/scheduler_test.go | 10 +- docs/configuration.md | 1 + go.mod | 2 +- 17 files changed, 1184 insertions(+), 77 deletions(-) create mode 100644 app/builderregistration.go create mode 100644 app/builderregistration_internal_test.go create mode 100644 cmd/feerecipientlist.go create mode 100644 cmd/feerecipientlist_internal_test.go diff --git a/app/app.go b/app/app.go index 51926f3ab..c223a447c 100644 --- a/app/app.go +++ b/app/app.go @@ -108,6 +108,7 @@ type Config struct { GraffitiDisableClientAppend bool VCTLSCertFile string VCTLSKeyFile string + BuilderRegOverridesFilePath string TestConfig TestConfig } @@ -462,6 +463,20 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, builderRegistrations = append(builderRegistrations, builderRegistration) } + builderRegSvc, err := NewBuilderRegistrationService( + ctx, + conf.BuilderRegOverridesFilePath, + eth2p0.Version(lock.ForkVersion), + builderRegistrations, + feeRecipientAddrByCorePubkey, + ) + if err != nil { + return err + } + + life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartBuilderRegWatcher, + lifecycle.HookFuncCtx(builderRegSvc.Run)) + peers, err := lock.Peers() if err != nil { return err @@ -476,7 +491,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, return core.NewDeadliner(ctx, label, deadlineFunc) } - sched, err := scheduler.New(builderRegistrations, eth2Cl, conf.BuilderAPI) + sched, err := scheduler.New(builderRegSvc.Registrations, eth2Cl, conf.BuilderAPI) if err != nil { return err } @@ -484,10 +499,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, sseListener.SubscribeChainReorgEvent(sched.HandleChainReorgEvent) sseListener.SubscribeBlockEvent(sched.HandleBlockEvent) - feeRecipientFunc := func(pubkey core.PubKey) string { - return feeRecipientAddrByCorePubkey[pubkey] - } - sched.SubscribeSlots(setFeeRecipient(eth2Cl, feeRecipientFunc)) + sched.SubscribeSlots(setFeeRecipient(eth2Cl, builderRegSvc.FeeRecipient)) // Setup validator cache, refreshing it every epoch. valCache := eth2wrap.NewValidatorCache(eth2Cl, eth2Pubkeys) @@ -581,14 +593,14 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, electraSlot := eth2p0.Slot(uint64(forkSchedule[eth2wrap.Electra].Epoch) * slotsPerEpoch) - fetch, err := fetcher.New(eth2Cl, feeRecipientFunc, conf.BuilderAPI, graffitiBuilder, electraSlot, featureset.Enabled(featureset.FetchOnlyCommIdx0)) + fetch, err := fetcher.New(eth2Cl, builderRegSvc.FeeRecipient, conf.BuilderAPI, graffitiBuilder, electraSlot, featureset.Enabled(featureset.FetchOnlyCommIdx0)) if err != nil { return err } dutyDB := dutydb.NewMemDB(deadlinerFunc("dutydb")) - vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, feeRecipientFunc, conf.BuilderAPI, lock.TargetGasLimit) + vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, builderRegSvc.FeeRecipient, conf.BuilderAPI, lock.TargetGasLimit) if err != nil { return err } diff --git a/app/builderregistration.go b/app/builderregistration.go new file mode 100644 index 000000000..ecc99ac68 --- /dev/null +++ b/app/builderregistration.go @@ -0,0 +1,292 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package app + +import ( + "context" + "encoding/hex" + "encoding/json" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "time" + + eth2api "github.com/attestantio/go-eth2-client/api" + 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/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +const fileWatchDebounce = 500 * time.Millisecond + +// BuilderRegistrationService manages builder registration state including +// runtime monitoring of the overrides file for changes. It provides thread-safe +// access to current registrations and fee recipient addresses. +type BuilderRegistrationService struct { + mu sync.RWMutex + path string + forkVersion eth2p0.Version + baseRegistrations []*eth2api.VersionedSignedValidatorRegistration + baseFeeRecipients map[core.PubKey]string + registrations []*eth2api.VersionedSignedValidatorRegistration + feeRecipients map[core.PubKey]string +} + +// NewBuilderRegistrationService creates a new service that manages builder registrations. +// It loads and applies overrides from the given path on creation. +func NewBuilderRegistrationService( + ctx context.Context, + path string, + forkVersion eth2p0.Version, + baseRegistrations []*eth2api.VersionedSignedValidatorRegistration, + baseFeeRecipients map[core.PubKey]string, +) (*BuilderRegistrationService, error) { + svc := &BuilderRegistrationService{ + path: path, + forkVersion: forkVersion, + baseRegistrations: baseRegistrations, + baseFeeRecipients: baseFeeRecipients, + } + + // Apply initial overrides. + if err := svc.reload(ctx); err != nil { + return nil, err + } + + return svc, nil +} + +// Registrations returns the current effective builder registrations. +func (s *BuilderRegistrationService) Registrations() []*eth2api.VersionedSignedValidatorRegistration { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.registrations +} + +// FeeRecipient returns the current fee recipient address for the given pubkey. +func (s *BuilderRegistrationService) FeeRecipient(pubkey core.PubKey) string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.feeRecipients[pubkey] +} + +// Run watches the overrides file for changes and reloads when modified. +// It blocks until ctx is cancelled. +func (s *BuilderRegistrationService) Run(ctx context.Context) { + if s.path == "" { + return + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Error(ctx, "Failed to create file watcher for builder registration overrides", err) + return + } + 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)) + + return + } + + baseName := filepath.Base(s.path) + + var debounce <-chan time.Time + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watcher.Events: + if !ok { + return + } + + if filepath.Base(event.Name) != baseName { + continue + } + + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { + continue + } + + // Debounce rapid events (editors may write multiple times). + debounce = time.After(fileWatchDebounce) + case <-debounce: + if err := s.reload(ctx); err != nil { + log.Warn(ctx, "Failed to reload builder registration overrides", err) + } + + debounce = nil + case err, ok := <-watcher.Errors: + if !ok { + return + } + + log.Warn(ctx, "File watcher error for builder registration overrides", err) + } + } +} + +// 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) + + if s.path == "" { + s.mu.Lock() + defer s.mu.Unlock() + + s.registrations = s.baseRegistrations + s.feeRecipients = feeRecipients + + return nil + } + + overrides, err := LoadBuilderRegistrationOverrides(s.path, s.forkVersion) + if err != nil { + return err + } + + var regs []*eth2api.VersionedSignedValidatorRegistration + if len(overrides) > 0 { + regs = applyBuilderRegistrationOverrides(ctx, s.baseRegistrations, overrides, feeRecipients) + } else { + regs = s.baseRegistrations + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.registrations = regs + s.feeRecipients = feeRecipients + + return nil +} + +// LoadBuilderRegistrationOverrides reads builder registration overrides from the given JSON file. +// It returns nil if the file does not exist. When forkVersion is non-zero, each registration's +// BLS signature is verified against the validator pubkey embedded in the message. +func LoadBuilderRegistrationOverrides(path string, forkVersion eth2p0.Version) ([]*eth2api.VersionedSignedValidatorRegistration, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, errors.Wrap(err, "read builder registration overrides file", z.Str("path", path)) + } + + var regs []*eth2api.VersionedSignedValidatorRegistration + if err := json.Unmarshal(data, ®s); err != nil { + return nil, errors.Wrap(err, "unmarshal builder registration overrides file", z.Str("path", path)) + } + + if forkVersion != (eth2p0.Version{}) { + for _, reg := range regs { + if err := verifyRegistrationSignature(reg, forkVersion); err != nil { + return nil, err + } + } + } + + return regs, nil +} + +// verifyRegistrationSignature verifies the BLS signature of a single builder registration. +func verifyRegistrationSignature(reg *eth2api.VersionedSignedValidatorRegistration, forkVersion eth2p0.Version) error { + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + return errors.New("invalid builder registration override: nil message") + } + + sigRoot, err := registration.GetMessageSigningRoot(reg.V1.Message, forkVersion) + if err != nil { + return errors.Wrap(err, "get signing root for builder registration override") + } + + pubkey, err := tblsconv.PubkeyFromBytes(reg.V1.Message.Pubkey[:]) + if err != nil { + return errors.Wrap(err, "convert pubkey from builder registration override") + } + + sig, err := tblsconv.SignatureFromBytes(reg.V1.Signature[:]) + if err != nil { + return errors.Wrap(err, "convert signature from builder registration override") + } + + if err := tbls.Verify(pubkey, sigRoot[:], sig); err != nil { + return errors.Wrap(err, "verify builder registration override signature", + z.Str("pubkey", hex.EncodeToString(reg.V1.Message.Pubkey[:])), + ) + } + + return nil +} + +// applyBuilderRegistrationOverrides replaces entries in builderRegs with overrides that have +// a strictly newer timestamp for the same validator pubkey. It also updates feeRecipientByPubkey +// for overridden validators. +func applyBuilderRegistrationOverrides( + ctx context.Context, + builderRegs []*eth2api.VersionedSignedValidatorRegistration, + overrides []*eth2api.VersionedSignedValidatorRegistration, + feeRecipientByPubkey map[core.PubKey]string, +) []*eth2api.VersionedSignedValidatorRegistration { + // Build lookup from overrides keyed by lowercase pubkey hex. + overrideByPubkey := make(map[string]*eth2api.VersionedSignedValidatorRegistration, len(overrides)) + for _, o := range overrides { + if o == nil || o.V1 == nil || o.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(o.V1.Message.Pubkey[:])) + overrideByPubkey[key] = o + } + + result := make([]*eth2api.VersionedSignedValidatorRegistration, len(builderRegs)) + for i, reg := range builderRegs { + result[i] = reg + + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) + + override, ok := overrideByPubkey[key] + if !ok { + continue + } + + if !override.V1.Message.Timestamp.After(reg.V1.Message.Timestamp) { + continue + } + + result[i] = override + + corePubkey, err := core.PubKeyFromBytes(reg.V1.Message.Pubkey[:]) + if err != nil { + continue + } + + feeRecipientByPubkey[corePubkey] = "0x" + hex.EncodeToString(override.V1.Message.FeeRecipient[:]) + + log.Info(ctx, "Applied builder registration override", + z.Str("pubkey", "0x"+key), + z.Str("fee_recipient", feeRecipientByPubkey[corePubkey]), + ) + } + + return result +} diff --git a/app/builderregistration_internal_test.go b/app/builderregistration_internal_test.go new file mode 100644 index 000000000..69535d039 --- /dev/null +++ b/app/builderregistration_internal_test.go @@ -0,0 +1,340 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package app + +import ( + "context" + "encoding/json" + "maps" + "math/rand" + "os" + "path/filepath" + "testing" + "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" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +func TestLoadBuilderRegistrationOverrides(t *testing.T) { + t.Run("file not found", func(t *testing.T) { + regs, err := LoadBuilderRegistrationOverrides(filepath.Join(t.TempDir(), "missing.json"), eth2p0.Version{}) + require.NoError(t, err) + require.Nil(t, regs) + }) + + t.Run("invalid json", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644)) + + _, err := LoadBuilderRegistrationOverrides(path, eth2p0.Version{}) + require.ErrorContains(t, err, "unmarshal builder registration overrides file") + }) + + t.Run("valid without verification", func(t *testing.T) { + regs := []*eth2api.VersionedSignedValidatorRegistration{ + makeUnsignedOverride(t), + } + + path := writeOverridesFile(t, regs) + + loaded, err := LoadBuilderRegistrationOverrides(path, eth2p0.Version{}) + require.NoError(t, err) + require.Len(t, loaded, 1) + }) + + t.Run("valid with signature verification", func(t *testing.T) { + lock, regs := makeSignedOverrides(t) + + path := writeOverridesFile(t, regs) + + loaded, err := LoadBuilderRegistrationOverrides(path, eth2p0.Version(lock.ForkVersion)) + require.NoError(t, err) + require.Len(t, loaded, len(regs)) + }) + + t.Run("invalid signature", func(t *testing.T) { + lock, regs := makeSignedOverrides(t) + + // Corrupt the signature. + regs[0].V1.Signature[0] ^= 0xff + + path := writeOverridesFile(t, regs) + + _, err := LoadBuilderRegistrationOverrides(path, eth2p0.Version(lock.ForkVersion)) + require.ErrorContains(t, err, "verify builder registration override signature") + }) +} + +func Test_applyBuilderRegistrationOverrides(t *testing.T) { + lock, _ := makeSignedOverrides(t) + ctx := t.Context() + + // Build base registrations from the lock. + var baseRegs []*eth2api.VersionedSignedValidatorRegistration + + feeRecipientByPubkey := make(map[core.PubKey]string) + + for _, val := range lock.Validators { + reg, err := val.Eth2Registration() + require.NoError(t, err) + + baseRegs = append(baseRegs, reg) + corePubkey, err := core.PubKeyFromBytes(val.PubKey) + require.NoError(t, err) + + feeRecipientByPubkey[corePubkey] = "0x" + lock.FeeRecipientAddresses()[0] + } + + t.Run("no overrides", func(t *testing.T) { + feeMap := maps.Clone(feeRecipientByPubkey) + result := applyBuilderRegistrationOverrides(ctx, baseRegs, nil, feeMap) + require.Equal(t, baseRegs, result) + }) + + t.Run("override with newer timestamp wins", func(t *testing.T) { + feeMap := maps.Clone(feeRecipientByPubkey) + + newFeeRecipient := bellatrix.ExecutionAddress{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAB, 0xCD} + override := ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: ð2v1.ValidatorRegistration{ + FeeRecipient: newFeeRecipient, + GasLimit: 42000, + Timestamp: baseRegs[0].V1.Message.Timestamp.Add(time.Hour), + Pubkey: baseRegs[0].V1.Message.Pubkey, + }, + }, + } + + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) + + require.Equal(t, override, result[0]) + + corePubkey, err := core.PubKeyFromBytes(baseRegs[0].V1.Message.Pubkey[:]) + require.NoError(t, err) + require.Contains(t, feeMap[corePubkey], "abcd") + }) + + t.Run("override with older timestamp loses", func(t *testing.T) { + feeMap := maps.Clone(feeRecipientByPubkey) + + override := ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: ð2v1.ValidatorRegistration{ + FeeRecipient: bellatrix.ExecutionAddress{}, + GasLimit: 42000, + Timestamp: baseRegs[0].V1.Message.Timestamp.Add(-time.Hour), + Pubkey: baseRegs[0].V1.Message.Pubkey, + }, + }, + } + + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) + + require.Equal(t, baseRegs[0], result[0]) + }) + + t.Run("unknown pubkey ignored", func(t *testing.T) { + feeMap := maps.Clone(feeRecipientByPubkey) + + override := ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: ð2v1.ValidatorRegistration{ + FeeRecipient: bellatrix.ExecutionAddress{}, + GasLimit: 42000, + Timestamp: time.Now().Add(time.Hour), + Pubkey: eth2p0.BLSPubKey{0x99}, + }, + }, + } + + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) + + for i := range baseRegs { + require.Equal(t, baseRegs[i], result[i]) + } + }) +} + +func TestBuilderRegistrationService(t *testing.T) { + lock, overrides := makeSignedOverrides(t) + ctx := t.Context() + + // Build base registrations and fee recipients from the lock. + var baseRegs []*eth2api.VersionedSignedValidatorRegistration + + baseFeeRecipients := make(map[core.PubKey]string) + + for vi, val := range lock.Validators { + reg, err := val.Eth2Registration() + require.NoError(t, err) + + baseRegs = append(baseRegs, reg) + + corePubkey, err := core.PubKeyFromBytes(val.PubKey) + require.NoError(t, err) + + baseFeeRecipients[corePubkey] = lock.FeeRecipientAddresses()[vi] + } + + t.Run("no overrides file", func(t *testing.T) { + svc, err := NewBuilderRegistrationService(ctx, "", eth2p0.Version{}, baseRegs, baseFeeRecipients) + require.NoError(t, err) + + require.Equal(t, baseRegs, svc.Registrations()) + + for pk, addr := range baseFeeRecipients { + require.Equal(t, addr, svc.FeeRecipient(pk)) + } + }) + + 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) + require.NoError(t, err) + + regs := svc.Registrations() + require.Len(t, regs, len(baseRegs)) + + // Overrides have newer timestamps, so they should win. + for i, reg := range regs { + require.Equal(t, overrides[i].V1.Message.FeeRecipient, reg.V1.Message.FeeRecipient) + } + }) + + t.Run("file watcher reloads on change", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overrides.json") + + // Start without overrides file. + svc, err := NewBuilderRegistrationService(ctx, path, eth2p0.Version(lock.ForkVersion), baseRegs, baseFeeRecipients) + require.NoError(t, err) + require.Equal(t, baseRegs, svc.Registrations()) + + // Start the watcher. + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go svc.Run(watchCtx) + + // Give the watcher time to start. + time.Sleep(100 * time.Millisecond) + + // Write the overrides file. + data, err := json.Marshal(overrides) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + + // Wait for debounce + reload. + require.Eventually(t, func() bool { + regs := svc.Registrations() + if len(regs) != len(overrides) { + return false + } + + return regs[0].V1.Message.FeeRecipient == overrides[0].V1.Message.FeeRecipient + }, 3*time.Second, 100*time.Millisecond) + + // Verify fee recipients were also updated. + corePubkey, err := core.PubKeyFromBytes(lock.Validators[0].PubKey) + require.NoError(t, err) + require.Contains(t, svc.FeeRecipient(corePubkey), "9900") + }) +} + +// makeSignedOverrides creates a test cluster lock and properly signed builder registration overrides. +func makeSignedOverrides(t *testing.T) (cluster.Lock, []*eth2api.VersionedSignedValidatorRegistration) { + t.Helper() + + random := rand.New(rand.NewSource(0)) + lock, _, keyShares := cluster.NewForT(t, 2, 4, 4, 0, random) + + forkVersion, err := eth2util.NetworkToForkVersionBytes(eth2util.Goerli.Name) + require.NoError(t, err) + + var overrides []*eth2api.VersionedSignedValidatorRegistration + + for valIdx, val := range lock.Validators { + // Reconstruct root secret from shares. + sharesMap := make(map[int]tbls.PrivateKey) + for i, share := range keyShares[valIdx] { + sharesMap[i+1] = share + } + + rootSecret, err := tbls.RecoverSecret(sharesMap, uint(len(keyShares[valIdx])), uint(lock.Threshold)) + require.NoError(t, err) + + pubkey, err := tblsconv.PubkeyToETH2(tbls.PublicKey(val.PubKey)) + require.NoError(t, err) + + feeRecipient := bellatrix.ExecutionAddress{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, byte(valIdx)} + + msg := ð2v1.ValidatorRegistration{ + FeeRecipient: feeRecipient, + GasLimit: registration.DefaultGasLimit, + Timestamp: time.Now().Add(time.Hour), + Pubkey: pubkey, + } + + sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(forkVersion)) + require.NoError(t, err) + + sig, err := tbls.Sign(rootSecret, sigRoot[:]) + require.NoError(t, err) + + overrides = append(overrides, ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: msg, + Signature: eth2p0.BLSSignature(sig), + }, + }) + } + + return lock, overrides +} + +// makeUnsignedOverride creates a minimal override without a valid signature. +func makeUnsignedOverride(t *testing.T) *eth2api.VersionedSignedValidatorRegistration { + t.Helper() + + return ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: ð2v1.ValidatorRegistration{ + FeeRecipient: bellatrix.ExecutionAddress{0x01}, + GasLimit: 30000000, + Timestamp: time.Now(), + Pubkey: eth2p0.BLSPubKey{0x01}, + }, + }, + } +} + +func writeOverridesFile(t *testing.T, regs []*eth2api.VersionedSignedValidatorRegistration) string { + t.Helper() + + data, err := json.Marshal(regs) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "overrides.json") + require.NoError(t, os.WriteFile(path, data, 0o644)) + + return path +} diff --git a/app/lifecycle/order.go b/app/lifecycle/order.go index 013b71ac7..4969432e7 100644 --- a/app/lifecycle/order.go +++ b/app/lifecycle/order.go @@ -28,6 +28,7 @@ const ( StartP2PConsensus StartSimulator StartScheduler + StartBuilderRegWatcher StartP2PEventCollector StartPeerInfo StartParSigDB diff --git a/app/lifecycle/orderstart_string.go b/app/lifecycle/orderstart_string.go index e1f005277..8f402f0b1 100644 --- a/app/lifecycle/orderstart_string.go +++ b/app/lifecycle/orderstart_string.go @@ -25,19 +25,21 @@ func _() { _ = x[StartP2PConsensus-12] _ = x[StartSimulator-13] _ = x[StartScheduler-14] - _ = x[StartP2PEventCollector-15] - _ = x[StartPeerInfo-16] - _ = x[StartParSigDB-17] - _ = x[StartStackSnipe-18] + _ = x[StartBuilderRegWatcher-15] + _ = x[StartP2PEventCollector-16] + _ = x[StartPeerInfo-17] + _ = x[StartParSigDB-18] + _ = x[StartStackSnipe-19] } -const _OrderStart_name = "TrackerPrivkeyLockEth1ClientAggSigDBRelayMonitoringAPIDebugAPIValidatorAPIP2PPingP2PRoutersForceDirectConnsForceQUICConnsP2PConsensusSimulatorSchedulerP2PEventCollectorPeerInfoParSigDBStackSnipe" +const _OrderStart_name = "TrackerPrivkeyLockEth1ClientAggSigDBRelayMonitoringAPIDebugAPIValidatorAPIP2PPingP2PRoutersForceDirectConnsForceQUICConnsP2PConsensusSimulatorSchedulerBuilderRegWatcherP2PEventCollectorPeerInfoParSigDBStackSnipe" -var _OrderStart_index = [...]uint8{0, 7, 18, 28, 36, 41, 54, 62, 74, 81, 91, 107, 121, 133, 142, 151, 168, 176, 184, 194} +var _OrderStart_index = [...]uint8{0, 7, 18, 28, 36, 41, 54, 62, 74, 81, 91, 107, 121, 133, 142, 151, 168, 185, 193, 201, 211} func (i OrderStart) String() string { - if i < 0 || i >= OrderStart(len(_OrderStart_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_OrderStart_index)-1 { return "OrderStart(" + strconv.FormatInt(int64(i), 10) + ")" } - return _OrderStart_name[_OrderStart_index[i]:_OrderStart_index[i+1]] + return _OrderStart_name[_OrderStart_index[idx]:_OrderStart_index[idx+1]] } diff --git a/app/lifecycle/orderstop_string.go b/app/lifecycle/orderstop_string.go index 6cf1f1c10..97b41a26f 100644 --- a/app/lifecycle/orderstop_string.go +++ b/app/lifecycle/orderstop_string.go @@ -28,8 +28,9 @@ const _OrderStop_name = "SchedulerPrivkeyLockRetryerDutyDBBeaconMockValidatorAPI var _OrderStop_index = [...]uint8{0, 9, 20, 27, 33, 43, 55, 62, 71, 78, 86, 99} func (i OrderStop) String() string { - if i < 0 || i >= OrderStop(len(_OrderStop_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_OrderStop_index)-1 { return "OrderStop(" + strconv.FormatInt(int64(i), 10) + ")" } - return _OrderStop_name[_OrderStop_index[i]:_OrderStop_index[i+1]] + return _OrderStop_name[_OrderStop_index[idx]:_OrderStop_index[idx+1]] } diff --git a/cmd/cmd.go b/cmd/cmd.go index fb00dab2e..505b0d29c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -77,6 +77,7 @@ func New() *cobra.Command { newFeeRecipientCmd( newFeeRecipientSignCmd(runFeeRecipientSign), newFeeRecipientFetchCmd(runFeeRecipientFetch), + newFeeRecipientListCmd(runFeeRecipientList), ), newUnsafeCmd(newRunCmd(app.Run, true)), ) diff --git a/cmd/cmd_internal_test.go b/cmd/cmd_internal_test.go index 969109fb8..e11c0764b 100644 --- a/cmd/cmd_internal_test.go +++ b/cmd/cmd_internal_test.go @@ -70,19 +70,20 @@ func TestCmdFlags(t *testing.T) { Enabled: nil, Disabled: nil, }, - LockFile: ".charon/cluster-lock.json", - ManifestFile: ".charon/cluster-manifest.pb", - PrivKeyFile: ".charon/charon-enr-private-key", - PrivKeyLocking: false, - SimnetValidatorKeysDir: ".charon/validator_keys", - SimnetSlotDuration: time.Second, - MonitoringAddr: "127.0.0.1:3620", - ValidatorAPIAddr: "127.0.0.1:3600", - OTLPAddress: "", - OTLPServiceName: "charon", - BeaconNodeAddrs: []string{"http://beacon.node"}, - BeaconNodeTimeout: 2 * time.Second, - BeaconNodeSubmitTimeout: 2 * time.Second, + LockFile: ".charon/cluster-lock.json", + ManifestFile: ".charon/cluster-manifest.pb", + PrivKeyFile: ".charon/charon-enr-private-key", + PrivKeyLocking: false, + SimnetValidatorKeysDir: ".charon/validator_keys", + SimnetSlotDuration: time.Second, + MonitoringAddr: "127.0.0.1:3620", + ValidatorAPIAddr: "127.0.0.1:3600", + OTLPAddress: "", + OTLPServiceName: "charon", + BeaconNodeAddrs: []string{"http://beacon.node"}, + BeaconNodeTimeout: 2 * time.Second, + BeaconNodeSubmitTimeout: 2 * time.Second, + BuilderRegOverridesFilePath: ".charon/builder_registrations_overrides.json", }, }, { @@ -121,19 +122,20 @@ func TestCmdFlags(t *testing.T) { Enabled: nil, Disabled: nil, }, - LockFile: ".charon/cluster-lock.json", - ManifestFile: ".charon/cluster-manifest.pb", - PrivKeyFile: ".charon/charon-enr-private-key", - PrivKeyLocking: false, - SimnetValidatorKeysDir: ".charon/validator_keys", - SimnetSlotDuration: time.Second, - MonitoringAddr: "127.0.0.1:3620", - ValidatorAPIAddr: "127.0.0.1:3600", - OTLPAddress: "", - OTLPServiceName: "charon", - BeaconNodeAddrs: []string{"http://beacon.node"}, - BeaconNodeTimeout: 2 * time.Second, - BeaconNodeSubmitTimeout: 2 * time.Second, + LockFile: ".charon/cluster-lock.json", + ManifestFile: ".charon/cluster-manifest.pb", + PrivKeyFile: ".charon/charon-enr-private-key", + PrivKeyLocking: false, + SimnetValidatorKeysDir: ".charon/validator_keys", + SimnetSlotDuration: time.Second, + MonitoringAddr: "127.0.0.1:3620", + ValidatorAPIAddr: "127.0.0.1:3600", + OTLPAddress: "", + OTLPServiceName: "charon", + BeaconNodeAddrs: []string{"http://beacon.node"}, + BeaconNodeTimeout: 2 * time.Second, + BeaconNodeSubmitTimeout: 2 * time.Second, + BuilderRegOverridesFilePath: ".charon/builder_registrations_overrides.json", TestConfig: app.TestConfig{ P2PFuzz: true, }, diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 8a2e9704c..f8dee81b0 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -148,7 +148,7 @@ func logValidatorStatus(ctx context.Context, pv processedValidators) { cats := pv.Categories if len(cats.Complete) > 0 { - log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(cats.Complete))) + log.Info(ctx, "Validators with complete builder registrations", z.Int("total", len(cats.Complete))) for _, pubkey := range cats.Complete { if msg := pv.QuorumMessages[pubkey]; msg != nil { @@ -165,7 +165,7 @@ func logValidatorStatus(ctx context.Context, pv processedValidators) { } if len(cats.Incomplete) > 0 { - log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(cats.Incomplete))) + log.Info(ctx, "Validators with partial builder registrations", z.Int("total", len(cats.Incomplete))) for _, pubkey := range cats.Incomplete { indices := pv.PartialSigIndices[pubkey] @@ -188,7 +188,7 @@ func logValidatorStatus(ctx context.Context, pv processedValidators) { } if len(cats.NoReg) > 0 { - log.Info(ctx, "Validators unknown to the API", z.Int("count", len(cats.NoReg))) + log.Info(ctx, "Validators unknown to the API", z.Int("total", len(cats.NoReg))) for _, pubkey := range cats.NoReg { log.Info(ctx, " No registrations", z.Str("pubkey", pubkey)) @@ -231,7 +231,7 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e } log.Info(ctx, "Successfully wrote builder registrations overrides", - z.Int("count", len(pv.AggregatedRegs)), + z.Int("total", len(pv.AggregatedRegs)), z.Str("path", config.OverridesFilePath), ) diff --git a/cmd/feerecipientlist.go b/cmd/feerecipientlist.go new file mode 100644 index 000000000..14bb97117 --- /dev/null +++ b/cmd/feerecipientlist.go @@ -0,0 +1,166 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/hex" + "strings" + "time" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" +) + +type feerecipientListConfig struct { + ValidatorPublicKeys []string + LockFilePath string + OverridesFilePath string + Log log.Config +} + +func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig) error) *cobra.Command { + var config feerecipientListConfig + + cmd := &cobra.Command{ + Use: "list", + Short: "List latest builder registration data per validator.", + Long: "Lists the latest builder registration data for each validator, picking the entry with the highest timestamp from the cluster lock or overrides file.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "Optional comma-separated list of validator public keys to list builder registrations for.") + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") + + return cmd +} + +// registrationEntry holds resolved builder registration data for a single validator. +type registrationEntry struct { + Pubkey string + FeeRecipient string + GasLimit uint64 + Timestamp int64 +} + +// resolveLatestRegistrations returns the latest builder registration for each validator +// by comparing timestamps from the cluster lock and overrides file. +func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrationSource, pubkeyFilter map[string]struct{}) []registrationEntry { + var entries []registrationEntry + + for _, dv := range cl.Validators { + pubkeyHex := dv.PublicKeyHex() + normalized := normalizePubkey(pubkeyHex) + + if len(pubkeyFilter) > 0 { + if _, ok := pubkeyFilter[normalized]; !ok { + continue + } + } + + feeRecipient := "0x" + hex.EncodeToString(dv.BuilderRegistration.Message.FeeRecipient) + gasLimit := uint64(dv.BuilderRegistration.Message.GasLimit) + timestamp := dv.BuilderRegistration.Message.Timestamp + + if override, ok := overrides[normalized]; ok { + if override.Timestamp.After(timestamp) { + feeRecipient = override.FeeRecipient + gasLimit = override.GasLimit + timestamp = override.Timestamp + } + } + + entries = append(entries, registrationEntry{ + Pubkey: pubkeyHex, + FeeRecipient: feeRecipient, + GasLimit: gasLimit, + Timestamp: timestamp.Unix(), + }) + } + + return entries +} + +func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) error { + cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath) + if err != nil { + return err + } + + if len(config.ValidatorPublicKeys) > 0 { + if err := validatePubkeysInCluster(config.ValidatorPublicKeys, *cl); err != nil { + return err + } + } + + overrides, err := loadOverridesAsSource(config.OverridesFilePath, cl.ForkVersion) + if err != nil { + return err + } + + pubkeyFilter := make(map[string]struct{}, len(config.ValidatorPublicKeys)) + for _, pk := range config.ValidatorPublicKeys { + pubkeyFilter[normalizePubkey(pk)] = struct{}{} + } + + entries := resolveLatestRegistrations(*cl, overrides, pubkeyFilter) + + if len(entries) == 0 { + log.Info(ctx, "No builder registrations found") + return nil + } + + log.Info(ctx, "Builder registrations", z.Int("total", len(entries))) + + for _, e := range entries { + log.Info(ctx, "Builder registration for "+e.Pubkey, + z.Str("fee_recipient", e.FeeRecipient), + z.U64("gas_limit", e.GasLimit), + z.I64("timestamp", e.Timestamp), + ) + } + + return nil +} + +// registrationSource holds fee recipient data extracted from overrides file. +type registrationSource struct { + FeeRecipient string + GasLimit uint64 + Timestamp time.Time +} + +// loadOverridesAsSource reads the builder registrations overrides file and returns +// a map keyed by normalized validator pubkey hex with the registration details needed for comparison. +func loadOverridesAsSource(path string, forkVersion []byte) (map[string]registrationSource, error) { + regs, err := app.LoadBuilderRegistrationOverrides(path, eth2p0.Version(forkVersion)) + if err != nil { + return nil, err + } + + result := make(map[string]registrationSource, len(regs)) + + for _, reg := range regs { + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) + result[key] = registrationSource{ + FeeRecipient: reg.V1.Message.FeeRecipient.String(), + GasLimit: reg.V1.Message.GasLimit, + Timestamp: reg.V1.Message.Timestamp, + } + } + + return result, nil +} diff --git a/cmd/feerecipientlist_internal_test.go b/cmd/feerecipientlist_internal_test.go new file mode 100644 index 000000000..b5e5b7ac0 --- /dev/null +++ b/cmd/feerecipientlist_internal_test.go @@ -0,0 +1,295 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "encoding/hex" + "encoding/json" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "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" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +func TestFeeRecipientListValid(t *testing.T) { + valAmt := 4 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, _, _ := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + lockFile := filepath.Join(t.TempDir(), "cluster-lock.json") + require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) + + config := feerecipientListConfig{ + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + } + + require.NoError(t, runFeeRecipientList(t.Context(), config)) +} + +func TestFeeRecipientListWithFilter(t *testing.T) { + valAmt := 4 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, _, _ := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + lockFile := filepath.Join(t.TempDir(), "cluster-lock.json") + require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) + + config := feerecipientListConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + } + + require.NoError(t, runFeeRecipientList(t.Context(), config)) +} + +func TestFeeRecipientListInvalidPubkey(t *testing.T) { + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, _, _ := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + lockFile := filepath.Join(t.TempDir(), "cluster-lock.json") + require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) + + config := feerecipientListConfig{ + ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + } + + err = runFeeRecipientList(t.Context(), config) + require.ErrorContains(t, err, "validator pubkey not found in cluster lock") +} + +func TestFeeRecipientListInvalidLockFile(t *testing.T) { + config := feerecipientListConfig{ + LockFilePath: "nonexistent-lock.json", + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + } + + err := runFeeRecipientList(t.Context(), config) + require.ErrorContains(t, err, "no such file or directory") +} + +func TestFeeRecipientListWithOverrides(t *testing.T) { + valAmt := 2 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, _, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + lockFile := filepath.Join(t.TempDir(), "cluster-lock.json") + require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) + + // Create a properly signed override with a newer timestamp for the first validator. + override := makeSignedOverride(t, lock, keyShares, 0, + bellatrix.ExecutionAddress{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x78}, + 99999, + time.Now().Add(time.Hour), + ) + + overridesJSON, err := json.Marshal([]*eth2api.VersionedSignedValidatorRegistration{override}) + require.NoError(t, err) + + overridesFile := filepath.Join(t.TempDir(), "overrides.json") + require.NoError(t, os.WriteFile(overridesFile, overridesJSON, 0o644)) + + config := feerecipientListConfig{ + LockFilePath: lockFile, + OverridesFilePath: overridesFile, + } + + require.NoError(t, runFeeRecipientList(t.Context(), config)) +} + +// makeSignedOverride creates a properly signed builder registration override for the validator at valIdx. +func makeSignedOverride( + t *testing.T, + lock cluster.Lock, + keyShares [][]tbls.PrivateKey, + valIdx int, + feeRecipient bellatrix.ExecutionAddress, + gasLimit uint64, + timestamp time.Time, +) *eth2api.VersionedSignedValidatorRegistration { + t.Helper() + + // Reconstruct root secret from shares. + sharesMap := make(map[int]tbls.PrivateKey) + for i, share := range keyShares[valIdx] { + sharesMap[i+1] = share + } + + rootSecret, err := tbls.RecoverSecret(sharesMap, uint(len(keyShares[valIdx])), uint(lock.Threshold)) + require.NoError(t, err) + + pubkey, err := tblsconv.PubkeyToETH2(tbls.PublicKey(lock.Validators[valIdx].PubKey)) + require.NoError(t, err) + + msg := ð2v1.ValidatorRegistration{ + FeeRecipient: feeRecipient, + GasLimit: gasLimit, + Timestamp: timestamp, + Pubkey: pubkey, + } + + forkVersion, err := eth2util.NetworkToForkVersionBytes(eth2util.Goerli.Name) + require.NoError(t, err) + + sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(forkVersion)) + require.NoError(t, err) + + sig, err := tbls.Sign(rootSecret, sigRoot[:]) + require.NoError(t, err) + + return ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: msg, + Signature: eth2p0.BLSSignature(sig), + }, + } +} + +func TestResolveLatestRegistrations(t *testing.T) { + valAmt := 2 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, _, _ := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + lockTimestamp := lock.Validators[0].BuilderRegistration.Message.Timestamp + normalizedPubkey := normalizePubkey(lock.Validators[0].PublicKeyHex()) + + t.Run("no overrides", func(t *testing.T) { + entries := resolveLatestRegistrations(lock, nil, nil) + require.Len(t, entries, 2) + + require.Equal(t, lock.Validators[0].PublicKeyHex(), entries[0].Pubkey) + require.Equal(t, "0x"+hex.EncodeToString(lock.Validators[0].BuilderRegistration.Message.FeeRecipient), entries[0].FeeRecipient) + require.Equal(t, uint64(lock.Validators[0].BuilderRegistration.Message.GasLimit), entries[0].GasLimit) + }) + + t.Run("override with newer timestamp wins", func(t *testing.T) { + overrides := map[string]registrationSource{ + normalizedPubkey: { + FeeRecipient: "0x0000000000000000000000000000000000005678", + GasLimit: 99999, + Timestamp: lockTimestamp.Add(time.Hour), + }, + } + + entries := resolveLatestRegistrations(lock, overrides, nil) + require.Len(t, entries, 2) + + require.Equal(t, "0x0000000000000000000000000000000000005678", entries[0].FeeRecipient) + require.Equal(t, uint64(99999), entries[0].GasLimit) + }) + + t.Run("override with older timestamp loses", func(t *testing.T) { + overrides := map[string]registrationSource{ + normalizedPubkey: { + FeeRecipient: "0x0000000000000000000000000000000000005678", + GasLimit: 99999, + Timestamp: lockTimestamp.Add(-time.Hour), + }, + } + + entries := resolveLatestRegistrations(lock, overrides, nil) + require.Len(t, entries, 2) + + // Should keep lock values. + require.Equal(t, "0x"+hex.EncodeToString(lock.Validators[0].BuilderRegistration.Message.FeeRecipient), entries[0].FeeRecipient) + require.Equal(t, uint64(lock.Validators[0].BuilderRegistration.Message.GasLimit), entries[0].GasLimit) + }) + + t.Run("pubkey filter", func(t *testing.T) { + filter := map[string]struct{}{ + normalizedPubkey: {}, + } + + entries := resolveLatestRegistrations(lock, nil, filter) + require.Len(t, entries, 1) + require.Equal(t, lock.Validators[0].PublicKeyHex(), entries[0].Pubkey) + }) +} + +func TestFeeRecipientListCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "correct flags", + expectedErr: "read cluster-lock.json: open test: no such file or directory", + flags: []string{ + "--lock-file=test", + "--overrides-file=test", + }, + }, + { + name: "correct flags with pubkeys", + expectedErr: "read cluster-lock.json: open test: no such file or directory", + flags: []string{ + "--lock-file=test", + "--overrides-file=test", + "--validator-public-keys=0x" + strings.Repeat("ab", 48), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newFeeRecipientCmd(newFeeRecipientListCmd(runFeeRecipientList)) + cmd.SetArgs(append([]string{"list"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 06bf1458a..c6915ba10 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -5,16 +5,14 @@ package cmd import ( "context" "encoding/hex" - "encoding/json" - "os" "strings" "time" - eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" 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/k1util" "github.com/obolnetwork/charon/app/log" @@ -192,7 +190,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") } - overrides, err := loadOverridesRegistrations(config.OverridesFilePath) + overrides, err := loadOverridesRegistrations(config.OverridesFilePath, cl.ForkVersion) if err != nil { return err } @@ -231,14 +229,14 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err ) } - log.Info(ctx, "Submitting partial builder registrations", z.Int("count", len(partialRegs))) + log.Info(ctx, "Submitting partial builder registrations", z.Int("total", len(partialRegs))) err = oAPI.PostPartialFeeRecipients(ctx, cl.LockHash, shareIdx, partialRegs) if err != nil { return errors.Wrap(err, "submit partial builder registrations to Obol API") } - log.Info(ctx, "Successfully submitted partial builder registrations", z.Int("count", len(partialRegs))) + log.Info(ctx, "Successfully submitted partial builder registrations", z.Int("total", len(partialRegs))) return nil } @@ -407,17 +405,10 @@ func resolveGasLimit(gasLimitOverride uint64, cl cluster.Lock, overrides map[str // loadOverridesRegistrations reads the builder registrations overrides file and returns // a map keyed by normalized (lowercase, no 0x prefix) validator pubkey hex. If the file // does not exist, an empty map is returned. -func loadOverridesRegistrations(path string) (map[string]eth2v1.ValidatorRegistration, error) { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return make(map[string]eth2v1.ValidatorRegistration), nil - } else if err != nil { - return nil, errors.Wrap(err, "read overrides file", z.Str("path", path)) - } - - var regs []*eth2api.VersionedSignedValidatorRegistration - if err := json.Unmarshal(data, ®s); err != nil { - return nil, errors.Wrap(err, "unmarshal overrides file", z.Str("path", path)) +func loadOverridesRegistrations(path string, forkVersion []byte) (map[string]eth2v1.ValidatorRegistration, error) { + regs, err := app.LoadBuilderRegistrationOverrides(path, eth2p0.Version(forkVersion)) + if err != nil { + return nil, err } result := make(map[string]eth2v1.ValidatorRegistration, len(regs)) diff --git a/cmd/run.go b/cmd/run.go index 235cbff80..9b348da2b 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -108,6 +108,7 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) { cmd.Flags().BoolVar(&config.GraffitiDisableClientAppend, "graffiti-disable-client-append", false, "Disables appending \"OB\" suffix to graffiti. Increases maximum bytes per graffiti to 32.") 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.") wrapPreRunE(cmd, func(cc *cobra.Command, _ []string) error { if len(config.BeaconNodeAddrs) == 0 && !config.SimnetBMock { diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index e5f3d9ee0..380cd0d9b 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -36,12 +36,12 @@ type delayFunc func(duty core.Duty, deadline time.Time) <-chan time.Time type schedSlotFunc func(ctx context.Context, slot core.Slot) // NewForT returns a new scheduler for testing using a fake clock. -func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRegistrations []*eth2api.VersionedSignedValidatorRegistration, +func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration, eth2Cl eth2wrap.Client, schedSlotFunc schedSlotFunc, builderEnabled bool, ) *Scheduler { t.Helper() - s, err := New(builderRegistrations, eth2Cl, builderEnabled) + s, err := New(builderRegistrationsFunc, eth2Cl, builderEnabled) require.NoError(t, err) s.clock = clock @@ -52,10 +52,10 @@ func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRe } // New returns a new scheduler. -func New(builderRegistrations []*eth2api.VersionedSignedValidatorRegistration, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) { +func New(builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) { return &Scheduler{ eth2Cl: eth2Cl, - builderRegistrations: builderRegistrations, + builderRegistrationsFunc: builderRegistrationsFunc, submittedRegistrationEpoch: math.MaxUint64, quit: make(chan struct{}), duties: make(map[core.Duty]core.DutyDefinitionSet), @@ -74,7 +74,7 @@ func New(builderRegistrations []*eth2api.VersionedSignedValidatorRegistration, e type Scheduler struct { eth2Cl eth2wrap.Client - builderRegistrations []*eth2api.VersionedSignedValidatorRegistration + builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration submittedRegistrationEpoch uint64 registrationMutex sync.Mutex quit chan struct{} @@ -878,12 +878,14 @@ func (s *Scheduler) submitValidatorRegistrations(ctx context.Context, epoch uint submitRegistrationCounter.Add(1) - err := s.eth2Cl.SubmitValidatorRegistrations(ctx, s.builderRegistrations) + regs := s.builderRegistrationsFunc() + + err := s.eth2Cl.SubmitValidatorRegistrations(ctx, regs) if err != nil { submitRegistrationErrors.Add(1) log.Error(ctx, "Failed to submit validator registrations", err, z.U64("epoch", epoch)) } else { - log.Info(ctx, "Submitted validator registrations", z.Int("count", len(s.builderRegistrations)), z.U64("epoch", epoch)) + log.Info(ctx, "Submitted validator registrations", z.Int("count", len(regs)), z.U64("epoch", epoch)) s.setSubmittedRegistrationEpoch(epoch) } } diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go index 454e4f683..abe7d90c4 100644 --- a/core/scheduler/scheduler_test.go +++ b/core/scheduler/scheduler_test.go @@ -77,7 +77,7 @@ func TestIntegration(t *testing.T) { }, } - s, err := scheduler.New(valRegs, eth2Cl, false) + s, err := scheduler.New(func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, false) require.NoError(t, err) count := 10 @@ -265,7 +265,7 @@ func TestSchedulerDuties(t *testing.T) { clock := newTestClock(t0) delayer := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, delayer.delay, valRegs, eth2Cl, nil, false) + sched := scheduler.NewForT(t, clock, delayer.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, nil, false) // Only test scheduler output for first N slots, so Stop scheduler (and slotTicker) after that. const stopAfter = 3 @@ -366,7 +366,7 @@ func TestScheduler_GetDuty(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, nil, false) + sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, nil, false) _, err = sched.GetDutyDefinition(ctx, core.NewAttesterDuty(slot)) require.ErrorContains(t, err, "epoch not resolved yet") @@ -495,7 +495,7 @@ func TestHandleChainReorgEvent(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, schedSlotFunc, false) + sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, schedSlotFunc, false) doneCh := make(chan error, 1) @@ -586,7 +586,7 @@ func TestSubmitValidatorRegistrations(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, schedSlotFunc, true) + sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, schedSlotFunc, true) doneCh := make(chan error, 1) diff --git a/docs/configuration.md b/docs/configuration.md index 559d6425c..f8fb10b63 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -189,6 +189,7 @@ Flags: --otlp-headers strings Comma separated list of headers formatted as header=value, to include in OTLP requests. --otlp-insecure Use insecure connection (no TLS) when connecting to OTLP endpoint. --otlp-service-name string Service name used for OTLP gRPC tracing. (default "charon") + --overrides-file string Path to the builder registrations overrides file. (default ".charon/builder_registrations_overrides.json") --p2p-disable-reuseport Disables TCP port reuse for outgoing libp2p connections. --p2p-external-hostname string The DNS hostname advertised by libp2p. This may be used to advertise an external DNS. --p2p-external-ip string The IP address advertised by libp2p. This may be used to advertise an external IP. diff --git a/go.mod b/go.mod index 88394b7ae..c2c394157 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/drand/kyber-bls12381 v0.3.4 github.com/ethereum/go-ethereum v1.17.1 github.com/ferranbt/fastssz v1.0.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/golang/snappy v1.0.0 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.6.0 @@ -116,7 +117,6 @@ require ( github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flynn/noise v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 // indirect github.com/getsentry/sentry-go v0.31.1 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect From c0a635e27553f65aefa7617b8ba0c1b96361d637 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Wed, 18 Mar 2026 12:30:11 +0300 Subject: [PATCH 2/3] refactoring --- app/app.go | 2 +- app/builderregistration.go | 37 +++++++++++++++---------- cmd/feerecipientlist.go | 39 +++++++++++++-------------- cmd/feerecipientlist_internal_test.go | 4 +-- core/scheduler/scheduler.go | 17 +++++++----- core/scheduler/scheduler_test.go | 19 +++++++++---- 6 files changed, 70 insertions(+), 48 deletions(-) diff --git a/app/app.go b/app/app.go index c223a447c..4def8b8de 100644 --- a/app/app.go +++ b/app/app.go @@ -491,7 +491,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, return core.NewDeadliner(ctx, label, deadlineFunc) } - sched, err := scheduler.New(builderRegSvc.Registrations, eth2Cl, conf.BuilderAPI) + sched, err := scheduler.New(builderRegSvc, eth2Cl, conf.BuilderAPI) if err != nil { return err } diff --git a/app/builderregistration.go b/app/builderregistration.go index ecc99ac68..96d0e4306 100644 --- a/app/builderregistration.go +++ b/app/builderregistration.go @@ -28,10 +28,20 @@ import ( const fileWatchDebounce = 500 * time.Millisecond -// BuilderRegistrationService manages builder registration state including -// runtime monitoring of the overrides file for changes. It provides thread-safe -// access to current registrations and fee recipient addresses. -type BuilderRegistrationService struct { +// BuilderRegistrationService provides thread-safe access to current builder +// registrations and fee recipient addresses with runtime override support. +type BuilderRegistrationService interface { + // Registrations returns the current effective builder registrations. + 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(ctx context.Context) +} + +// builderRegistrationService implements BuilderRegistrationService. +type builderRegistrationService struct { mu sync.RWMutex path string forkVersion eth2p0.Version @@ -49,8 +59,8 @@ func NewBuilderRegistrationService( forkVersion eth2p0.Version, baseRegistrations []*eth2api.VersionedSignedValidatorRegistration, baseFeeRecipients map[core.PubKey]string, -) (*BuilderRegistrationService, error) { - svc := &BuilderRegistrationService{ +) (BuilderRegistrationService, error) { + svc := &builderRegistrationService{ path: path, forkVersion: forkVersion, baseRegistrations: baseRegistrations, @@ -66,7 +76,7 @@ func NewBuilderRegistrationService( } // Registrations returns the current effective builder registrations. -func (s *BuilderRegistrationService) Registrations() []*eth2api.VersionedSignedValidatorRegistration { +func (s *builderRegistrationService) Registrations() []*eth2api.VersionedSignedValidatorRegistration { s.mu.RLock() defer s.mu.RUnlock() @@ -74,7 +84,7 @@ func (s *BuilderRegistrationService) Registrations() []*eth2api.VersionedSignedV } // FeeRecipient returns the current fee recipient address for the given pubkey. -func (s *BuilderRegistrationService) FeeRecipient(pubkey core.PubKey) string { +func (s *builderRegistrationService) FeeRecipient(pubkey core.PubKey) string { s.mu.RLock() defer s.mu.RUnlock() @@ -83,7 +93,7 @@ func (s *BuilderRegistrationService) FeeRecipient(pubkey core.PubKey) string { // Run watches the overrides file for changes and reloads when modified. // It blocks until ctx is cancelled. -func (s *BuilderRegistrationService) Run(ctx context.Context) { +func (s *builderRegistrationService) Run(ctx context.Context) { if s.path == "" { return } @@ -129,6 +139,8 @@ func (s *BuilderRegistrationService) Run(ctx context.Context) { case <-debounce: if err := s.reload(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)) } debounce = nil @@ -143,7 +155,7 @@ func (s *BuilderRegistrationService) Run(ctx context.Context) { } // reload reads the overrides file and re-applies overrides against base state. -func (s *BuilderRegistrationService) reload(ctx context.Context) error { +func (s *builderRegistrationService) reload(ctx context.Context) error { feeRecipients := maps.Clone(s.baseFeeRecipients) if s.path == "" { @@ -282,10 +294,7 @@ func applyBuilderRegistrationOverrides( feeRecipientByPubkey[corePubkey] = "0x" + hex.EncodeToString(override.V1.Message.FeeRecipient[:]) - log.Info(ctx, "Applied builder registration override", - z.Str("pubkey", "0x"+key), - z.Str("fee_recipient", feeRecipientByPubkey[corePubkey]), - ) + log.Info(ctx, "Applied builder registration override for 0x"+key, z.Str("fee_recipient", feeRecipientByPubkey[corePubkey])) } return result diff --git a/cmd/feerecipientlist.go b/cmd/feerecipientlist.go index 14bb97117..300ef8e35 100644 --- a/cmd/feerecipientlist.go +++ b/cmd/feerecipientlist.go @@ -5,6 +5,7 @@ package cmd import ( "context" "encoding/hex" + "slices" "strings" "time" @@ -21,7 +22,6 @@ type feerecipientListConfig struct { ValidatorPublicKeys []string LockFilePath string OverridesFilePath string - Log log.Config } func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig) error) *cobra.Command { @@ -29,8 +29,8 @@ func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig cmd := &cobra.Command{ Use: "list", - Short: "List latest builder registration data per validator.", - Long: "Lists the latest builder registration data for each validator, picking the entry with the highest timestamp from the cluster lock or overrides file.", + Short: "Display the latest builder registration details for each validator.", + Long: "Displays the most recent builder registration for each validator, selecting the entry with the highest timestamp from either the cluster lock file or the overrides file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) @@ -49,12 +49,12 @@ type registrationEntry struct { Pubkey string FeeRecipient string GasLimit uint64 - Timestamp int64 + Timestamp time.Time } // resolveLatestRegistrations returns the latest builder registration for each validator // by comparing timestamps from the cluster lock and overrides file. -func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrationSource, pubkeyFilter map[string]struct{}) []registrationEntry { +func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrationEntry, pubkeyFilter map[string]struct{}) []registrationEntry { var entries []registrationEntry for _, dv := range cl.Validators { @@ -83,7 +83,7 @@ func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrati Pubkey: pubkeyHex, FeeRecipient: feeRecipient, GasLimit: gasLimit, - Timestamp: timestamp.Unix(), + Timestamp: timestamp, }) } @@ -102,7 +102,7 @@ func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) err } } - overrides, err := loadOverridesAsSource(config.OverridesFilePath, cl.ForkVersion) + overrides, err := loadOverrides(config.OverridesFilePath, cl.ForkVersion) if err != nil { return err } @@ -115,39 +115,38 @@ func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) err entries := resolveLatestRegistrations(*cl, overrides, pubkeyFilter) if len(entries) == 0 { - log.Info(ctx, "No builder registrations found") + log.Info(ctx, "No builder registrations found", nil) + return nil } + // Organize output by fee recipient for better readability, using stable sort to maintain original order where fee recipients are the same. + slices.SortStableFunc(entries, func(a, b registrationEntry) int { + return strings.Compare(a.FeeRecipient, b.FeeRecipient) + }) + log.Info(ctx, "Builder registrations", z.Int("total", len(entries))) for _, e := range entries { log.Info(ctx, "Builder registration for "+e.Pubkey, z.Str("fee_recipient", e.FeeRecipient), z.U64("gas_limit", e.GasLimit), - z.I64("timestamp", e.Timestamp), + z.I64("timestamp", e.Timestamp.Unix()), ) } return nil } -// registrationSource holds fee recipient data extracted from overrides file. -type registrationSource struct { - FeeRecipient string - GasLimit uint64 - Timestamp time.Time -} - -// loadOverridesAsSource reads the builder registrations overrides file and returns +// loadOverrides reads the builder registrations overrides file and returns // a map keyed by normalized validator pubkey hex with the registration details needed for comparison. -func loadOverridesAsSource(path string, forkVersion []byte) (map[string]registrationSource, error) { +func loadOverrides(path string, forkVersion []byte) (map[string]registrationEntry, error) { regs, err := app.LoadBuilderRegistrationOverrides(path, eth2p0.Version(forkVersion)) if err != nil { return nil, err } - result := make(map[string]registrationSource, len(regs)) + result := make(map[string]registrationEntry, len(regs)) for _, reg := range regs { if reg == nil || reg.V1 == nil || reg.V1.Message == nil { @@ -155,7 +154,7 @@ func loadOverridesAsSource(path string, forkVersion []byte) (map[string]registra } key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) - result[key] = registrationSource{ + result[key] = registrationEntry{ FeeRecipient: reg.V1.Message.FeeRecipient.String(), GasLimit: reg.V1.Message.GasLimit, Timestamp: reg.V1.Message.Timestamp, diff --git a/cmd/feerecipientlist_internal_test.go b/cmd/feerecipientlist_internal_test.go index b5e5b7ac0..702bf4b22 100644 --- a/cmd/feerecipientlist_internal_test.go +++ b/cmd/feerecipientlist_internal_test.go @@ -210,7 +210,7 @@ func TestResolveLatestRegistrations(t *testing.T) { }) t.Run("override with newer timestamp wins", func(t *testing.T) { - overrides := map[string]registrationSource{ + overrides := map[string]registrationEntry{ normalizedPubkey: { FeeRecipient: "0x0000000000000000000000000000000000005678", GasLimit: 99999, @@ -226,7 +226,7 @@ func TestResolveLatestRegistrations(t *testing.T) { }) t.Run("override with older timestamp loses", func(t *testing.T) { - overrides := map[string]registrationSource{ + overrides := map[string]registrationEntry{ normalizedPubkey: { FeeRecipient: "0x0000000000000000000000000000000000005678", GasLimit: 99999, diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 380cd0d9b..f03496a98 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -28,6 +28,11 @@ import ( const trimEpochOffset = 3 // Trim cached duties after 3 epochs. Note inclusion delay calculation requires now-32 slot duties. +// BuilderRegistrationProvider provides access to current builder registrations. +type BuilderRegistrationProvider interface { + Registrations() []*eth2api.VersionedSignedValidatorRegistration +} + // delayFunc abstracts slot offset delaying/sleeping for deterministic tests. type delayFunc func(duty core.Duty, deadline time.Time) <-chan time.Time @@ -36,12 +41,12 @@ type delayFunc func(duty core.Duty, deadline time.Time) <-chan time.Time type schedSlotFunc func(ctx context.Context, slot core.Slot) // NewForT returns a new scheduler for testing using a fake clock. -func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration, +func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRegProvider BuilderRegistrationProvider, eth2Cl eth2wrap.Client, schedSlotFunc schedSlotFunc, builderEnabled bool, ) *Scheduler { t.Helper() - s, err := New(builderRegistrationsFunc, eth2Cl, builderEnabled) + s, err := New(builderRegProvider, eth2Cl, builderEnabled) require.NoError(t, err) s.clock = clock @@ -52,10 +57,10 @@ func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRe } // New returns a new scheduler. -func New(builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) { +func New(builderRegProvider BuilderRegistrationProvider, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) { return &Scheduler{ eth2Cl: eth2Cl, - builderRegistrationsFunc: builderRegistrationsFunc, + builderRegProvider: builderRegProvider, submittedRegistrationEpoch: math.MaxUint64, quit: make(chan struct{}), duties: make(map[core.Duty]core.DutyDefinitionSet), @@ -74,7 +79,7 @@ func New(builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegi type Scheduler struct { eth2Cl eth2wrap.Client - builderRegistrationsFunc func() []*eth2api.VersionedSignedValidatorRegistration + builderRegProvider BuilderRegistrationProvider submittedRegistrationEpoch uint64 registrationMutex sync.Mutex quit chan struct{} @@ -878,7 +883,7 @@ func (s *Scheduler) submitValidatorRegistrations(ctx context.Context, epoch uint submitRegistrationCounter.Add(1) - regs := s.builderRegistrationsFunc() + regs := s.builderRegProvider.Registrations() err := s.eth2Cl.SubmitValidatorRegistrations(ctx, regs) if err != nil { diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go index abe7d90c4..c8e875eea 100644 --- a/core/scheduler/scheduler_test.go +++ b/core/scheduler/scheduler_test.go @@ -32,6 +32,15 @@ import ( var integration = flag.Bool("integration", false, "enable integration test, requires BEACON_URL vars.") +// stubRegProvider implements scheduler.BuilderRegistrationProvider for tests. +type stubRegProvider struct { + regs []*eth2api.VersionedSignedValidatorRegistration +} + +func (s *stubRegProvider) Registrations() []*eth2api.VersionedSignedValidatorRegistration { + return s.regs +} + // TestIntegration runs an integration test for the Scheduler. // It expects the above flag to enabled and a BEACON_URL env var. // It then generates a fake manifest with actual mainnet validators @@ -77,7 +86,7 @@ func TestIntegration(t *testing.T) { }, } - s, err := scheduler.New(func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, false) + s, err := scheduler.New(&stubRegProvider{regs: valRegs}, eth2Cl, false) require.NoError(t, err) count := 10 @@ -265,7 +274,7 @@ func TestSchedulerDuties(t *testing.T) { clock := newTestClock(t0) delayer := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, delayer.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, nil, false) + sched := scheduler.NewForT(t, clock, delayer.delay, &stubRegProvider{regs: valRegs}, eth2Cl, nil, false) // Only test scheduler output for first N slots, so Stop scheduler (and slotTicker) after that. const stopAfter = 3 @@ -366,7 +375,7 @@ func TestScheduler_GetDuty(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, nil, false) + sched := scheduler.NewForT(t, clock, dd.delay, &stubRegProvider{regs: valRegs}, eth2Cl, nil, false) _, err = sched.GetDutyDefinition(ctx, core.NewAttesterDuty(slot)) require.ErrorContains(t, err, "epoch not resolved yet") @@ -495,7 +504,7 @@ func TestHandleChainReorgEvent(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, schedSlotFunc, false) + sched := scheduler.NewForT(t, clock, dd.delay, &stubRegProvider{regs: valRegs}, eth2Cl, schedSlotFunc, false) doneCh := make(chan error, 1) @@ -586,7 +595,7 @@ func TestSubmitValidatorRegistrations(t *testing.T) { clock := newTestClock(t0) dd := new(delayer) valRegs := beaconmock.BuilderRegistrationSetA - sched := scheduler.NewForT(t, clock, dd.delay, func() []*eth2api.VersionedSignedValidatorRegistration { return valRegs }, eth2Cl, schedSlotFunc, true) + sched := scheduler.NewForT(t, clock, dd.delay, &stubRegProvider{regs: valRegs}, eth2Cl, schedSlotFunc, true) doneCh := make(chan error, 1) From 10ddee4feca4fb7934b1be3a7ec3c94cef4f8103 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Wed, 18 Mar 2026 19:54:07 +0300 Subject: [PATCH 3/3] refactroing --- app/builderregistration.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/builderregistration.go b/app/builderregistration.go index 96d0e4306..21239cf81 100644 --- a/app/builderregistration.go +++ b/app/builderregistration.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "sync" - "time" eth2api "github.com/attestantio/go-eth2-client/api" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -26,8 +25,6 @@ import ( "github.com/obolnetwork/charon/tbls/tblsconv" ) -const fileWatchDebounce = 500 * time.Millisecond - // BuilderRegistrationService provides thread-safe access to current builder // registrations and fee recipient addresses with runtime override support. type BuilderRegistrationService interface { @@ -115,8 +112,6 @@ func (s *builderRegistrationService) Run(ctx context.Context) { baseName := filepath.Base(s.path) - var debounce <-chan time.Time - for { select { case <-ctx.Done(): @@ -134,16 +129,11 @@ func (s *builderRegistrationService) Run(ctx context.Context) { continue } - // Debounce rapid events (editors may write multiple times). - debounce = time.After(fileWatchDebounce) - case <-debounce: if err := s.reload(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)) } - - debounce = nil case err, ok := <-watcher.Errors: if !ok { return