diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/server.go b/beacon-chain/rpc/prysm/v1alpha1/validator/server.go index 64c9c54c4b9b..f0073ac274c8 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/server.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/server.go @@ -97,7 +97,7 @@ type Server struct { lastBidSlot primitives.Slot lastBidSource bidSource // Guarded by lastBidLock, set during Gloas block build, read when proposing. lastBidBuilderURL string // Guarded by lastBidLock, winning Builder-API URL for lastBidSlot. - maxExecutionPayments sync.Map // validator pubkey [48]byte -> max execution payment (Gwei uint64). + maxExecutionPayments sync.Map // pubkey [48]byte -> Gwei uint64, min-collapsed per-entry caps; TODO(gloas): re-key per builder identity with beacon-APIs #630. } // Deprecated: The gRPC API will remain the default and fully supported through v8 (expected in 2026) but will be eventually removed in favor of REST API. diff --git a/changelog/james-prysm_keymanager-builders-endpoints.md b/changelog/james-prysm_keymanager-builders-endpoints.md new file mode 100644 index 000000000000..e67ad257bce9 --- /dev/null +++ b/changelog/james-prysm_keymanager-builders-endpoints.md @@ -0,0 +1,25 @@ +### Added + +- Add `GET`/`POST`/`DELETE /eth/v1/validator/{pubkey}/builders` keymanager endpoints for per-key builder configuration (keymanager-APIs #88). The endpoints respond 501 on networks without a scheduled gloas fork, where builder configuration cannot take effect. + +### Changed + +- Add v2 proposer settings (`"version": 2`) for configuring gloas builders; see the keymanager-APIs specification for the schema. +- Proposer settings sources without a `version` are treated as version 2 when they contain v2 builder fields. +- In v2 proposer settings, a nonempty `builders` list opts a key into mev-boost registration before the gloas fork, and an explicit empty list opts it out. +- v1 builder settings are not migrated to v2: at the gloas fork fee recipients and graffiti carry over, while v1 builder content — including its gas limits — is dropped and replaced with defaults, with a warning. Gas limits apply post-fork only when explicitly set at the option level, so validators follow future chain-default gas limit increases unless they opt out. +- The gas-limit keymanager API writes the option-level gas limit and no longer requires an enabled builder; deleting a gas limit unsets it (following the chain default) instead of pinning the current default value. Builder registration resolves fee recipients and participation independently, so a key with an enabled builder and only a default fee recipient now registers. +- Setting a fee recipient, gas limit, or graffiti no longer snapshots `default_config`'s builder settings onto that key; the key keeps following the default as it changes. + +### Deprecated + +- `--with-builder` generates legacy (pre-gloas) mev-boost builder settings, which are discontinued at the gloas fork; the command now warns when the flag is used. +- `--enable-builder` and `--suggested-gas-limit` produce only legacy (pre-gloas) content: they still drive mev-boost registrations before the fork, but never override v2 proposer settings or the gas limit schedule, and warn that they have no effect after gloas. An explicitly configured gas limit below the scheduled network gas limit is honored with a once-per-epoch warning. + +### Fixed + +- Fix the minimal slashing protection database decoding unset builder settings fields as explicit zero values; both validator DB backends now read the same stored settings identically. + +### Removed + +- Remove the unused `relays` field from the builder config; a settings file that still contains it is unaffected. diff --git a/cmd/prysmctl/validator/cmd.go b/cmd/prysmctl/validator/cmd.go index 635a71cc4a33..3a24809ff534 100644 --- a/cmd/prysmctl/validator/cmd.go +++ b/cmd/prysmctl/validator/cmd.go @@ -55,7 +55,8 @@ var ( WithBuilderFlag = &cli.BoolFlag{ Name: "with-builder", Aliases: []string{"wb"}, - Usage: "adds default builder options to proposer settings output, used for enabling mev-boost and relays", + Usage: "adds legacy (pre-gloas) mev-boost builder options to the proposer settings output; " + + "these are discontinued at the gloas fork, where builder participation requires v2 settings with a builders list", } DefaultFeeRecipientFlag = &cli.StringFlag{ diff --git a/cmd/prysmctl/validator/proposer_settings.go b/cmd/prysmctl/validator/proposer_settings.go index 0a97481eb9a5..487adcef3c8e 100644 --- a/cmd/prysmctl/validator/proposer_settings.go +++ b/cmd/prysmctl/validator/proposer_settings.go @@ -69,6 +69,7 @@ func getProposerSettings(c *cli.Context, r io.Reader) error { log.Infof("The default fee recipient is set to %s", defaultFeeRecipient) var builderSettings *validatorpb.BuilderConfig if c.Bool(WithBuilderFlag.Name) { + log.Warnf("--%s generates legacy (pre-gloas) mev-boost settings; they are discontinued at the gloas fork. Gloas builder participation requires v2 proposer settings with a builders list.", WithBuilderFlag.Name) builderSettings = &validatorpb.BuilderConfig{ Enabled: true, GasLimit: validatorType.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), diff --git a/config/proposer/BUILD.bazel b/config/proposer/BUILD.bazel index ce5651978108..caef8b0a4562 100644 --- a/config/proposer/BUILD.bazel +++ b/config/proposer/BUILD.bazel @@ -25,13 +25,17 @@ go_library( go_test( name = "go_default_test", - srcs = ["settings_test.go"], + srcs = [ + "effective_builder_config_test.go", + "settings_test.go", + ], embed = [":go_default_library"], deps = [ "//config/fieldparams:go_default_library", "//config/params:go_default_library", "//consensus-types/validator:go_default_library", "//encoding/bytesutil:go_default_library", + "//proto/prysm/v1alpha1/validator-client:go_default_library", "//testing/assert:go_default_library", "//testing/require:go_default_library", "@com_github_ethereum_go_ethereum//common:go_default_library", diff --git a/config/proposer/effective_builder_config_test.go b/config/proposer/effective_builder_config_test.go new file mode 100644 index 000000000000..b27dd891925b --- /dev/null +++ b/config/proposer/effective_builder_config_test.go @@ -0,0 +1,98 @@ +package proposer + +import ( + "testing" + + "github.com/OffchainLabs/prysm/v7/consensus-types/validator" + "github.com/OffchainLabs/prysm/v7/testing/require" +) + +// These cases mirror the test vectors proposed upstream on keymanager-APIs #87 +// for builder-config inheritance granularity. +func TestEffectiveBuilderConfig(t *testing.T) { + entryA := &BuilderEntry{URL: "https://a"} + entryB := &BuilderEntry{URL: "https://b"} + entryC := &BuilderEntry{URL: "https://c"} + + t.Run("nil per-key returns default", func(t *testing.T) { + def := &BuilderConfig{Enabled: true} + require.Equal(t, def, effectiveBuilderConfig(nil, def)) + }) + t.Run("nil default returns per-key", func(t *testing.T) { + perKey := &BuilderConfig{Enabled: true} + require.Equal(t, perKey, effectiveBuilderConfig(perKey, nil)) + }) + t.Run("min_bid inherits when per-key omits it", func(t *testing.T) { + def := &BuilderConfig{Enabled: true, Builders: []*BuilderEntry{entryA, entryB}, MinBid: uint64ValPtr(5000000)} + perKey := &BuilderConfig{Enabled: true, Builders: []*BuilderEntry{entryC}} + eff := effectiveBuilderConfig(perKey, def) + require.NotNil(t, eff.MinBid) + require.Equal(t, validator.Uint64(5000000), *eff.MinBid) + require.Equal(t, 1, len(eff.Builders)) + require.Equal(t, "https://c", eff.Builders[0].URL) + }) + t.Run("explicit zero max payment is preserved, not inherited over", func(t *testing.T) { + def := &BuilderConfig{MaxExecutionPayment: uint64ValPtr(1000000000)} + perKey := &BuilderConfig{Enabled: true, MaxExecutionPayment: uint64ValPtr(0)} + eff := effectiveBuilderConfig(perKey, def) + require.NotNil(t, eff.MaxExecutionPayment) + require.Equal(t, validator.Uint64(0), *eff.MaxExecutionPayment) + }) + t.Run("unset max payment inherits default", func(t *testing.T) { + def := &BuilderConfig{MaxExecutionPayment: uint64ValPtr(1000000000)} + perKey := &BuilderConfig{Enabled: true} + eff := effectiveBuilderConfig(perKey, def) + require.NotNil(t, eff.MaxExecutionPayment) + require.Equal(t, validator.Uint64(1000000000), *eff.MaxExecutionPayment) + }) + t.Run("explicit per-key disable wins over enabled default", func(t *testing.T) { + def := &BuilderConfig{Enabled: true} + perKey := &BuilderConfig{Enabled: false, MinBid: uint64ValPtr(1)} + require.Equal(t, false, effectiveBuilderConfig(perKey, def).IsEnabled()) + }) + t.Run("present per-key builder config is authoritative on enabled", func(t *testing.T) { + // A per-key config with enabled false does not inherit an enabled default; + // whole-config inheritance happens only when the per-key builder config is nil. + def := &BuilderConfig{Enabled: true} + perKey := &BuilderConfig{MinBid: uint64ValPtr(1)} + require.Equal(t, false, effectiveBuilderConfig(perKey, def).IsEnabled()) + require.Equal(t, true, effectiveBuilderConfig(nil, def).IsEnabled()) + }) + t.Run("present builders list replaces, never unions", func(t *testing.T) { + def := &BuilderConfig{Builders: []*BuilderEntry{entryA, entryB}} + perKey := &BuilderConfig{Builders: []*BuilderEntry{entryC}} + eff := effectiveBuilderConfig(perKey, def) + require.Equal(t, 1, len(eff.Builders)) + require.Equal(t, "https://c", eff.Builders[0].URL) + }) + t.Run("absent builders list inherits default list", func(t *testing.T) { + def := &BuilderConfig{Builders: []*BuilderEntry{entryA, entryB}} + perKey := &BuilderConfig{Enabled: true} + require.Equal(t, 2, len(effectiveBuilderConfig(perKey, def).Builders)) + }) + t.Run("zero gas limit inherits default gas limit", func(t *testing.T) { + def := &BuilderConfig{GasLimit: validator.Uint64(30000000)} + perKey := &BuilderConfig{Enabled: true} + require.Equal(t, validator.Uint64(30000000), effectiveBuilderConfig(perKey, def).GasLimit) + }) + t.Run("boost factor inherits per field", func(t *testing.T) { + def := &BuilderConfig{MinBid: uint64ValPtr(5000000), BuilderBoostFactor: uint64ValPtr(90)} + perKey := &BuilderConfig{Enabled: true, BuilderBoostFactor: uint64ValPtr(120)} + eff := effectiveBuilderConfig(perKey, def) + require.Equal(t, validator.Uint64(5000000), *eff.MinBid) + require.Equal(t, validator.Uint64(120), *eff.BuilderBoostFactor) + }) + t.Run("both-set min_bid: per-key wins", func(t *testing.T) { + def := &BuilderConfig{MinBid: uint64ValPtr(5000000)} + perKey := &BuilderConfig{MinBid: uint64ValPtr(7000000)} + require.Equal(t, validator.Uint64(7000000), *effectiveBuilderConfig(perKey, def).MinBid) + }) + t.Run("nonzero per-key gas limit wins over default", func(t *testing.T) { + def := &BuilderConfig{GasLimit: validator.Uint64(30000000)} + perKey := &BuilderConfig{GasLimit: validator.Uint64(45000000)} + require.Equal(t, validator.Uint64(45000000), effectiveBuilderConfig(perKey, def).GasLimit) + }) + t.Run("nil nil is nil", func(t *testing.T) { + require.Equal(t, (*BuilderConfig)(nil), effectiveBuilderConfig(nil, nil)) + }) +} diff --git a/config/proposer/loader/loader.go b/config/proposer/loader/loader.go index faebffc66e97..05577b96b212 100644 --- a/config/proposer/loader/loader.go +++ b/config/proposer/loader/loader.go @@ -1,7 +1,6 @@ package loader import ( - "encoding/json" "fmt" "strconv" @@ -135,13 +134,9 @@ func (psl *SettingsLoader) Load(cliCtx *cli.Context) (*proposer.Settings, error) return nil, err } dbSettings = dbps.ToConsensus() - log.Debugf("DB loaded proposer settings: %s", func() string { - b, err := json.Marshal(dbSettings) - if err != nil { - return err.Error() - } - return string(b) - }()) + log.WithField("version", dbSettings.Version). + WithField("proposerConfigCount", len(dbSettings.ProposerConfig)). + Debug("Loaded proposer settings from DB") } // start to process based on load method @@ -189,18 +184,6 @@ func (psl *SettingsLoader) Load(cliCtx *cli.Context) (*proposer.Settings, error) return ps, nil } -func hasBuilderShape(p *validatorpb.ProposerSettingsPayload) bool { - if p.DefaultConfig != nil && p.DefaultConfig.Builder != nil { - return true - } - for _, o := range p.ProposerConfig { - if o != nil && o.Builder != nil { - return true - } - } - return false -} - func (psl *SettingsLoader) applyOverrides() { if psl.options.builderConfig != nil && psl.options.gasLimit != nil { psl.options.builderConfig.GasLimit = *psl.options.gasLimit @@ -234,6 +217,8 @@ func (psl *SettingsLoader) loadFromFile(cliCtx *cli.Context, dbSettings *validat if settingFromFile == nil { return nil, errors.Errorf("proposer settings is empty after unmarshalling from file specified by %s flag", flags.ProposerSettingsFlag.Name) } + markExplicitEmptyBuilders(settingFromFile) + inferSchemaVersion(settingFromFile) log.WithField(flags.ProposerSettingsFlag.Name, cliCtx.String(flags.ProposerSettingsFlag.Name)).Info("Proposer settings loaded from file") return psl.processProposerSettings(settingFromFile, dbSettings), nil } @@ -246,6 +231,8 @@ func (psl *SettingsLoader) loadFromURL(cliCtx *cli.Context, dbSettings *validato if settingFromURL == nil { return nil, errors.Errorf("proposer settings is empty after unmarshalling from url specified by %s flag", flags.ProposerSettingsURLFlag.Name) } + markExplicitEmptyBuilders(settingFromURL) + inferSchemaVersion(settingFromURL) log.WithField(flags.ProposerSettingsURLFlag.Name, cliCtx.String(flags.ProposerSettingsURLFlag.Name)).Infof("Proposer settings loaded from URL") return psl.processProposerSettings(settingFromURL, dbSettings), nil } @@ -274,8 +261,7 @@ func mergeProposerSettings(loaded, db *validatorpb.ProposerSettingsPayload, opti if db != nil { merged.Version = db.Version } - // v1-shaped source content must not inherit DB's v2, else the runtime upgrade is skipped. - if loaded != nil && (loaded.Version != 0 || hasBuilderShape(loaded)) { + if loaded != nil && loaded.Version > merged.Version { merged.Version = loaded.Version } @@ -289,11 +275,68 @@ func mergeProposerSettings(loaded, db *validatorpb.ProposerSettingsPayload, opti } if merged.Version == proposer.SchemaV2 { - return mergeProposerSettingsV2(merged, loaded, db, gasLimitOnly) + return mergeProposerSettingsV2(merged, loaded, db, builderConfig, gasLimitOnly) } return mergeProposerSettingsV1(merged, loaded, db, builderConfig, gasLimitOnly) } +// markExplicitEmptyBuilders stamps the persistence marker for a user source's +// explicit "builders": [] (opt-out), which yaml keeps distinct from absent. +func markExplicitEmptyBuilders(p *validatorpb.ProposerSettingsPayload) { + mark := func(opt *validatorpb.ProposerOptionPayload) { + if opt == nil || opt.Builder == nil { + return + } + if opt.Builder.Builders != nil { + opt.Builder.BuildersSet = true + } + } + mark(p.DefaultConfig) + for _, opt := range p.ProposerConfig { + mark(opt) + } +} + +// inferSchemaVersion stamps version 2 on an unversioned source carrying v2-only +// builder fields, so a forgotten "version" cannot get gloas content dropped as v1. +func inferSchemaVersion(p *validatorpb.ProposerSettingsPayload) { + if p.Version != proposer.SchemaV1Unset { + return + } + hasV2 := func(opt *validatorpb.ProposerOptionPayload) bool { + if opt == nil || opt.Builder == nil { + return false + } + b := opt.Builder + return len(b.Builders) > 0 || b.BuildersSet || b.MinBid != nil || + b.BuilderBoostFactor != nil || b.MaxExecutionPayment != nil + } + found := hasV2(p.DefaultConfig) + for _, opt := range p.ProposerConfig { + if found { + break + } + found = hasV2(opt) + } + if !found { + return + } + p.Version = proposer.SchemaV2 + log.Info("Proposer settings contain v2 builder fields but no version; treating the source as version 2") +} + +// selectProposerConfig keeps the pre-v2 source precedence: a loaded per-key +// section replaces the DB's entirely, so restarting with a file resets the DB. +func selectProposerConfig(db, loaded *validatorpb.ProposerSettingsPayload) map[string]*validatorpb.ProposerOptionPayload { + if loaded != nil && len(loaded.ProposerConfig) > 0 { + return loaded.ProposerConfig + } + if db != nil && len(db.ProposerConfig) > 0 { + return db.ProposerConfig + } + return nil +} + func mergeProposerSettingsV1(merged, loaded, db *validatorpb.ProposerSettingsPayload, builderConfig *validatorpb.BuilderConfig, gasLimitOnly *validator.Uint64) *validatorpb.ProposerSettingsPayload { stripDBBuilder := builderConfig == nil @@ -307,17 +350,12 @@ func mergeProposerSettingsV1(merged, loaded, db *validatorpb.ProposerSettingsPay merged.DefaultConfig = loaded.DefaultConfig } - if db != nil && len(db.ProposerConfig) > 0 { - merged.ProposerConfig = db.ProposerConfig - if stripDBBuilder { - for _, option := range db.ProposerConfig { - option.Builder = nil - } + if db != nil && stripDBBuilder { + for _, option := range db.ProposerConfig { + option.Builder = nil } } - if loaded != nil && len(loaded.ProposerConfig) > 0 { - merged.ProposerConfig = loaded.ProposerConfig - } + merged.ProposerConfig = selectProposerConfig(db, loaded) if merged.DefaultConfig != nil { merged.DefaultConfig.Builder = processBuilderConfig(merged.DefaultConfig.Builder, builderConfig, gasLimitOnly) @@ -334,39 +372,46 @@ func mergeProposerSettingsV1(merged, loaded, db *validatorpb.ProposerSettingsPay merged.DefaultConfig = &validatorpb.ProposerOptionPayload{Builder: builderConfig} case gasLimitOnly != nil: merged.DefaultConfig = &validatorpb.ProposerOptionPayload{ - Builder: &validatorpb.BuilderConfig{Enabled: false, GasLimit: *gasLimitOnly}, + Builder: &validatorpb.BuilderConfig{GasLimit: *gasLimitOnly}, } } } return merged } -func mergeProposerSettingsV2(merged, loaded, db *validatorpb.ProposerSettingsPayload, gasLimitOnly *validator.Uint64) *validatorpb.ProposerSettingsPayload { +func mergeProposerSettingsV2(merged, loaded, db *validatorpb.ProposerSettingsPayload, builderConfig *validatorpb.BuilderConfig, gasLimitOnly *validator.Uint64) *validatorpb.ProposerSettingsPayload { if db != nil && db.DefaultConfig != nil { merged.DefaultConfig = db.DefaultConfig } if loaded != nil && loaded.DefaultConfig != nil { merged.DefaultConfig = loaded.DefaultConfig } - if db != nil && len(db.ProposerConfig) > 0 { - merged.ProposerConfig = db.ProposerConfig - } - if loaded != nil && len(loaded.ProposerConfig) > 0 { - merged.ProposerConfig = loaded.ProposerConfig - } + merged.ProposerConfig = selectProposerConfig(db, loaded) - if gasLimitOnly == nil { - return merged - } - if merged.DefaultConfig == nil { - merged.DefaultConfig = &validatorpb.ProposerOptionPayload{GasLimit: *gasLimitOnly} - } else { - merged.DefaultConfig.GasLimit = *gasLimitOnly + // --enable-builder is legacy content: it still forces the default mev-boost + // toggle on for pre-gloas registrations, and is inert from the fork onward. + if builderConfig != nil { + if merged.DefaultConfig == nil { + merged.DefaultConfig = &validatorpb.ProposerOptionPayload{} + } + if merged.DefaultConfig.Builder == nil { + merged.DefaultConfig.Builder = &validatorpb.BuilderConfig{} + } + merged.DefaultConfig.Builder.Enabled = true + log.Warnf("--%s is legacy (pre-gloas) mev-boost content and has no effect after the gloas fork; configure builders via the settings source or keymanager API", flags.EnableBuilderFlag.Name) } - for _, option := range merged.ProposerConfig { - if option != nil { - option.GasLimit = *gasLimitOnly + + // --suggested-gas-limit is likewise legacy content: it applies to the + // pre-gloas builder gas limit and never overrides v2 or schedule values. + if gasLimitOnly != nil { + if merged.DefaultConfig == nil { + merged.DefaultConfig = &validatorpb.ProposerOptionPayload{} + } + if merged.DefaultConfig.Builder == nil { + merged.DefaultConfig.Builder = &validatorpb.BuilderConfig{} } + merged.DefaultConfig.Builder.GasLimit = *gasLimitOnly + log.Warnf("--%s is legacy (pre-gloas) content and has no effect after the gloas fork; set gas limits in v2 proposer settings or via the keymanager API", flags.BuilderGasLimitFlag.Name) } return merged } @@ -386,7 +431,7 @@ func processBuilderConfig(current *validatorpb.BuilderConfig, override *validato return override } if gasLimitOnly != nil { - return &validatorpb.BuilderConfig{Enabled: false, GasLimit: *gasLimitOnly} + return &validatorpb.BuilderConfig{GasLimit: *gasLimitOnly} } return nil } diff --git a/config/proposer/loader/loader_test.go b/config/proposer/loader/loader_test.go index 3875358425bb..7f1a55a4a069 100644 --- a/config/proposer/loader/loader_test.go +++ b/config/proposer/loader/loader_test.go @@ -8,6 +8,11 @@ import ( "os" "testing" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/urfave/cli/v2" + "github.com/OffchainLabs/prysm/v7/cmd/validator/flags" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/params" @@ -19,10 +24,6 @@ import ( "github.com/OffchainLabs/prysm/v7/testing/require" "github.com/OffchainLabs/prysm/v7/validator/db/iface" dbTest "github.com/OffchainLabs/prysm/v7/validator/db/testing" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" - logtest "github.com/sirupsen/logrus/hooks/test" - "github.com/urfave/cli/v2" ) func TestProposerSettingsLoader(t *testing.T) { @@ -404,6 +405,80 @@ func TestProposerSettingsLoader(t *testing.T) { }, wantErr: "", }, + { + name: "unversioned file with v2 builder fields is inferred as v2", + args: args{ + proposerSettingsFlagValues: &proposerSettingsFlag{ + dir: "./testdata/good-v2-proposer-config-unversioned.json", + }, + }, + want: func() *proposer.Settings { + key1, err := hexutil.Decode("0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a") + require.NoError(t, err) + u64 := func(v uint64) *validator.Uint64 { u := validator.Uint64(v); return &u } + return &proposer.Settings{ + Version: proposer.SchemaV2, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + bytesutil.ToBytes48(key1): { + FeeRecipientConfig: &proposer.FeeRecipientConfig{ + FeeRecipient: common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3"), + }, + BuilderConfig: &proposer.BuilderConfig{ + MinBid: u64(500000000), + Builders: []*proposer.BuilderEntry{ + {URL: "https://builder-a.example"}, + }, + }, + }, + }, + DefaultConfig: &proposer.Option{ + FeeRecipientConfig: &proposer.FeeRecipientConfig{ + FeeRecipient: common.HexToAddress("0x6e35733c5af9B61374A128e6F85f553aF09ff89A"), + }, + }, + } + }, + wantErr: "", + }, + { + name: "v2 file with builders list loads at v2 and dedups duplicate builder urls", + args: args{ + proposerSettingsFlagValues: &proposerSettingsFlag{ + dir: "./testdata/good-v2-proposer-config.json", + }, + }, + want: func() *proposer.Settings { + key1, err := hexutil.Decode("0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a") + require.NoError(t, err) + u64 := func(v uint64) *validator.Uint64 { u := validator.Uint64(v); return &u } + return &proposer.Settings{ + Version: proposer.SchemaV2, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + bytesutil.ToBytes48(key1): { + FeeRecipientConfig: &proposer.FeeRecipientConfig{ + FeeRecipient: common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3"), + }, + GasLimit: 40000000, + BuilderConfig: &proposer.BuilderConfig{ + MinBid: u64(500000000), + Builders: []*proposer.BuilderEntry{ + {URL: "https://builder-a.example", MaxExecutionPayment: u64(1000000000)}, + {URL: "https://builder-b.example"}, + }, + }, + }, + }, + DefaultConfig: &proposer.Option{ + FeeRecipientConfig: &proposer.FeeRecipientConfig{ + FeeRecipient: common.HexToAddress("0x6e35733c5af9B61374A128e6F85f553aF09ff89A"), + }, + GasLimit: 30000000, + BuilderConfig: &proposer.BuilderConfig{Builders: []*proposer.BuilderEntry{}}, + }, + } + }, + wantErr: "", + }, { name: "Happy Path Suggested Fee ", args: args{ @@ -995,7 +1070,6 @@ func Test_ProposerSettingsLoaderWithOnlyBuilder_DoesNotSaveInDB(t *testing.T) { BuilderConfig: &proposer.BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), - Relays: nil, }, }, } @@ -1027,7 +1101,7 @@ func Test_ProposerSettingsLoader_GasLimitWithoutBuilder(t *testing.T) { require.NotNil(t, got) require.NotNil(t, got.DefaultConfig) require.NotNil(t, got.DefaultConfig.BuilderConfig) - require.Equal(t, false, got.DefaultConfig.BuilderConfig.Enabled) + require.Equal(t, false, got.DefaultConfig.BuilderConfig.IsEnabled()) require.Equal(t, validator.Uint64(12345678), got.DefaultConfig.BuilderConfig.GasLimit) }) } @@ -1180,7 +1254,7 @@ func Test_mergeProposerSettings_VersionPrecedence(t *testing.T) { ) require.Equal(t, uint32(proposer.SchemaV2), merged.Version) }) - t.Run("unversioned v1 content does not inherit v2 from db", func(t *testing.T) { + t.Run("v1 content merged into a v2 db coexists; version never regresses", func(t *testing.T) { merged := mergeProposerSettings( &validatorpb.ProposerSettingsPayload{ DefaultConfig: &validatorpb.ProposerOptionPayload{ @@ -1190,13 +1264,51 @@ func Test_mergeProposerSettings_VersionPrecedence(t *testing.T) { &validatorpb.ProposerSettingsPayload{Version: proposer.SchemaV2}, &flagOptions{}, ) - require.Equal(t, uint32(0), merged.Version) + require.Equal(t, uint32(proposer.SchemaV2), merged.Version) + // Semantics are fork-keyed: legacy content stays for pre-gloas reads and + // is stripped by the post-fork cleanup, not by the merge. require.NotNil(t, merged.DefaultConfig.Builder) + require.Equal(t, true, merged.DefaultConfig.Builder.Enabled) + }) + t.Run("file per-key section replaces the DB's entirely", func(t *testing.T) { + dbPayload := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV2, + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{ + "0xaa": {FeeRecipient: "0x1111111111111111111111111111111111111111"}, + "0xbb": {FeeRecipient: "0x2222222222222222222222222222222222222222", GasLimit: 45000000}, + }, + } + filePayload := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV2, + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{ + "0xaa": {FeeRecipient: "0x3333333333333333333333333333333333333333"}, + }, + } + merged := mergeProposerSettings(filePayload, dbPayload, &flagOptions{}) + require.Equal(t, 1, len(merged.ProposerConfig)) + require.Equal(t, "0x3333333333333333333333333333333333333333", merged.ProposerConfig["0xaa"].FeeRecipient) + // Restarting with a file resets DB-resident keys the file does not name. + require.IsNil(t, merged.ProposerConfig["0xbb"]) + }) + t.Run("db per-key section kept when the file has none", func(t *testing.T) { + dbPayload := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV2, + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{ + "0xaa": {FeeRecipient: "0x1111111111111111111111111111111111111111"}, + }, + } + filePayload := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV2, + DefaultConfig: &validatorpb.ProposerOptionPayload{FeeRecipient: "0x4444444444444444444444444444444444444444"}, + } + merged := mergeProposerSettings(filePayload, dbPayload, &flagOptions{}) + require.Equal(t, 1, len(merged.ProposerConfig)) + require.Equal(t, "0x1111111111111111111111111111111111111111", merged.ProposerConfig["0xaa"].FeeRecipient) }) } -// Restarting with the same v1 file after migration persisted v2 to the DB must -// reload in v1 form so the runtime upgrade re-applies the file's gas limits. +// Restarting with the same v1 file after migration persisted v2 to the DB keeps +// the v2 version and promotes the file's content so its gas limits stay readable. func TestSettingsLoader_V1FileAfterMigratedDB(t *testing.T) { params.SetupTestConfigCleanup(t) cfg := params.BeaconConfig().Copy() @@ -1223,15 +1335,22 @@ func TestSettingsLoader_V1FileAfterMigratedDB(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) - require.Equal(t, uint32(0), got.Version) + require.Equal(t, proposer.SchemaV2, got.Version) + // The v1 file's builder content survives the merge for pre-gloas reads; + // the post-fork cleanup is what strips it. require.NotNil(t, got.DefaultConfig.BuilderConfig) - require.Equal(t, validator.Uint64(40000000), got.DefaultConfig.BuilderConfig.GasLimit) - assert.LogsContain(t, hook, "deprecated v1 schema") + assert.LogsDoNotContain(t, hook, "deprecated v1 schema") - require.Equal(t, true, got.UpgradeToV2()) key1, err := hexutil.Decode("0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a") require.NoError(t, err) + // Pre-gloas reads still resolve the v1 builder gas limit as a fallback. require.Equal(t, validator.Uint64(60000000), got.GasLimit(bytesutil.ToBytes48(key1))) + + // The cutover scrubs the v1 content even under the v2 stamp, then no-ops. + require.Equal(t, true, got.UpgradeToV2()) + require.IsNil(t, got.DefaultConfig.BuilderConfig) + require.Equal(t, validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), got.GasLimit(bytesutil.ToBytes48(key1))) + require.Equal(t, false, got.UpgradeToV2()) } func Test_mergeProposerSettings_CreatesDefaultFromGasLimitFlag(t *testing.T) { @@ -1243,25 +1362,28 @@ func Test_mergeProposerSettings_CreatesDefaultFromGasLimitFlag(t *testing.T) { ) require.NotNil(t, merged.DefaultConfig) require.NotNil(t, merged.DefaultConfig.Builder) - require.Equal(t, false, merged.DefaultConfig.Builder.Enabled) + require.Equal(t, false, merged.DefaultConfig.Builder.GetEnabled()) require.Equal(t, gl, merged.DefaultConfig.Builder.GasLimit) } -func Test_mergeProposerSettings_V2GasLimitOnlyGoesToOption(t *testing.T) { +func Test_mergeProposerSettings_V2GasLimitIsLegacyContent(t *testing.T) { gl := validator.Uint64(12345678) merged := mergeProposerSettings( nil, &validatorpb.ProposerSettingsPayload{Version: proposer.SchemaV2}, &flagOptions{gasLimit: &gl}, ) + // The flag writes only legacy builder-level content, so post-fork + // resolution and the gas limit schedule are never overridden by it. require.NotNil(t, merged.DefaultConfig) - require.IsNil(t, merged.DefaultConfig.Builder) - require.Equal(t, gl, merged.DefaultConfig.GasLimit) + require.Equal(t, validator.Uint64(0), merged.DefaultConfig.GasLimit) + require.NotNil(t, merged.DefaultConfig.Builder) + require.Equal(t, gl, merged.DefaultConfig.Builder.GasLimit) } func Test_mergeProposerSettings_VersionGatesBuilderReset(t *testing.T) { v1Builder := func() *validatorpb.BuilderConfig { - return &validatorpb.BuilderConfig{Enabled: true, GasLimit: 40000000, Relays: []string{"r"}} + return &validatorpb.BuilderConfig{Enabled: true, GasLimit: 40000000} } t.Run("v1 db without enable-builder drops DB builder", func(t *testing.T) { db := &validatorpb.ProposerSettingsPayload{ @@ -1280,6 +1402,30 @@ func Test_mergeProposerSettings_VersionGatesBuilderReset(t *testing.T) { require.NotNil(t, merged.DefaultConfig.Builder) require.Equal(t, validator.Uint64(40000000), merged.DefaultConfig.Builder.GasLimit) }) + t.Run("v2 --enable-builder still forces the legacy toggle and warns", func(t *testing.T) { + hook := logtest.NewGlobal() + opts := &flagOptions{builderConfig: &proposer.BuilderConfig{Enabled: true}} + db := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV2, + DefaultConfig: &validatorpb.ProposerOptionPayload{FeeRecipient: "0x"}, + } + merged := mergeProposerSettings(nil, db, opts) + require.NotNil(t, merged.DefaultConfig.Builder) + require.Equal(t, true, merged.DefaultConfig.Builder.Enabled) + assert.LogsContain(t, hook, "no effect after the gloas fork") + }) + t.Run("v1 builder content merged into v2 coexists until the post-fork cleanup", func(t *testing.T) { + file := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{ + FeeRecipient: "0x", + Builder: &validatorpb.BuilderConfig{GasLimit: 30000000}, + }, + } + db := &validatorpb.ProposerSettingsPayload{Version: proposer.SchemaV2} + merged := mergeProposerSettings(file, db, &flagOptions{}) + require.NotNil(t, merged.DefaultConfig.Builder) + require.Equal(t, validator.Uint64(30000000), merged.DefaultConfig.Builder.GasLimit) + }) } func Test_mergeProposerSettings_V2LoadedOverridesDB(t *testing.T) { @@ -1320,20 +1466,7 @@ func Test_mergeProposerSettings_V2LoadedOverridesDB(t *testing.T) { }) } -func Test_mergeProposerSettings_V2GasLimitOverwritesExistingDefault(t *testing.T) { - gl := validator.Uint64(12345678) - db := &validatorpb.ProposerSettingsPayload{ - Version: proposer.SchemaV2, - DefaultConfig: &validatorpb.ProposerOptionPayload{FeeRecipient: "0xdb", GasLimit: 1}, - } - merged := mergeProposerSettings(nil, db, &flagOptions{gasLimit: &gl}) - require.NotNil(t, merged.DefaultConfig) - require.IsNil(t, merged.DefaultConfig.Builder) - require.Equal(t, "0xdb", merged.DefaultConfig.FeeRecipient) - require.Equal(t, gl, merged.DefaultConfig.GasLimit) -} - -func Test_mergeProposerSettings_V2GasLimitOverwritesPerValidator(t *testing.T) { +func Test_mergeProposerSettings_V2GasLimitNeverOverridesOptions(t *testing.T) { gl := validator.Uint64(12345678) db := &validatorpb.ProposerSettingsPayload{ Version: proposer.SchemaV2, @@ -1343,6 +1476,94 @@ func Test_mergeProposerSettings_V2GasLimitOverwritesPerValidator(t *testing.T) { }, } merged := mergeProposerSettings(nil, db, &flagOptions{gasLimit: &gl}) - require.Equal(t, gl, merged.DefaultConfig.GasLimit) - require.Equal(t, gl, merged.ProposerConfig["0xkey"].GasLimit) + // Explicit v2 option-level values are the operator's; the legacy flag + // no longer stomps them at any level. + require.Equal(t, validator.Uint64(1), merged.DefaultConfig.GasLimit) + require.Equal(t, validator.Uint64(2), merged.ProposerConfig["0xkey"].GasLimit) + require.Equal(t, gl, merged.DefaultConfig.Builder.GasLimit) +} + +func Test_markExplicitEmptyBuilders(t *testing.T) { + entry := &validatorpb.BuilderEntry{Url: "https://a.example"} + t.Run("explicit empty list gains the marker", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{ + Builder: &validatorpb.BuilderConfig{Builders: []*validatorpb.BuilderEntry{}}, + }, + } + markExplicitEmptyBuilders(p) + require.Equal(t, true, p.DefaultConfig.Builder.BuildersSet) + }) + t.Run("nonempty list gains the marker too", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{ + "0xaa": {Builder: &validatorpb.BuilderConfig{Builders: []*validatorpb.BuilderEntry{entry}}}, + }, + } + markExplicitEmptyBuilders(p) + require.Equal(t, true, p.ProposerConfig["0xaa"].Builder.BuildersSet) + }) + t.Run("absent list stays unmarked", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{Builder: &validatorpb.BuilderConfig{Enabled: true}}, + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{"0xaa": {}, "0xbb": nil}, + } + markExplicitEmptyBuilders(p) + require.Equal(t, false, p.DefaultConfig.Builder.BuildersSet) + }) +} + +func Test_inferSchemaVersion(t *testing.T) { + u64 := func(v uint64) *validator.Uint64 { u := validator.Uint64(v); return &u } + v2Cases := map[string]*validatorpb.BuilderConfig{ + "builders list": {Builders: []*validatorpb.BuilderEntry{{Url: "https://a.example"}}}, + "builders set marker": {BuildersSet: true}, + "min_bid": {MinBid: u64(1)}, + "builder_boost_factor": {BuilderBoostFactor: u64(100)}, + "max_execution_payment": {MaxExecutionPayment: u64(0)}, + } + for name, bc := range v2Cases { + t.Run("unversioned with "+name+" infers v2", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{Builder: bc}, + } + inferSchemaVersion(p) + require.Equal(t, uint32(proposer.SchemaV2), p.Version) + }) + } + t.Run("per-key v2 content infers v2", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + ProposerConfig: map[string]*validatorpb.ProposerOptionPayload{ + "0xaa": {Builder: &validatorpb.BuilderConfig{MinBid: u64(1)}}, + }, + } + inferSchemaVersion(p) + require.Equal(t, uint32(proposer.SchemaV2), p.Version) + }) + t.Run("pure v1 content stays unversioned", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{ + Builder: &validatorpb.BuilderConfig{Enabled: true, GasLimit: 30000000}, + }, + } + inferSchemaVersion(p) + require.Equal(t, uint32(proposer.SchemaV1Unset), p.Version) + }) + t.Run("explicit version is never overridden", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + Version: proposer.SchemaV1, + DefaultConfig: &validatorpb.ProposerOptionPayload{ + Builder: &validatorpb.BuilderConfig{MinBid: u64(1)}, + }, + } + inferSchemaVersion(p) + require.Equal(t, uint32(proposer.SchemaV1), p.Version) + }) + t.Run("no builder content stays unversioned", func(t *testing.T) { + p := &validatorpb.ProposerSettingsPayload{ + DefaultConfig: &validatorpb.ProposerOptionPayload{FeeRecipient: "0x"}, + } + inferSchemaVersion(p) + require.Equal(t, uint32(proposer.SchemaV1Unset), p.Version) + }) } diff --git a/config/proposer/loader/testdata/good-v2-proposer-config-unversioned.json b/config/proposer/loader/testdata/good-v2-proposer-config-unversioned.json new file mode 100644 index 000000000000..bced45e8c438 --- /dev/null +++ b/config/proposer/loader/testdata/good-v2-proposer-config-unversioned.json @@ -0,0 +1,16 @@ +{ + "proposer_config": { + "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a": { + "fee_recipient": "0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3", + "builder": { + "min_bid": "500000000", + "builders": [ + { "url": "https://builder-a.example" } + ] + } + } + }, + "default_config": { + "fee_recipient": "0x6e35733c5af9B61374A128e6F85f553aF09ff89A" + } +} diff --git a/config/proposer/loader/testdata/good-v2-proposer-config.json b/config/proposer/loader/testdata/good-v2-proposer-config.json new file mode 100644 index 000000000000..c75ef417ec30 --- /dev/null +++ b/config/proposer/loader/testdata/good-v2-proposer-config.json @@ -0,0 +1,24 @@ +{ + "proposer_config": { + "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a": { + "fee_recipient": "0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3", + "gas_limit": "40000000", + "builder": { + "min_bid": "500000000", + "builders": [ + { "url": "https://builder-a.example", "max_execution_payment": "1000000000" }, + { "url": "https://builder-b.example" }, + { "url": "https://builder-a.example", "max_execution_payment": "2" } + ] + } + } + }, + "default_config": { + "fee_recipient": "0x6e35733c5af9B61374A128e6F85f553aF09ff89A", + "gas_limit": "30000000", + "builder": { + "builders": [] + } + }, + "version": 2 +} diff --git a/config/proposer/settings.go b/config/proposer/settings.go index db38da0dfba0..9f73dbd8dee7 100644 --- a/config/proposer/settings.go +++ b/config/proposer/settings.go @@ -65,9 +65,37 @@ func SettingFromConsensus(ps *validatorpb.ProposerSettingsPayload) (*Settings, e d.GasLimit = ps.DefaultConfig.GasLimit settings.DefaultConfig = d } + settings.dedupBuilders() return settings, nil } +// Persisted configs may predate url-required and (url, auth_data) uniqueness; +// url-less entries are dropped and the first entry wins, matching POST validation. +func (ps *Settings) dedupBuilders() { + dedup := func(opt *Option) { + if opt == nil || opt.BuilderConfig == nil || len(opt.BuilderConfig.Builders) == 0 { + return + } + seen := make(map[EntryIdentity]bool, len(opt.BuilderConfig.Builders)) + kept := opt.BuilderConfig.Builders[:0] + for _, e := range opt.BuilderConfig.Builders { + if e == nil || e.URL == "" || seen[e.Identity()] { + continue + } + seen[e.Identity()] = true + kept = append(kept, e) + } + if len(kept) != len(opt.BuilderConfig.Builders) { + log.Warn("Removed url-less or duplicate builder entries from proposer settings") + opt.BuilderConfig.Builders = kept + } + } + dedup(ps.DefaultConfig) + for _, opt := range ps.ProposeConfig { + dedup(opt) + } +} + func verifyOption(key string, option *validatorpb.ProposerOptionPayload) error { if option == nil { return fmt.Errorf("fee recipient is required for proposer %s", key) @@ -81,13 +109,224 @@ func verifyOption(key string, option *validatorpb.ProposerOptionPayload) error { return nil } -// BuilderConfig is the struct representation of the JSON config file set in the validator through the CLI. -// GasLimit is a number set to help the network decide on the maximum gas in each block. +// BuilderConfig is the in-memory builder settings. type BuilderConfig struct { - Enabled bool `json:"enabled" yaml:"enabled"` - GasLimit validator.Uint64 `json:"gas_limit,omitempty" yaml:"gas_limit,omitempty"` - Relays []string `json:"relays,omitempty" yaml:"relays,omitempty"` - MaxExecutionPayment validator.Uint64 `json:"max_execution_payment,omitempty" yaml:"max_execution_payment,omitempty"` + Enabled bool `json:"enabled" yaml:"enabled"` // legacy v1 (mev-boost); ignored by v2, dropped at the gloas cutover + GasLimit validator.Uint64 `json:"gas_limit,omitempty" yaml:"gas_limit,omitempty"` // legacy v1; v2 gas limits live on the option + MaxExecutionPayment *validator.Uint64 `json:"max_execution_payment,omitempty" yaml:"max_execution_payment,omitempty"` // explicit 0 = trustless-only; unset inherits + Builders []*BuilderEntry `json:"builders" yaml:"builders"` // nil = inherit, [] = use none; no omitempty so the marker survives marshal + MinBid *validator.Uint64 `json:"min_bid,omitempty" yaml:"min_bid,omitempty"` + BuilderBoostFactor *validator.Uint64 `json:"builder_boost_factor,omitempty" yaml:"builder_boost_factor,omitempty"` +} + +// BuilderEntry is one builder in a proposer's per-key builder list. Unset fields +// fall back to the enclosing BuilderConfig, then default_config. +type BuilderEntry struct { + URL string `json:"url" yaml:"url"` + Pubkeys [][]byte `json:"builder_pubkeys,omitempty" yaml:"builder_pubkeys,omitempty"` + AuthData []byte `json:"auth_data,omitempty" yaml:"auth_data,omitempty"` + MinBid *validator.Uint64 `json:"min_bid,omitempty" yaml:"min_bid,omitempty"` + MaxExecutionPayment *validator.Uint64 `json:"max_execution_payment,omitempty" yaml:"max_execution_payment,omitempty"` + BuilderBoostFactor *validator.Uint64 `json:"builder_boost_factor,omitempty" yaml:"builder_boost_factor,omitempty"` +} + +// EffectiveAuthData resolves omitted auth_data to the spec convention: +// the UTF-8 bytes of the builder's URL. +func (be *BuilderEntry) EffectiveAuthData() []byte { + if len(be.AuthData) != 0 { + return be.AuthData + } + return []byte(be.URL) +} + +// EffectiveBuilderConfig resolves pubkey's builder config against default_config; +// nil when neither level configures a builder. +func (ps *Settings) EffectiveBuilderConfig(pubkey [fieldparams.BLSPubkeyLength]byte) *BuilderConfig { + if ps == nil { + return nil + } + var perKey, def *BuilderConfig + if ps.DefaultConfig != nil { + def = ps.DefaultConfig.BuilderConfig + } + if opt, ok := ps.ProposeConfig[pubkey]; ok && opt != nil { + perKey = opt.BuilderConfig + } + return effectiveBuilderConfig(perKey, def) +} + +// RegistrationFor resolves pubkey's mev-boost registration: fee recipient, gas +// limit, and participation. Registrations are pushed pre-gloas only. +func (ps *Settings) RegistrationFor(pubkey [fieldparams.BLSPubkeyLength]byte) (common.Address, validator.Uint64, bool) { + feeRecipient := common.HexToAddress(params.BeaconConfig().EthBurnAddressHex) + gasLimit := validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit) + if ps == nil { + return feeRecipient, gasLimit, false + } + hasFeeRecipient := false + if ps.DefaultConfig != nil && ps.DefaultConfig.FeeRecipientConfig != nil { + feeRecipient = ps.DefaultConfig.FeeRecipientConfig.FeeRecipient + hasFeeRecipient = true + } + opt := ps.ProposeConfig[pubkey] + if opt != nil && opt.FeeRecipientConfig != nil { + feeRecipient = opt.FeeRecipientConfig.FeeRecipient + hasFeeRecipient = true + } + enabled := false + if ps.DefaultConfig != nil { + if in, ok := ps.DefaultConfig.BuilderConfig.registrationEnabled(); ok { + enabled = in + } + } + // A per-key choice wins over the default's. + if opt != nil { + if in, ok := opt.BuilderConfig.registrationEnabled(); ok { + enabled = in + } + } + // Explicitly set option-level gas limits win; legacy builder-level values + // are the pre-gloas fallback. + switch { + case opt != nil && opt.GasLimit != 0: + gasLimit = opt.GasLimit + case ps.DefaultConfig != nil && ps.DefaultConfig.GasLimit != 0: + gasLimit = ps.DefaultConfig.GasLimit + case opt != nil && opt.BuilderConfig != nil && opt.BuilderConfig.GasLimit != 0: + gasLimit = opt.BuilderConfig.GasLimit + case ps.DefaultConfig != nil && ps.DefaultConfig.BuilderConfig != nil && ps.DefaultConfig.BuilderConfig.GasLimit != 0: + gasLimit = ps.DefaultConfig.BuilderConfig.GasLimit + } + return feeRecipient, gasLimit, enabled && hasFeeRecipient +} + +// EntryIdentity is what makes a builder entry unique: its url compared as the +// exact string and its auth_data as the resolved bytes. +type EntryIdentity struct { + URL string + Auth string +} + +func (be *BuilderEntry) Identity() EntryIdentity { + return EntryIdentity{URL: be.URL, Auth: string(be.EffectiveAuthData())} +} + +// NeutralBuilderBoostFactor is the resolved boost when none is configured: +// pure profit maximization between builder and local payloads. +const NeutralBuilderBoostFactor = 100 + +// EffectiveMinBid resolves the config-level floor; unset means no floor. +func (bc *BuilderConfig) EffectiveMinBid() validator.Uint64 { + if bc == nil || bc.MinBid == nil { + return 0 + } + return *bc.MinBid +} + +// EffectiveBuilderBoostFactor resolves the config-level boost; unset means neutral. +func (bc *BuilderConfig) EffectiveBuilderBoostFactor() validator.Uint64 { + if bc == nil || bc.BuilderBoostFactor == nil { + return NeutralBuilderBoostFactor + } + return *bc.BuilderBoostFactor +} + +// EffectiveMaxExecutionPayment resolves the ceiling; unset means trustless-only. +func (bc *BuilderConfig) EffectiveMaxExecutionPayment() validator.Uint64 { + if bc == nil || bc.MaxExecutionPayment == nil { + return 0 + } + return *bc.MaxExecutionPayment +} + +// EffectiveMinBid resolves this entry's floor, falling back to the enclosing config. +func (be *BuilderEntry) EffectiveMinBid(bc *BuilderConfig) validator.Uint64 { + if be.MinBid != nil { + return *be.MinBid + } + return bc.EffectiveMinBid() +} + +// EffectiveBuilderBoostFactor resolves this entry's boost, falling back to the enclosing config. +func (be *BuilderEntry) EffectiveBuilderBoostFactor(bc *BuilderConfig) validator.Uint64 { + if be.BuilderBoostFactor != nil { + return *be.BuilderBoostFactor + } + return bc.EffectiveBuilderBoostFactor() +} + +// EffectiveMaxExecutionPayment resolves this entry's ceiling, falling back to the enclosing config. +func (be *BuilderEntry) EffectiveMaxExecutionPayment(bc *BuilderConfig) validator.Uint64 { + if be.MaxExecutionPayment != nil { + return *be.MaxExecutionPayment + } + return bc.EffectiveMaxExecutionPayment() +} + +// IsEnabled reports whether the legacy v1 builder path is explicitly enabled. +// v2 settings ignore the enabled field entirely. +func (bc *BuilderConfig) IsEnabled() bool { + return bc != nil && bc.Enabled +} + +// hasV2Content reports whether any v2 builder field is set; an explicit empty +// builders list counts. +func (bc *BuilderConfig) hasV2Content() bool { + return bc != nil && (bc.Builders != nil || bc.MinBid != nil || bc.BuilderBoostFactor != nil || bc.MaxExecutionPayment != nil) +} + +// registrationEnabled reports whether this config opts the key in or out of +// mev-boost registration. ok is false when the config says neither. +func (bc *BuilderConfig) registrationEnabled() (enabled, ok bool) { + switch { + case bc == nil: + return false, false + case len(bc.Builders) > 0 || bc.Enabled: + // A nonempty v2 builders list opts in pre-gloas just like v1 enabled. + return true, true + case bc.Builders != nil: + // An explicit empty list means self-build everywhere: no mev-boost either. + return false, true + case !bc.hasV2Content(): + // A pure-v1 config without enabled is the legacy wins-wholesale disable. + return false, true + default: + // Gloas knobs (min_bid etc.) without a builders list: no registration choice. + return false, false + } +} + +// effectiveBuilderConfig merges two config levels with field-level inheritance; +// the builders list replaces rather than merges. +func effectiveBuilderConfig(perKey, def *BuilderConfig) *BuilderConfig { + if perKey == nil { + return def + } + if def == nil { + return perKey + } + eff := &BuilderConfig{ + GasLimit: perKey.GasLimit, + MaxExecutionPayment: coalesceUint64(perKey.MaxExecutionPayment, def.MaxExecutionPayment), + MinBid: coalesceUint64(perKey.MinBid, def.MinBid), + BuilderBoostFactor: coalesceUint64(perKey.BuilderBoostFactor, def.BuilderBoostFactor), + Builders: perKey.Builders, + } + if eff.GasLimit == 0 { + eff.GasLimit = def.GasLimit + } + // A nil list inherits the default's; a non-nil empty list means "use no builders". + if eff.Builders == nil { + eff.Builders = def.Builders + } + return eff +} + +func coalesceUint64(a, b *validator.Uint64) *validator.Uint64 { + if a != nil { + return a + } + return b } // BuilderConfigFromConsensus converts protobuf to a builder config used in in-memory storage @@ -98,25 +337,59 @@ func BuilderConfigFromConsensus(from *validatorpb.BuilderConfig) *BuilderConfig c := &BuilderConfig{ Enabled: from.Enabled, GasLimit: from.GasLimit, + MaxExecutionPayment: cloneUint64(from.MaxExecutionPayment), + MinBid: cloneUint64(from.MinBid), + BuilderBoostFactor: cloneUint64(from.BuilderBoostFactor), + } + if len(from.Builders) > 0 { + c.Builders = make([]*BuilderEntry, 0, len(from.Builders)) + for _, b := range from.Builders { + c.Builders = append(c.Builders, builderEntryFromConsensus(b)) + } + } else if from.GetBuildersSet() { + // An explicitly configured empty list means "use no builders". + c.Builders = []*BuilderEntry{} + } + return c +} + +func builderEntryFromConsensus(from *validatorpb.BuilderEntry) *BuilderEntry { + if from == nil { + return nil + } + e := &BuilderEntry{ + URL: from.Url, + MinBid: from.MinBid, MaxExecutionPayment: from.MaxExecutionPayment, + BuilderBoostFactor: from.BuilderBoostFactor, } - if from.Relays != nil { - relays := make([]string, len(from.Relays)) - copy(relays, from.Relays) - c.Relays = relays + // Treat empty as absent so bolt (nil) and filesystem (empty) round-trips agree. + if len(from.Pubkeys) != 0 { + e.Pubkeys = bytesutil.SafeCopy2dBytes(from.Pubkeys) } - return c + if len(from.AuthData) != 0 { + e.AuthData = bytesutil.SafeCopyBytes(from.AuthData) + } + return e } -// Schema versions for proposer settings. SchemaV1Unset is the proto3 zero -// value — every existing v1 user has it, since the version field is new. -// Both SchemaV1Unset and SchemaV1 are legacy v1 inputs to the migration. +// Schema versions for proposer settings. SchemaV1Unset is the proto3 zero value +// every pre-versioning user has; both it and SchemaV1 are legacy v1 inputs. const ( SchemaV1Unset uint32 = 0 SchemaV1 uint32 = 1 SchemaV2 uint32 = 2 ) +// FreshSettingsVersion is the schema stamped on settings the keymanager APIs +// create from nothing: v2 once the network schedules gloas, legacy before. +func FreshSettingsVersion() uint32 { + if params.GloasEnabled() { + return SchemaV2 + } + return SchemaV1Unset +} + // Settings is a Prysm internal representation of the fee recipient config on the validator client. // validatorpb.ProposerSettingsPayload maps to Settings on import through the CLI. type Settings struct { @@ -243,16 +516,42 @@ func (bc *BuilderConfig) Clone() *BuilderConfig { c := &BuilderConfig{} c.Enabled = bc.Enabled c.GasLimit = bc.GasLimit - c.MaxExecutionPayment = bc.MaxExecutionPayment - var relays []string - if bc.Relays != nil { - relays = make([]string, len(bc.Relays)) - copy(relays, bc.Relays) - c.Relays = relays + c.MaxExecutionPayment = cloneUint64(bc.MaxExecutionPayment) + c.MinBid = cloneUint64(bc.MinBid) + c.BuilderBoostFactor = cloneUint64(bc.BuilderBoostFactor) + // Preserve nil vs empty: an empty list is the "use no builders" marker. + if bc.Builders != nil { + c.Builders = make([]*BuilderEntry, 0, len(bc.Builders)) + for _, b := range bc.Builders { + c.Builders = append(c.Builders, b.Clone()) + } } return c } +// Clone creates a deep copy of a builder entry +func (be *BuilderEntry) Clone() *BuilderEntry { + if be == nil { + return nil + } + return &BuilderEntry{ + URL: be.URL, + Pubkeys: bytesutil.SafeCopy2dBytes(be.Pubkeys), + AuthData: bytesutil.SafeCopyBytes(be.AuthData), + MinBid: cloneUint64(be.MinBid), + MaxExecutionPayment: cloneUint64(be.MaxExecutionPayment), + BuilderBoostFactor: cloneUint64(be.BuilderBoostFactor), + } +} + +func cloneUint64(v *validator.Uint64) *validator.Uint64 { + if v == nil { + return nil + } + c := *v + return &c +} + // Clone creates a deep copy of graffiti config func (gc *GraffitiConfig) Clone() *GraffitiConfig { if gc == nil { @@ -268,55 +567,112 @@ func (bc *BuilderConfig) ToConsensus() *validatorpb.BuilderConfig { } c := &validatorpb.BuilderConfig{} c.Enabled = bc.Enabled - var relays []string - if bc.Relays != nil { - relays = make([]string, len(bc.Relays)) - copy(relays, bc.Relays) - c.Relays = relays - } c.GasLimit = bc.GasLimit - c.MaxExecutionPayment = bc.MaxExecutionPayment + c.MaxExecutionPayment = cloneUint64(bc.MaxExecutionPayment) + c.MinBid = cloneUint64(bc.MinBid) + c.BuilderBoostFactor = cloneUint64(bc.BuilderBoostFactor) + // BuildersSet preserves nil-vs-empty across the wire: an explicit empty + // list is the "use no builders" marker and must survive persistence. + c.BuildersSet = bc.Builders != nil + if len(bc.Builders) > 0 { + c.Builders = make([]*validatorpb.BuilderEntry, 0, len(bc.Builders)) + for _, b := range bc.Builders { + c.Builders = append(c.Builders, b.toConsensus()) + } + } return c } +func (be *BuilderEntry) toConsensus() *validatorpb.BuilderEntry { + if be == nil { + return nil + } + return &validatorpb.BuilderEntry{ + Url: be.URL, + Pubkeys: bytesutil.SafeCopy2dBytes(be.Pubkeys), + AuthData: bytesutil.SafeCopyBytes(be.AuthData), + MinBid: cloneUint64(be.MinBid), + MaxExecutionPayment: cloneUint64(be.MaxExecutionPayment), + BuilderBoostFactor: cloneUint64(be.BuilderBoostFactor), + } +} + func (ps *Settings) isV2() bool { return ps != nil && ps.Version == SchemaV2 } -// WarnDeprecatedSchema logs a warning when v1 settings are used on a network -// with gloas scheduled. +// WarnDeprecatedSchema logs a warning when legacy v1 builder content is loaded +// on a network with gloas scheduled, regardless of the schema stamp. func (ps *Settings) WarnDeprecatedSchema() { - if ps == nil || ps.Version == SchemaV2 || !params.GloasEnabled() { + if ps == nil || !params.GloasEnabled() { + return + } + // Fee recipients and graffiti behave identically across schemas; only v1 + // builder fields give the cutover something to drop. + if !ps.HasLegacyBuilderContent() { return } - log.Warn("Proposer settings use the deprecated v1 schema; they are upgraded automatically at the gloas fork. Please migrate your settings source to v2.") + log.Warn("Proposer settings contain deprecated v1 builder fields (enabled, builder-level gas limits); they stop applying at the gloas fork and are replaced with defaults (fee recipients and graffiti carry over). Configure gloas builders via v2 settings or the keymanager API.") } -// UpgradeToV2 migrates v1 settings to v2 in place: builder gas limits are -// promoted to the top-level preferences gas limit (unless one is already set). -// BuilderConfig is retained because it carries the gloas builder-API relays / -// enabled / max_execution_payment, which have no top-level v2 field. Settings -// already on v2 are left untouched. Returns true if changed. +// HasLegacyBuilderContent reports whether any level carries v1 builder fields, +// i.e. whether the gloas cutover has anything to drop. +func (ps *Settings) HasLegacyBuilderContent() bool { + if ps == nil { + return false + } + legacy := func(opt *Option) bool { + return opt != nil && opt.BuilderConfig != nil && (opt.BuilderConfig.Enabled || opt.BuilderConfig.GasLimit != 0) + } + if legacy(ps.DefaultConfig) { + return true + } + for _, opt := range ps.ProposeConfig { + if legacy(opt) { + return true + } + } + return false +} + +// UpgradeToV2 is the gloas cutover: legacy v1 builder fields are scrubbed +// wherever they appear — even under a v2 stamp — and the version is stamped. func (ps *Settings) UpgradeToV2() bool { - if ps == nil || ps.isV2() { + if ps == nil { return false } - migrate := func(opt *Option) { + scrubbed := false + scrub := func(opt *Option) { if opt == nil || opt.BuilderConfig == nil { return } - if opt.GasLimit == 0 { - opt.GasLimit = opt.BuilderConfig.GasLimit + bc := opt.BuilderConfig + if bc.Enabled || bc.GasLimit != 0 { + bc.Enabled = false + bc.GasLimit = 0 + scrubbed = true + } + // A config left with no v2 content disappears entirely; an explicit + // empty builders list is v2 content and survives. + if !bc.hasV2Content() { + opt.BuilderConfig = nil + scrubbed = true } } - migrate(ps.DefaultConfig) + scrub(ps.DefaultConfig) for _, opt := range ps.ProposeConfig { - migrate(opt) + scrub(opt) } + changed := scrubbed || ps.Version != SchemaV2 ps.Version = SchemaV2 - return true + if scrubbed { + log.Warn("v1 builder settings, including gas limits, do not apply to gloas and were replaced with defaults; provide v2 proposer settings to configure builders") + } + return changed } +// TargetGasLimit resolves pubkey's proposer-preference gas limit at epoch: the +// explicit operator value, else the EIP-8261 schedule, else the chain default. func (ps *Settings) TargetGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte, epoch primitives.Epoch) validator.Uint64 { scheduled, active := params.BeaconConfig().ScheduledGasLimit(epoch) operator, ok := ps.operatorGasLimit(pubkey) @@ -329,6 +685,9 @@ func (ps *Settings) TargetGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte, epo if active && uint64(operator) > scheduled { warnGasLimitExceedsSchedule(uint64(operator), scheduled, epoch) } + if active && uint64(operator) < scheduled { + warnGasLimitBelowSchedule(uint64(operator), scheduled, epoch) + } return operator } @@ -348,86 +707,77 @@ func (ps *Settings) operatorGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte) ( var warnedGasLimitScheduleEpoch atomic.Uint64 func warnGasLimitExceedsSchedule(operator, scheduled uint64, epoch primitives.Epoch) { + if !warnOncePerEpoch(&warnedGasLimitScheduleEpoch, epoch) { + return + } + log.Warnf("Configured gas limit %d exceeds the recommended maximum of %d at epoch %d", operator, scheduled, epoch) +} + +var warnedGasLimitBelowScheduleEpoch atomic.Uint64 + +func warnGasLimitBelowSchedule(operator, scheduled uint64, epoch primitives.Epoch) { + if !warnOncePerEpoch(&warnedGasLimitBelowScheduleEpoch, epoch) { + return + } + log.Warnf("Configured gas limit %d is below the scheduled network gas limit of %d at epoch %d; remove the explicit gas limit to follow the schedule", operator, scheduled, epoch) +} + +// warnOncePerEpoch reports whether the caller won this epoch's single warning slot. +func warnOncePerEpoch(guard *atomic.Uint64, epoch primitives.Epoch) bool { e := uint64(epoch) + 1 for { - prev := warnedGasLimitScheduleEpoch.Load() + prev := guard.Load() if e <= prev { - return + return false } - if warnedGasLimitScheduleEpoch.CompareAndSwap(prev, e) { - break + if guard.CompareAndSwap(prev, e) { + return true } } - log.Warnf("Configured gas limit %d exceeds the recommended maximum of %d at epoch %d", operator, scheduled, epoch) } -// GasLimit returns the gas limit (gwei) for pubkey: the per-pubkey override, -// else the default config value, else the chain default. v1 reads the builder -// gas limit; v2 reads the top-level fields. +// GasLimit resolves pubkey's gas limit: explicitly set option-level values win, +// legacy builder-level values are the fallback, else the chain default. func (ps *Settings) GasLimit(pubkey [fieldparams.BLSPubkeyLength]byte) validator.Uint64 { chainDefault := validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit) if ps == nil { return chainDefault } - if ps.isV2() { - if gl, ok := ps.operatorGasLimit(pubkey); ok { - return gl - } - return chainDefault - } - if opt, ok := ps.ProposeConfig[pubkey]; ok && opt != nil && opt.BuilderConfig != nil && opt.BuilderConfig.GasLimit != 0 { + opt := ps.ProposeConfig[pubkey] + switch { + case opt != nil && opt.GasLimit != 0: + return opt.GasLimit + case ps.DefaultConfig != nil && ps.DefaultConfig.GasLimit != 0: + return ps.DefaultConfig.GasLimit + case opt != nil && opt.BuilderConfig != nil && opt.BuilderConfig.GasLimit != 0: return opt.BuilderConfig.GasLimit - } - if ps.DefaultConfig != nil && ps.DefaultConfig.BuilderConfig != nil && ps.DefaultConfig.BuilderConfig.GasLimit != 0 { + case ps.DefaultConfig != nil && ps.DefaultConfig.BuilderConfig != nil && ps.DefaultConfig.BuilderConfig.GasLimit != 0: return ps.DefaultConfig.BuilderConfig.GasLimit } return chainDefault } -// SetGasLimit writes the per-pubkey gas limit. v1 requires existing settings -// with builder enabled. -func (ps *Settings) SetGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte, gasLimit validator.Uint64) error { - if ps == nil { - return errors.New("No proposer settings were found to update") - } - if ps.isV2() { - if ps.ProposeConfig == nil { - ps.ProposeConfig = make(map[[fieldparams.BLSPubkeyLength]byte]*Option) - } - opt := ps.ProposeConfig[pubkey] - if opt == nil { - opt = &Option{} - ps.ProposeConfig[pubkey] = opt - } - opt.GasLimit = gasLimit - return nil - } - builderEnabled := func(o *Option) bool { - return o != nil && o.BuilderConfig != nil && o.BuilderConfig.Enabled - } +// UpsertProposeOption returns pubkey's option, creating it if absent. A new +// option keeps BuilderConfig nil so it inherits default_config. +func (ps *Settings) UpsertProposeOption(pubkey [fieldparams.BLSPubkeyLength]byte) *Option { if ps.ProposeConfig == nil { - if !builderEnabled(ps.DefaultConfig) { - return errors.New("Gas limit changes only apply when builder is enabled") - } ps.ProposeConfig = make(map[[fieldparams.BLSPubkeyLength]byte]*Option) - opt := ps.DefaultConfig.Clone() - opt.BuilderConfig.GasLimit = gasLimit - ps.ProposeConfig[pubkey] = opt - return nil } - if opt, found := ps.ProposeConfig[pubkey]; found { - if !builderEnabled(opt) { - return errors.New("Gas limit changes only apply when builder is enabled") - } - opt.BuilderConfig.GasLimit = gasLimit - return nil + opt := ps.ProposeConfig[pubkey] + if opt == nil { + opt = &Option{} + ps.ProposeConfig[pubkey] = opt } - if !builderEnabled(ps.DefaultConfig) { - return errors.New("Gas limit changes only apply when builder is enabled") + return opt +} + +// SetGasLimit writes the per-pubkey gas limit at the option level, where both +// pre-gloas registrations and post-gloas preferences read it first. +func (ps *Settings) SetGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte, gasLimit validator.Uint64) error { + if ps == nil { + return errors.New("No proposer settings were found to update") } - opt := ps.DefaultConfig.Clone() - opt.BuilderConfig.GasLimit = gasLimit - ps.ProposeConfig[pubkey] = opt + ps.UpsertProposeOption(pubkey).GasLimit = gasLimit return nil } @@ -438,26 +788,27 @@ func (ps *Settings) ResetGasLimit(pubkey [fieldparams.BLSPubkeyLength]byte) bool return false } chainDefault := validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit) - if ps.isV2() { - opt, found := ps.ProposeConfig[pubkey] - if !found || opt == nil || opt.GasLimit == 0 { - return false - } + opt, found := ps.ProposeConfig[pubkey] + if !found || opt == nil { + return false + } + reset := false + if opt.GasLimit != 0 { if ps.DefaultConfig != nil && ps.DefaultConfig.GasLimit != 0 { opt.GasLimit = ps.DefaultConfig.GasLimit } else { - opt.GasLimit = chainDefault + opt.GasLimit = 0 } - return true + reset = true } - opt, found := ps.ProposeConfig[pubkey] - if !found || opt == nil || opt.BuilderConfig == nil { - return false - } - if ps.DefaultConfig != nil && ps.DefaultConfig.BuilderConfig != nil { - opt.BuilderConfig.GasLimit = ps.DefaultConfig.BuilderConfig.GasLimit - } else { - opt.BuilderConfig.GasLimit = chainDefault + // Legacy per-key builder gas limits reset to the default's builder value. + if opt.BuilderConfig != nil && opt.BuilderConfig.GasLimit != 0 { + if ps.DefaultConfig != nil && ps.DefaultConfig.BuilderConfig != nil { + opt.BuilderConfig.GasLimit = ps.DefaultConfig.BuilderConfig.GasLimit + } else { + opt.BuilderConfig.GasLimit = chainDefault + } + reset = true } - return true + return reset } diff --git a/config/proposer/settings_test.go b/config/proposer/settings_test.go index 717b14317366..9d63c345d087 100644 --- a/config/proposer/settings_test.go +++ b/config/proposer/settings_test.go @@ -3,17 +3,24 @@ package proposer import ( "testing" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + logtest "github.com/sirupsen/logrus/hooks/test" + fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/consensus-types/validator" "github.com/OffchainLabs/prysm/v7/encoding/bytesutil" + validatorpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1/validator-client" "github.com/OffchainLabs/prysm/v7/testing/assert" "github.com/OffchainLabs/prysm/v7/testing/require" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" - logtest "github.com/sirupsen/logrus/hooks/test" ) +func uint64ValPtr(v uint64) *validator.Uint64 { + u := validator.Uint64(v) + return &u +} + func Test_Proposer_Setting_Cloning(t *testing.T) { key1hex := "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a" key1, err := hexutil.Decode(key1hex) @@ -27,8 +34,7 @@ func Test_Proposer_Setting_Cloning(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, - MaxExecutionPayment: validator.Uint64(1000000000), + MaxExecutionPayment: uint64ValPtr(1000000000), }, }, }, @@ -39,8 +45,7 @@ func Test_Proposer_Setting_Cloning(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: false, GasLimit: validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), - Relays: []string{"https://example-relay.com"}, - MaxExecutionPayment: validator.Uint64(2000000000), + MaxExecutionPayment: uint64ValPtr(2000000000), }, }, } @@ -62,14 +67,25 @@ func Test_Proposer_Setting_Cloning(t *testing.T) { settings.DefaultConfig.BuilderConfig.GasLimit = 1 require.NotEqual(t, settings.DefaultConfig.BuilderConfig.GasLimit, clone.GasLimit) }) + t.Run("Cloning preserves the use-none builders marker", func(t *testing.T) { + clone := (&BuilderConfig{Enabled: true, Builders: []*BuilderEntry{}}).Clone() + require.NotNil(t, clone.Builders) + require.Equal(t, 0, len(clone.Builders)) + }) + t.Run("Consensus round-trip preserves the use-none builders marker", func(t *testing.T) { + got := BuilderConfigFromConsensus((&BuilderConfig{Enabled: true, Builders: []*BuilderEntry{}}).ToConsensus()) + require.NotNil(t, got.Builders) + require.Equal(t, 0, len(got.Builders)) + // And nil stays nil, meaning "inherit". + require.IsNil(t, BuilderConfigFromConsensus((&BuilderConfig{Enabled: true}).ToConsensus()).Builders) + }) t.Run("Happy Path BuilderConfigFromConsensus", func(t *testing.T) { clone := settings.DefaultConfig.BuilderConfig.Clone() config := BuilderConfigFromConsensus(clone.ToConsensus()) - require.DeepEqual(t, config.Relays, clone.Relays) - require.Equal(t, config.Enabled, clone.Enabled) + require.DeepEqual(t, config.Enabled, clone.Enabled) require.Equal(t, config.GasLimit, clone.GasLimit) - require.Equal(t, config.MaxExecutionPayment, clone.MaxExecutionPayment) + require.DeepEqual(t, config.MaxExecutionPayment, clone.MaxExecutionPayment) }) t.Run("To Payload and SettingFromConsensus", func(t *testing.T) { payload := settings.ToConsensus() @@ -80,7 +96,7 @@ func Test_Proposer_Setting_Cloning(t *testing.T) { require.Equal(t, true, pok) require.Equal(t, option.FeeRecipientConfig.FeeRecipient.Hex(), potion.FeeRecipient) require.Equal(t, settings.DefaultConfig.FeeRecipientConfig.FeeRecipient.Hex(), payload.DefaultConfig.FeeRecipient) - require.Equal(t, settings.DefaultConfig.BuilderConfig.Enabled, payload.DefaultConfig.Builder.Enabled) + require.Equal(t, settings.DefaultConfig.BuilderConfig.Enabled, payload.DefaultConfig.Builder.GetEnabled()) potion.FeeRecipient = fee newSettings, err := SettingFromConsensus(payload) require.NoError(t, err) @@ -116,7 +132,6 @@ func TestProposerSettings_ShouldBeSaved(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, }, }, }, @@ -135,7 +150,6 @@ func TestProposerSettings_ShouldBeSaved(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, }, }, }, @@ -152,7 +166,6 @@ func TestProposerSettings_ShouldBeSaved(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, }, }, }, @@ -163,7 +176,6 @@ func TestProposerSettings_ShouldBeSaved(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, }, }, }, @@ -196,7 +208,6 @@ func TestProposerSettings_ShouldBeSaved(t *testing.T) { BuilderConfig: &BuilderConfig{ Enabled: true, GasLimit: validator.Uint64(40000000), - Relays: []string{"https://example-relay.com"}, }, }, }, @@ -305,19 +316,19 @@ func TestSettings_SetGasLimit(t *testing.T) { require.NoError(t, err) pk := bytesutil.ToBytes48(pubkey) - t.Run("nil settings rejects with v1 error message", func(t *testing.T) { + t.Run("nil settings rejects", func(t *testing.T) { var ps *Settings err := ps.SetGasLimit(pk, validator.Uint64(70_000_000)) require.ErrorContains(t, "No proposer settings were found to update", err) }) - t.Run("v2 writes per-validator GasLimit", func(t *testing.T) { + t.Run("writes per-validator option-level GasLimit", func(t *testing.T) { ps := &Settings{Version: SchemaV2} require.NoError(t, ps.SetGasLimit(pk, validator.Uint64(70_000_000))) require.Equal(t, validator.Uint64(70_000_000), ps.ProposeConfig[pk].GasLimit) }) - t.Run("v2 updates existing per-validator entry", func(t *testing.T) { + t.Run("updates existing per-validator entry", func(t *testing.T) { ps := &Settings{ Version: SchemaV2, ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ @@ -328,52 +339,28 @@ func TestSettings_SetGasLimit(t *testing.T) { require.Equal(t, validator.Uint64(20_000_000), ps.ProposeConfig[pk].GasLimit) }) - t.Run("v1 with no builder rejects", func(t *testing.T) { - ps := &Settings{} - err := ps.SetGasLimit(pk, validator.Uint64(80_000_000)) - require.ErrorContains(t, "Gas limit changes only apply when builder is enabled", err) - }) - - t.Run("v1 with disabled builder rejects", func(t *testing.T) { + t.Run("v1 settings accept option-level writes without builder gating", func(t *testing.T) { + // The option-level value feeds pre-gloas registrations first, so the + // write no longer requires an enabled builder. ps := &Settings{ DefaultConfig: &Option{BuilderConfig: &BuilderConfig{Enabled: false}}, } - err := ps.SetGasLimit(pk, validator.Uint64(80_000_000)) - require.ErrorContains(t, "Gas limit changes only apply when builder is enabled", err) - }) - - t.Run("v1 clones enabled-builder default into new per-validator entry", func(t *testing.T) { - feeRecipient := common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3") - ps := &Settings{ - DefaultConfig: &Option{ - FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: feeRecipient}, - BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: validator.Uint64(30_000_000)}, - }, - } - require.NoError(t, ps.SetGasLimit(pk, validator.Uint64(90_000_000))) - opt := ps.ProposeConfig[pk] - require.Equal(t, feeRecipient, opt.FeeRecipientConfig.FeeRecipient) - require.Equal(t, validator.Uint64(90_000_000), opt.BuilderConfig.GasLimit) + require.NoError(t, ps.SetGasLimit(pk, validator.Uint64(80_000_000))) + require.Equal(t, validator.Uint64(80_000_000), ps.ProposeConfig[pk].GasLimit) + require.IsNil(t, ps.ProposeConfig[pk].BuilderConfig) }) - t.Run("v1 updates existing enabled-builder per-validator entry", func(t *testing.T) { + t.Run("v1 per-key builder entry keeps its builder config untouched", func(t *testing.T) { ps := &Settings{ ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ pk: {BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: validator.Uint64(10_000_000)}}, }, } require.NoError(t, ps.SetGasLimit(pk, validator.Uint64(20_000_000))) - require.Equal(t, validator.Uint64(20_000_000), ps.ProposeConfig[pk].BuilderConfig.GasLimit) - }) - - t.Run("v1 per-validator entry with disabled builder rejects", func(t *testing.T) { - ps := &Settings{ - ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ - pk: {BuilderConfig: &BuilderConfig{Enabled: false}}, - }, - } - err := ps.SetGasLimit(pk, validator.Uint64(20_000_000)) - require.ErrorContains(t, "Gas limit changes only apply when builder is enabled", err) + require.Equal(t, validator.Uint64(20_000_000), ps.ProposeConfig[pk].GasLimit) + // The legacy builder-level value stays; option-level wins on every read. + require.Equal(t, validator.Uint64(10_000_000), ps.ProposeConfig[pk].BuilderConfig.GasLimit) + require.Equal(t, validator.Uint64(20_000_000), ps.GasLimit(pk)) }) } @@ -405,7 +392,7 @@ func TestSettings_ResetGasLimit(t *testing.T) { require.Equal(t, validator.Uint64(40_000_000), ps.ProposeConfig[pk].GasLimit) }) - t.Run("v2 resets per-validator to chain default when no default", func(t *testing.T) { + t.Run("v2 resets per-validator to unset when no default", func(t *testing.T) { ps := &Settings{ Version: SchemaV2, ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ @@ -413,7 +400,10 @@ func TestSettings_ResetGasLimit(t *testing.T) { }, } require.Equal(t, true, ps.ResetGasLimit(pk)) - require.Equal(t, chainDefault, ps.ProposeConfig[pk].GasLimit) + // Unset rather than pinned to today's chain default, so the key follows + // future default gas limit increases. + require.Equal(t, validator.Uint64(0), ps.ProposeConfig[pk].GasLimit) + require.Equal(t, chainDefault, ps.GasLimit(pk)) }) t.Run("v1 returns false for missing per-validator entry", func(t *testing.T) { @@ -467,25 +457,43 @@ func TestSettings_WarnDeprecatedSchema(t *testing.T) { params.OverrideBeaconConfig(cfg) hook := logtest.NewGlobal() v1Settings.WarnDeprecatedSchema() - assert.LogsContain(t, hook, "deprecated v1 schema") + assert.LogsContain(t, hook, "deprecated v1 builder fields") }) t.Run("v1 without gloas scheduled silent", func(t *testing.T) { hook := logtest.NewGlobal() v1Settings.WarnDeprecatedSchema() - assert.LogsDoNotContain(t, hook, "deprecated v1 schema") + assert.LogsDoNotContain(t, hook, "deprecated v1 builder fields") + }) + t.Run("v2 stamp with v1 content still warns", func(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 100 + params.OverrideBeaconConfig(cfg) + hook := logtest.NewGlobal() + mixed := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + BuilderConfig: &BuilderConfig{Enabled: true, Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + } + mixed.WarnDeprecatedSchema() + assert.LogsContain(t, hook, "deprecated v1 builder fields") }) - t.Run("v2 silent", func(t *testing.T) { + t.Run("pure v2 content silent", func(t *testing.T) { params.SetupTestConfigCleanup(t) cfg := params.BeaconConfig().Copy() cfg.GloasForkEpoch = 100 params.OverrideBeaconConfig(cfg) hook := logtest.NewGlobal() v2 := &Settings{ - Version: SchemaV2, - DefaultConfig: &Option{GasLimit: 30000000}, + Version: SchemaV2, + DefaultConfig: &Option{ + GasLimit: 30000000, + BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, } v2.WarnDeprecatedSchema() - assert.LogsDoNotContain(t, hook, "deprecated v1 schema") + assert.LogsDoNotContain(t, hook, "deprecated v1 builder fields") }) } @@ -504,19 +512,17 @@ func TestSettings_UpgradeToV2(t *testing.T) { require.Equal(t, false, ps.UpgradeToV2()) }) - t.Run("v1 default lifts BuilderConfig.GasLimit to top-level and retains builder relays", func(t *testing.T) { + t.Run("v1 builder gas limit is not promoted; the builder config is dropped", func(t *testing.T) { ps := &Settings{ DefaultConfig: &Option{ - BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: validator.Uint64(42_000_000), Relays: []string{"http://b:8080"}}, + BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: validator.Uint64(42_000_000)}, }, } require.Equal(t, true, ps.UpgradeToV2()) require.Equal(t, SchemaV2, ps.Version) - require.Equal(t, validator.Uint64(42_000_000), ps.DefaultConfig.GasLimit) - // BuilderConfig is retained so the gloas builder-API relays/enabled survive the upgrade. - require.NotNil(t, ps.DefaultConfig.BuilderConfig) - require.Equal(t, 1, len(ps.DefaultConfig.BuilderConfig.Relays)) - require.Equal(t, "http://b:8080", ps.DefaultConfig.BuilderConfig.Relays[0]) + // v1 gas limits are not carried over: the key follows the chain default. + require.Equal(t, validator.Uint64(0), ps.DefaultConfig.GasLimit) + require.IsNil(t, ps.DefaultConfig.BuilderConfig) }) t.Run("v1 top-level GasLimit already set is preserved", func(t *testing.T) { @@ -530,7 +536,7 @@ func TestSettings_UpgradeToV2(t *testing.T) { require.Equal(t, validator.Uint64(70_000_000), ps.DefaultConfig.GasLimit) }) - t.Run("per-validator builder gas limits promoted and builders retained", func(t *testing.T) { + t.Run("per-validator builder gas limits are dropped, explicit top-level ones kept", func(t *testing.T) { pubkey2, err := hexutil.Decode("0xbedefeaa94e03438ea819bd4033c6c1bf6b04320ee2075b77273c08d02f8a61bcc303c2cdddddddddddddddddddddddd") require.NoError(t, err) pk2 := bytesutil.ToBytes48(pubkey2) @@ -543,23 +549,57 @@ func TestSettings_UpgradeToV2(t *testing.T) { require.Equal(t, true, ps.UpgradeToV2()) require.Equal(t, SchemaV2, ps.Version) require.Equal(t, true, ps.DefaultConfig == nil) - require.Equal(t, validator.Uint64(35_000_000), ps.ProposeConfig[pk].GasLimit) - require.NotNil(t, ps.ProposeConfig[pk].BuilderConfig) - // An explicit top-level gas limit wins over the builder value. + require.Equal(t, validator.Uint64(0), ps.ProposeConfig[pk].GasLimit) + require.IsNil(t, ps.ProposeConfig[pk].BuilderConfig) + // An explicitly set top-level gas limit is not builder content and survives. require.Equal(t, validator.Uint64(50_000_000), ps.ProposeConfig[pk2].GasLimit) - require.NotNil(t, ps.ProposeConfig[pk2].BuilderConfig) + require.IsNil(t, ps.ProposeConfig[pk2].BuilderConfig) }) - t.Run("already v2 is left untouched", func(t *testing.T) { + t.Run("v1 content under a v2 stamp is still scrubbed", func(t *testing.T) { ps := &Settings{ Version: SchemaV2, DefaultConfig: &Option{ BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: validator.Uint64(42_000_000)}, }, } - require.Equal(t, false, ps.UpgradeToV2()) + require.Equal(t, true, ps.UpgradeToV2()) require.Equal(t, validator.Uint64(0), ps.DefaultConfig.GasLimit) + require.IsNil(t, ps.DefaultConfig.BuilderConfig) + require.Equal(t, false, ps.UpgradeToV2()) + }) + + t.Run("mixed config keeps its v2 fields and loses the v1 ones", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + BuilderConfig: &BuilderConfig{ + Enabled: true, + GasLimit: validator.Uint64(42_000_000), + Builders: []*BuilderEntry{{URL: "https://b.example"}}, + }, + }, + } + require.Equal(t, true, ps.UpgradeToV2()) + bc := ps.DefaultConfig.BuilderConfig + require.NotNil(t, bc) + require.Equal(t, false, bc.Enabled) + require.Equal(t, validator.Uint64(0), bc.GasLimit) + require.Equal(t, 1, len(bc.Builders)) + require.Equal(t, false, ps.UpgradeToV2()) + }) + + t.Run("explicit empty builders list survives the scrub", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + BuilderConfig: &BuilderConfig{Enabled: true, Builders: []*BuilderEntry{}}, + }, + } + require.Equal(t, true, ps.UpgradeToV2()) require.NotNil(t, ps.DefaultConfig.BuilderConfig) + require.NotNil(t, ps.DefaultConfig.BuilderConfig.Builders) + require.Equal(t, 0, len(ps.DefaultConfig.BuilderConfig.Builders)) }) t.Run("default with no builder and zero GasLimit still bumps to v2", func(t *testing.T) { @@ -652,11 +692,402 @@ func TestSettings_TargetGasLimit_Schedule(t *testing.T) { require.LogsDoNotContain(t, hook, "exceeds the recommended maximum") }) - t.Run("operator value below the schedule is honored silently", func(t *testing.T) { + t.Run("operator value below the schedule is honored with a loud warning", func(t *testing.T) { hook := logtest.NewGlobal() - warnedGasLimitScheduleEpoch.Store(0) + warnedGasLimitBelowScheduleEpoch.Store(0) ps := &Settings{DefaultConfig: &Option{GasLimit: validator.Uint64(50_000_000)}} require.Equal(t, validator.Uint64(50_000_000), ps.TargetGasLimit(pk, 200)) + require.LogsContain(t, hook, "below the scheduled network gas limit") + require.LogsDoNotContain(t, hook, "exceeds the recommended maximum") + // Deduplicated within the epoch, warned again in the next one. + hook.Reset() + require.Equal(t, validator.Uint64(50_000_000), ps.TargetGasLimit(pk, 200)) + require.LogsDoNotContain(t, hook, "below the scheduled network gas limit") + require.Equal(t, validator.Uint64(50_000_000), ps.TargetGasLimit(pk, 201)) + require.LogsContain(t, hook, "below the scheduled network gas limit") + }) + + t.Run("operator value matching the schedule warns nothing", func(t *testing.T) { + hook := logtest.NewGlobal() + warnedGasLimitScheduleEpoch.Store(0) + warnedGasLimitBelowScheduleEpoch.Store(0) + ps := &Settings{DefaultConfig: &Option{GasLimit: validator.Uint64(90_000_000)}} + require.Equal(t, validator.Uint64(90_000_000), ps.TargetGasLimit(pk, 200)) + require.LogsDoNotContain(t, hook, "below the scheduled network gas limit") require.LogsDoNotContain(t, hook, "exceeds the recommended maximum") }) } + +func TestSettingFromConsensus(t *testing.T) { + // Persisted payloads may predate url-required and (url, auth_data) uniqueness: + // url-less entries drop, (url, auth) duplicates keep the first, and an omitted + // auth_data compares as its derived value (the url's UTF-8 bytes). + t.Run("dedups builders", func(t *testing.T) { + payload := &validatorpb.ProposerSettingsPayload{ + Version: SchemaV2, + DefaultConfig: &validatorpb.ProposerOptionPayload{ + Builder: &validatorpb.BuilderConfig{ + Builders: []*validatorpb.BuilderEntry{ + {Url: "https://b.example", AuthData: []byte("first")}, + {Url: "https://b.example", AuthData: []byte("second")}, + {Url: "https://b.example", AuthData: []byte("first")}, + {Url: "https://other.example"}, + {Url: "https://other.example", AuthData: []byte("https://other.example")}, + {AuthData: []byte("url-less")}, + }, + }, + }, + } + ps, err := SettingFromConsensus(payload) + require.NoError(t, err) + builders := ps.DefaultConfig.BuilderConfig.Builders + require.Equal(t, 3, len(builders)) + require.DeepEqual(t, []byte("first"), builders[0].AuthData) + require.DeepEqual(t, []byte("second"), builders[1].AuthData) + require.Equal(t, "https://other.example", builders[2].URL) + }) + + t.Run("v1 explicit max_execution_payment survives ingest", func(t *testing.T) { + key := [fieldparams.BLSPubkeyLength]byte{9} + legacy := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{BuilderConfig: &BuilderConfig{ + GasLimit: validator.Uint64(30000000), + MaxExecutionPayment: uint64ValPtr(0), + }}, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {BuilderConfig: &BuilderConfig{GasLimit: validator.Uint64(25000000)}}, + }, + } + + got, err := SettingFromConsensus(legacy.ToConsensus()) + require.NoError(t, err) + + def := got.DefaultConfig.BuilderConfig + require.Equal(t, false, def.Enabled) + // The explicit trustless-only ceiling is preserved, not stripped. + require.Equal(t, validator.Uint64(0), *def.MaxExecutionPayment) + + perKey := got.ProposeConfig[key].BuilderConfig + require.Equal(t, false, perKey.Enabled) + require.Equal(t, (*validator.Uint64)(nil), perKey.MaxExecutionPayment) + }) + + t.Run("v2 presence preserved", func(t *testing.T) { + v2 := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{BuilderConfig: &BuilderConfig{ + MaxExecutionPayment: uint64ValPtr(0), + }}, + } + got, err := SettingFromConsensus(v2.ToConsensus()) + require.NoError(t, err) + bc := got.DefaultConfig.BuilderConfig + require.Equal(t, false, bc.Enabled) + require.NotNil(t, bc.MaxExecutionPayment) + require.Equal(t, validator.Uint64(0), *bc.MaxExecutionPayment) + }) +} + +func TestRegistrationFor(t *testing.T) { + key := [fieldparams.BLSPubkeyLength]byte{7} + recipient := common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3") + + t.Run("v2 with no builder config anywhere does not register", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}}, + } + fr, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + require.Equal(t, recipient, fr) + }) + + t.Run("v2 inherits the default builders and fee recipient", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + GasLimit: 123, + BuilderConfig: &BuilderConfig{GasLimit: 456, Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + } + fr, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + require.Equal(t, recipient, fr) + // Only the explicitly set option-level gas limit is read; never the builder's. + require.Equal(t, validator.Uint64(123), gl) + }) + + t.Run("v2 without a fee recipient at any level does not register", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{{URL: "https://b.example"}}}}, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + t.Run("legacy enabled content registers regardless of version stamp", func(t *testing.T) { + // Semantics are fork-keyed, not version-keyed: registrations exist only + // pre-gloas, where legacy enabled content stays authoritative. + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true, MinBid: uint64ValPtr(1)}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + }) + + t.Run("no builder content anywhere does not register", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{MinBid: uint64ValPtr(1)}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + t.Run("v2-only extras are neutral: key keeps the enabled default's toggle", func(t *testing.T) { + // A gloas-only preference (min_bid, no builders list) must not silently + // opt the key out of an enabled v1 default. + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {BuilderConfig: &BuilderConfig{MinBid: uint64ValPtr(5)}}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + }) + + t.Run("per-key explicit empty builders opts out of an enabled v1 default", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{}}}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + t.Run("transition config registers: enabled with explicit empty builders", func(t *testing.T) { + // enabled:true + builders:[] means mev-boost until the fork, self-build after. + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true, Builders: []*BuilderEntry{}}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + }) + + t.Run("per-key v1 disable wins over a default with builders", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {BuilderConfig: &BuilderConfig{Enabled: false}}, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + t.Run("v1 per-key disabled builder opts the key out", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: 123}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: { + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: false}, + }, + }, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + perKeyRecipient := common.HexToAddress("0x6e35733c5af9B61374A128e6F85f553aF09ff89A") + + t.Run("v2 per-key fee recipient and explicit empty builders both win over the default", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: { + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: perKeyRecipient}, + BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{}}, + }, + }, + } + fr, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + require.Equal(t, perKeyRecipient, fr) + }) + + t.Run("v2 option-level gas limit wins over builder-level", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{GasLimit: 123, Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {GasLimit: 999}, + }, + } + _, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + // The option-level value (written by UpgradeToV2 and the gas-limit API) wins. + require.Equal(t, validator.Uint64(999), gl) + }) + + t.Run("v2 unset gas limit falls back to the chain default", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV2, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Builders: []*BuilderEntry{{URL: "https://b.example"}}}, + }, + } + _, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + require.Equal(t, validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), gl) + }) + + t.Run("v1 default enabled registers with the default gas limit", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: 456}, + }, + } + fr, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + require.Equal(t, recipient, fr) + require.Equal(t, validator.Uint64(456), gl) + }) + + t.Run("v1 per-key fee recipient without builder config keeps the default's toggle", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{ + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true, GasLimit: 456}, + }, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: perKeyRecipient}}, + }, + } + fr, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + require.Equal(t, perKeyRecipient, fr) + require.Equal(t, validator.Uint64(456), gl) + }) + + t.Run("v1 zero builder gas limit falls back to the chain default", func(t *testing.T) { + // API-created builder configs have no builder-level gas limit; a v1 + // registration must not advertise gas limit 0. + ps := &Settings{ + Version: SchemaV1, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: { + FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &BuilderConfig{Enabled: true}, + }, + }, + } + _, gl, enabled := ps.RegistrationFor(key) + require.Equal(t, true, enabled) + require.Equal(t, validator.Uint64(params.BeaconConfig().DefaultBuilderGasLimit), gl) + }) + + t.Run("unset version uses v1 semantics", func(t *testing.T) { + ps := &Settings{ + Version: SchemaV1Unset, + DefaultConfig: &Option{FeeRecipientConfig: &FeeRecipientConfig{FeeRecipient: recipient}}, + } + _, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + }) + + t.Run("nil settings never register", func(t *testing.T) { + var ps *Settings + fr, _, enabled := ps.RegistrationFor(key) + require.Equal(t, false, enabled) + require.Equal(t, common.HexToAddress(params.BeaconConfig().EthBurnAddressHex), fr) + }) +} + +// Version-neutral settings (fee recipient, graffiti) and pure v2 builder content +// give the deprecation warning nothing to say; only v1 fields trigger it. +func TestHasLegacyBuilderContent(t *testing.T) { + key := [fieldparams.BLSPubkeyLength]byte{9} + ps := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{FeeRecipientConfig: &FeeRecipientConfig{}}, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {GraffitiConfig: &GraffitiConfig{Graffiti: "hi"}}, + }, + } + require.Equal(t, false, ps.HasLegacyBuilderContent()) + ps.ProposeConfig[key].BuilderConfig = &BuilderConfig{MinBid: uint64ValPtr(1), Builders: []*BuilderEntry{}} + require.Equal(t, false, ps.HasLegacyBuilderContent()) + ps.ProposeConfig[key].BuilderConfig.GasLimit = 30000000 + require.Equal(t, true, ps.HasLegacyBuilderContent()) + ps.ProposeConfig[key].BuilderConfig.GasLimit = 0 + ps.DefaultConfig.BuilderConfig = &BuilderConfig{Enabled: true} + require.Equal(t, true, ps.HasLegacyBuilderContent()) + var nilSettings *Settings + require.Equal(t, false, nilSettings.HasLegacyBuilderContent()) +} + +// The cutover scrubs v1 builder fields; v2 content — including an explicit +// max_execution_payment — survives it. +func TestUpgradeToV2_DropsBuilderContent(t *testing.T) { + key := [fieldparams.BLSPubkeyLength]byte{9} + ps := &Settings{ + Version: SchemaV1, + DefaultConfig: &Option{BuilderConfig: &BuilderConfig{MaxExecutionPayment: uint64ValPtr(0)}}, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*Option{ + key: {BuilderConfig: &BuilderConfig{Enabled: true}}, + }, + } + require.Equal(t, true, ps.UpgradeToV2()) + require.Equal(t, SchemaV2, ps.Version) + // The explicit trustless-only ceiling is v2 content and survives. + require.NotNil(t, ps.DefaultConfig.BuilderConfig) + require.Equal(t, validator.Uint64(0), *ps.DefaultConfig.BuilderConfig.MaxExecutionPayment) + // The pure-v1 per-key config is gone entirely. + require.IsNil(t, ps.ProposeConfig[key].BuilderConfig) + require.Equal(t, false, ps.UpgradeToV2()) +} diff --git a/proto/prysm/v1alpha1/validator-client/keymanager.pb.go b/proto/prysm/v1alpha1/validator-client/keymanager.pb.go index c07eebf76de6..ce46c9b6ba0c 100755 --- a/proto/prysm/v1alpha1/validator-client/keymanager.pb.go +++ b/proto/prysm/v1alpha1/validator-client/keymanager.pb.go @@ -712,11 +712,14 @@ func (x *ProposerOptionPayload) GetGasLimit() github_com_OffchainLabs_prysm_v7_c } type BuilderConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` - GasLimit github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,2,opt,name=gas_limit,json=gasLimit,proto3" json:"gas_limit,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` - Relays []string `protobuf:"bytes,3,rep,name=relays,proto3" json:"relays,omitempty"` - MaxExecutionPayment github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,4,opt,name=max_execution_payment,json=maxExecutionPayment,proto3" json:"max_execution_payment,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + GasLimit github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,2,opt,name=gas_limit,json=gasLimit,proto3" json:"gas_limit,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + MaxExecutionPayment *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,4,opt,name=max_execution_payment,json=maxExecutionPayment,proto3,oneof" json:"max_execution_payment,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + Builders []*BuilderEntry `protobuf:"bytes,5,rep,name=builders,proto3" json:"builders,omitempty"` + BuildersSet bool `protobuf:"varint,6,opt,name=builders_set,json=buildersSet,proto3" json:"builders_set,omitempty"` + MinBid *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,7,opt,name=min_bid,json=minBid,proto3,oneof" json:"min_bid,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + BuilderBoostFactor *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,8,opt,name=builder_boost_factor,json=builderBoostFactor,proto3,oneof" json:"builder_boost_factor,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -765,16 +768,121 @@ func (x *BuilderConfig) GetGasLimit() github_com_OffchainLabs_prysm_v7_consensus return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) } -func (x *BuilderConfig) GetRelays() []string { +func (x *BuilderConfig) GetMaxExecutionPayment() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.MaxExecutionPayment != nil { + return *x.MaxExecutionPayment + } + return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) +} + +func (x *BuilderConfig) GetBuilders() []*BuilderEntry { if x != nil { - return x.Relays + return x.Builders } return nil } -func (x *BuilderConfig) GetMaxExecutionPayment() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { +func (x *BuilderConfig) GetBuildersSet() bool { + if x != nil { + return x.BuildersSet + } + return false +} + +func (x *BuilderConfig) GetMinBid() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.MinBid != nil { + return *x.MinBid + } + return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) +} + +func (x *BuilderConfig) GetBuilderBoostFactor() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.BuilderBoostFactor != nil { + return *x.BuilderBoostFactor + } + return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) +} + +type BuilderEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Pubkeys [][]byte `protobuf:"bytes,2,rep,name=pubkeys,proto3" json:"pubkeys,omitempty"` + AuthData []byte `protobuf:"bytes,3,opt,name=auth_data,json=authData,proto3,oneof" json:"auth_data,omitempty"` + MinBid *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,4,opt,name=min_bid,json=minBid,proto3,oneof" json:"min_bid,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + MaxExecutionPayment *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,5,opt,name=max_execution_payment,json=maxExecutionPayment,proto3,oneof" json:"max_execution_payment,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + BuilderBoostFactor *github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 `protobuf:"varint,6,opt,name=builder_boost_factor,json=builderBoostFactor,proto3,oneof" json:"builder_boost_factor,omitempty" cast-type:"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BuilderEntry) Reset() { + *x = BuilderEntry{} + mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BuilderEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BuilderEntry) ProtoMessage() {} + +func (x *BuilderEntry) ProtoReflect() protoreflect.Message { + mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BuilderEntry.ProtoReflect.Descriptor instead. +func (*BuilderEntry) Descriptor() ([]byte, []int) { + return file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDescGZIP(), []int{4} +} + +func (x *BuilderEntry) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *BuilderEntry) GetPubkeys() [][]byte { + if x != nil { + return x.Pubkeys + } + return nil +} + +func (x *BuilderEntry) GetAuthData() []byte { if x != nil { - return x.MaxExecutionPayment + return x.AuthData + } + return nil +} + +func (x *BuilderEntry) GetMinBid() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.MinBid != nil { + return *x.MinBid + } + return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) +} + +func (x *BuilderEntry) GetMaxExecutionPayment() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.MaxExecutionPayment != nil { + return *x.MaxExecutionPayment + } + return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) +} + +func (x *BuilderEntry) GetBuilderBoostFactor() github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64 { + if x != nil && x.BuilderBoostFactor != nil { + return *x.BuilderBoostFactor } return github_com_OffchainLabs_prysm_v7_consensus_types_validator.Uint64(0) } @@ -790,7 +898,7 @@ type ProposerSettingsPayload struct { func (x *ProposerSettingsPayload) Reset() { *x = ProposerSettingsPayload{} - mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[4] + mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -802,7 +910,7 @@ func (x *ProposerSettingsPayload) String() string { func (*ProposerSettingsPayload) ProtoMessage() {} func (x *ProposerSettingsPayload) ProtoReflect() protoreflect.Message { - mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[4] + mi := &file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -815,7 +923,7 @@ func (x *ProposerSettingsPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use ProposerSettingsPayload.ProtoReflect.Descriptor instead. func (*ProposerSettingsPayload) Descriptor() ([]byte, []int) { - return file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDescGZIP(), []int{4} + return file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDescGZIP(), []int{5} } func (x *ProposerSettingsPayload) GetProposerConfig() map[string]*ProposerOptionPayload { @@ -1068,7 +1176,7 @@ var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDesc = []byte 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x52, 0x08, 0x67, 0x61, 0x73, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x67, 0x72, - 0x61, 0x66, 0x66, 0x69, 0x74, 0x69, 0x22, 0xa0, 0x02, 0x0a, 0x0d, 0x42, 0x75, 0x69, 0x6c, 0x64, + 0x61, 0x66, 0x66, 0x69, 0x74, 0x69, 0x22, 0xaa, 0x05, 0x0a, 0x0d, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x62, 0x0a, 0x09, 0x67, 0x61, 0x73, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, @@ -1077,54 +1185,111 @@ var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDesc = []byte 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x52, 0x08, 0x67, 0x61, - 0x73, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x73, 0x12, 0x79, - 0x0a, 0x15, 0x6d, 0x61, 0x78, 0x5f, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, - 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, - 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, - 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, - 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, - 0x6e, 0x74, 0x36, 0x34, 0x52, 0x13, 0x6d, 0x61, 0x78, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, - 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x81, 0x03, 0x0a, 0x17, 0x50, 0x72, - 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x50, 0x61, - 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x74, 0x0a, 0x0f, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, - 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4b, - 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, - 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x70, 0x72, 0x6f, - 0x70, 0x6f, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x5c, 0x0a, 0x0e, 0x64, - 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x4f, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x0d, 0x64, 0x65, 0x66, 0x61, - 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x1a, 0x78, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x4b, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x65, 0x74, - 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, - 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x72, 0x6f, - 0x70, 0x6f, 0x73, 0x65, 0x72, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, - 0x61, 0x64, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0xcd, 0x01, - 0x0a, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x2e, 0x76, 0x32, 0x42, 0x0f, 0x4b, 0x65, 0x79, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x52, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x73, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x7e, 0x0a, 0x15, 0x6d, 0x61, 0x78, 0x5f, 0x65, 0x78, + 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, + 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, + 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x00, 0x52, 0x13, + 0x6d, 0x61, 0x78, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x88, 0x01, 0x01, 0x12, 0x48, 0x0a, 0x08, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, + 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, + 0x65, 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x65, + 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x73, + 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x73, 0x5f, 0x73, 0x65, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x73, + 0x53, 0x65, 0x74, 0x12, 0x63, 0x0a, 0x07, 0x6d, 0x69, 0x6e, 0x5f, 0x62, 0x69, 0x64, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, 0x62, + 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x65, + 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x01, 0x52, 0x06, 0x6d, + 0x69, 0x6e, 0x42, 0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x7c, 0x0a, 0x14, 0x62, 0x75, 0x69, 0x6c, + 0x64, 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, + 0x61, 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, + 0x73, 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x02, 0x52, + 0x12, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x42, 0x6f, 0x6f, 0x73, 0x74, 0x46, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x65, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x62, 0x69, 0x64, 0x42, 0x17, 0x0a, 0x15, + 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x5f, 0x66, + 0x61, 0x63, 0x74, 0x6f, 0x72, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x52, 0x06, 0x72, 0x65, 0x6c, + 0x61, 0x79, 0x73, 0x22, 0x8c, 0x04, 0x0a, 0x0c, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x73, + 0x12, 0x20, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x08, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, 0x88, + 0x01, 0x01, 0x12, 0x63, 0x0a, 0x07, 0x6d, 0x69, 0x6e, 0x5f, 0x62, 0x69, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, 0x62, 0x73, - 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, - 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x3b, - 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x70, 0x62, 0xaa, 0x02, 0x1e, 0x45, 0x74, - 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, - 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x56, 0x32, 0xca, 0x02, 0x1e, 0x45, - 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x5c, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, - 0x72, 0x5c, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5c, 0x56, 0x32, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x65, 0x6e, + 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x01, 0x52, 0x06, 0x6d, 0x69, + 0x6e, 0x42, 0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x7e, 0x0a, 0x15, 0x6d, 0x61, 0x78, 0x5f, 0x65, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, + 0x61, 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, + 0x73, 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x02, 0x52, + 0x13, 0x6d, 0x61, 0x78, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x88, 0x01, 0x01, 0x12, 0x7c, 0x0a, 0x14, 0x62, 0x75, 0x69, 0x6c, 0x64, + 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x04, 0x42, 0x45, 0x82, 0xb5, 0x18, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, + 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, 0x37, 0x2f, 0x63, 0x6f, 0x6e, 0x73, + 0x65, 0x6e, 0x73, 0x75, 0x73, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x48, 0x03, 0x52, 0x12, + 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x42, 0x6f, 0x6f, 0x73, 0x74, 0x46, 0x61, 0x63, 0x74, + 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x64, + 0x61, 0x74, 0x61, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x62, 0x69, 0x64, 0x42, + 0x18, 0x0a, 0x16, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x62, 0x75, + 0x69, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x5f, 0x66, 0x61, 0x63, 0x74, + 0x6f, 0x72, 0x22, 0x81, 0x03, 0x0a, 0x17, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x53, + 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x74, + 0x0a, 0x0f, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4b, 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, 0x65, + 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, + 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x5c, 0x0a, 0x0e, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x65, + 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, + 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x72, + 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x52, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x78, 0x0a, 0x13, + 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x4b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x65, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x73, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x65, 0x72, 0x4f, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0xcd, 0x01, 0x0a, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x65, + 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, + 0x72, 0x2e, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x2e, 0x76, 0x32, 0x42, 0x0f, 0x4b, + 0x65, 0x79, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, + 0x5a, 0x52, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4f, 0x66, 0x66, + 0x63, 0x68, 0x61, 0x69, 0x6e, 0x4c, 0x61, 0x62, 0x73, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, + 0x76, 0x37, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x79, 0x73, 0x6d, 0x2f, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, + 0x72, 0x2d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x3b, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x6f, 0x72, 0x70, 0x62, 0xaa, 0x02, 0x1e, 0x45, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, 0x2e, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x73, 0x2e, 0x56, 0x32, 0xca, 0x02, 0x1e, 0x45, 0x74, 0x68, 0x65, 0x72, 0x65, 0x75, 0x6d, + 0x5c, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, 0x72, 0x5c, 0x41, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x73, 0x5c, 0x56, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1140,74 +1305,76 @@ func file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDescGZIP() [ } var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_goTypes = []any{ (SignResponse_Status)(0), // 0: ethereum.validator.accounts.v2.SignResponse.Status (*SignRequest)(nil), // 1: ethereum.validator.accounts.v2.SignRequest (*SignResponse)(nil), // 2: ethereum.validator.accounts.v2.SignResponse (*ProposerOptionPayload)(nil), // 3: ethereum.validator.accounts.v2.ProposerOptionPayload (*BuilderConfig)(nil), // 4: ethereum.validator.accounts.v2.BuilderConfig - (*ProposerSettingsPayload)(nil), // 5: ethereum.validator.accounts.v2.ProposerSettingsPayload - nil, // 6: ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry - (*v1alpha1.BeaconBlock)(nil), // 7: ethereum.eth.v1alpha1.BeaconBlock - (*v1alpha1.AttestationData)(nil), // 8: ethereum.eth.v1alpha1.AttestationData - (*v1alpha1.AggregateAttestationAndProof)(nil), // 9: ethereum.eth.v1alpha1.AggregateAttestationAndProof - (*v1alpha1.VoluntaryExit)(nil), // 10: ethereum.eth.v1alpha1.VoluntaryExit - (*v1alpha1.BeaconBlockAltair)(nil), // 11: ethereum.eth.v1alpha1.BeaconBlockAltair - (*v1alpha1.SyncAggregatorSelectionData)(nil), // 12: ethereum.eth.v1alpha1.SyncAggregatorSelectionData - (*v1alpha1.ContributionAndProof)(nil), // 13: ethereum.eth.v1alpha1.ContributionAndProof - (*v1alpha1.BeaconBlockBellatrix)(nil), // 14: ethereum.eth.v1alpha1.BeaconBlockBellatrix - (*v1alpha1.BlindedBeaconBlockBellatrix)(nil), // 15: ethereum.eth.v1alpha1.BlindedBeaconBlockBellatrix - (*v1alpha1.ValidatorRegistrationV1)(nil), // 16: ethereum.eth.v1alpha1.ValidatorRegistrationV1 - (*v1alpha1.BeaconBlockCapella)(nil), // 17: ethereum.eth.v1alpha1.BeaconBlockCapella - (*v1alpha1.BlindedBeaconBlockCapella)(nil), // 18: ethereum.eth.v1alpha1.BlindedBeaconBlockCapella - (*v1alpha1.BeaconBlockDeneb)(nil), // 19: ethereum.eth.v1alpha1.BeaconBlockDeneb - (*v1alpha1.BlindedBeaconBlockDeneb)(nil), // 20: ethereum.eth.v1alpha1.BlindedBeaconBlockDeneb - (*v1alpha1.BeaconBlockElectra)(nil), // 21: ethereum.eth.v1alpha1.BeaconBlockElectra - (*v1alpha1.BlindedBeaconBlockElectra)(nil), // 22: ethereum.eth.v1alpha1.BlindedBeaconBlockElectra - (*v1alpha1.AggregateAttestationAndProofElectra)(nil), // 23: ethereum.eth.v1alpha1.AggregateAttestationAndProofElectra - (*v1alpha1.BlindedBeaconBlockFulu)(nil), // 24: ethereum.eth.v1alpha1.BlindedBeaconBlockFulu - (*v1alpha1.BeaconBlockGloas)(nil), // 25: ethereum.eth.v1alpha1.BeaconBlockGloas - (*v1alpha1.ExecutionPayloadEnvelope)(nil), // 26: ethereum.eth.v1alpha1.ExecutionPayloadEnvelope - (*v1alpha1.PayloadAttestationData)(nil), // 27: ethereum.eth.v1alpha1.PayloadAttestationData - (*v1alpha1.ProposerPreferences)(nil), // 28: ethereum.eth.v1alpha1.ProposerPreferences - (*v1alpha1.RequestAuthV1)(nil), // 29: ethereum.eth.v1alpha1.RequestAuthV1 + (*BuilderEntry)(nil), // 5: ethereum.validator.accounts.v2.BuilderEntry + (*ProposerSettingsPayload)(nil), // 6: ethereum.validator.accounts.v2.ProposerSettingsPayload + nil, // 7: ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry + (*v1alpha1.BeaconBlock)(nil), // 8: ethereum.eth.v1alpha1.BeaconBlock + (*v1alpha1.AttestationData)(nil), // 9: ethereum.eth.v1alpha1.AttestationData + (*v1alpha1.AggregateAttestationAndProof)(nil), // 10: ethereum.eth.v1alpha1.AggregateAttestationAndProof + (*v1alpha1.VoluntaryExit)(nil), // 11: ethereum.eth.v1alpha1.VoluntaryExit + (*v1alpha1.BeaconBlockAltair)(nil), // 12: ethereum.eth.v1alpha1.BeaconBlockAltair + (*v1alpha1.SyncAggregatorSelectionData)(nil), // 13: ethereum.eth.v1alpha1.SyncAggregatorSelectionData + (*v1alpha1.ContributionAndProof)(nil), // 14: ethereum.eth.v1alpha1.ContributionAndProof + (*v1alpha1.BeaconBlockBellatrix)(nil), // 15: ethereum.eth.v1alpha1.BeaconBlockBellatrix + (*v1alpha1.BlindedBeaconBlockBellatrix)(nil), // 16: ethereum.eth.v1alpha1.BlindedBeaconBlockBellatrix + (*v1alpha1.ValidatorRegistrationV1)(nil), // 17: ethereum.eth.v1alpha1.ValidatorRegistrationV1 + (*v1alpha1.BeaconBlockCapella)(nil), // 18: ethereum.eth.v1alpha1.BeaconBlockCapella + (*v1alpha1.BlindedBeaconBlockCapella)(nil), // 19: ethereum.eth.v1alpha1.BlindedBeaconBlockCapella + (*v1alpha1.BeaconBlockDeneb)(nil), // 20: ethereum.eth.v1alpha1.BeaconBlockDeneb + (*v1alpha1.BlindedBeaconBlockDeneb)(nil), // 21: ethereum.eth.v1alpha1.BlindedBeaconBlockDeneb + (*v1alpha1.BeaconBlockElectra)(nil), // 22: ethereum.eth.v1alpha1.BeaconBlockElectra + (*v1alpha1.BlindedBeaconBlockElectra)(nil), // 23: ethereum.eth.v1alpha1.BlindedBeaconBlockElectra + (*v1alpha1.AggregateAttestationAndProofElectra)(nil), // 24: ethereum.eth.v1alpha1.AggregateAttestationAndProofElectra + (*v1alpha1.BlindedBeaconBlockFulu)(nil), // 25: ethereum.eth.v1alpha1.BlindedBeaconBlockFulu + (*v1alpha1.BeaconBlockGloas)(nil), // 26: ethereum.eth.v1alpha1.BeaconBlockGloas + (*v1alpha1.ExecutionPayloadEnvelope)(nil), // 27: ethereum.eth.v1alpha1.ExecutionPayloadEnvelope + (*v1alpha1.PayloadAttestationData)(nil), // 28: ethereum.eth.v1alpha1.PayloadAttestationData + (*v1alpha1.ProposerPreferences)(nil), // 29: ethereum.eth.v1alpha1.ProposerPreferences + (*v1alpha1.RequestAuthV1)(nil), // 30: ethereum.eth.v1alpha1.RequestAuthV1 } var file_proto_prysm_v1alpha1_validator_client_keymanager_proto_depIdxs = []int32{ - 7, // 0: ethereum.validator.accounts.v2.SignRequest.block:type_name -> ethereum.eth.v1alpha1.BeaconBlock - 8, // 1: ethereum.validator.accounts.v2.SignRequest.attestation_data:type_name -> ethereum.eth.v1alpha1.AttestationData - 9, // 2: ethereum.validator.accounts.v2.SignRequest.aggregate_attestation_and_proof:type_name -> ethereum.eth.v1alpha1.AggregateAttestationAndProof - 10, // 3: ethereum.validator.accounts.v2.SignRequest.exit:type_name -> ethereum.eth.v1alpha1.VoluntaryExit - 11, // 4: ethereum.validator.accounts.v2.SignRequest.block_altair:type_name -> ethereum.eth.v1alpha1.BeaconBlockAltair - 12, // 5: ethereum.validator.accounts.v2.SignRequest.sync_aggregator_selection_data:type_name -> ethereum.eth.v1alpha1.SyncAggregatorSelectionData - 13, // 6: ethereum.validator.accounts.v2.SignRequest.contribution_and_proof:type_name -> ethereum.eth.v1alpha1.ContributionAndProof - 14, // 7: ethereum.validator.accounts.v2.SignRequest.block_bellatrix:type_name -> ethereum.eth.v1alpha1.BeaconBlockBellatrix - 15, // 8: ethereum.validator.accounts.v2.SignRequest.blinded_block_bellatrix:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockBellatrix - 16, // 9: ethereum.validator.accounts.v2.SignRequest.registration:type_name -> ethereum.eth.v1alpha1.ValidatorRegistrationV1 - 17, // 10: ethereum.validator.accounts.v2.SignRequest.block_capella:type_name -> ethereum.eth.v1alpha1.BeaconBlockCapella - 18, // 11: ethereum.validator.accounts.v2.SignRequest.blinded_block_capella:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockCapella - 19, // 12: ethereum.validator.accounts.v2.SignRequest.block_deneb:type_name -> ethereum.eth.v1alpha1.BeaconBlockDeneb - 20, // 13: ethereum.validator.accounts.v2.SignRequest.blinded_block_deneb:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockDeneb - 21, // 14: ethereum.validator.accounts.v2.SignRequest.block_electra:type_name -> ethereum.eth.v1alpha1.BeaconBlockElectra - 22, // 15: ethereum.validator.accounts.v2.SignRequest.blinded_block_electra:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockElectra - 23, // 16: ethereum.validator.accounts.v2.SignRequest.aggregate_attestation_and_proof_electra:type_name -> ethereum.eth.v1alpha1.AggregateAttestationAndProofElectra - 21, // 17: ethereum.validator.accounts.v2.SignRequest.block_fulu:type_name -> ethereum.eth.v1alpha1.BeaconBlockElectra - 24, // 18: ethereum.validator.accounts.v2.SignRequest.blinded_block_fulu:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockFulu - 25, // 19: ethereum.validator.accounts.v2.SignRequest.block_gloas:type_name -> ethereum.eth.v1alpha1.BeaconBlockGloas - 26, // 20: ethereum.validator.accounts.v2.SignRequest.execution_payload_envelope:type_name -> ethereum.eth.v1alpha1.ExecutionPayloadEnvelope - 27, // 21: ethereum.validator.accounts.v2.SignRequest.payload_attestation_data:type_name -> ethereum.eth.v1alpha1.PayloadAttestationData - 28, // 22: ethereum.validator.accounts.v2.SignRequest.proposer_preference:type_name -> ethereum.eth.v1alpha1.ProposerPreferences - 29, // 23: ethereum.validator.accounts.v2.SignRequest.request_auth:type_name -> ethereum.eth.v1alpha1.RequestAuthV1 + 8, // 0: ethereum.validator.accounts.v2.SignRequest.block:type_name -> ethereum.eth.v1alpha1.BeaconBlock + 9, // 1: ethereum.validator.accounts.v2.SignRequest.attestation_data:type_name -> ethereum.eth.v1alpha1.AttestationData + 10, // 2: ethereum.validator.accounts.v2.SignRequest.aggregate_attestation_and_proof:type_name -> ethereum.eth.v1alpha1.AggregateAttestationAndProof + 11, // 3: ethereum.validator.accounts.v2.SignRequest.exit:type_name -> ethereum.eth.v1alpha1.VoluntaryExit + 12, // 4: ethereum.validator.accounts.v2.SignRequest.block_altair:type_name -> ethereum.eth.v1alpha1.BeaconBlockAltair + 13, // 5: ethereum.validator.accounts.v2.SignRequest.sync_aggregator_selection_data:type_name -> ethereum.eth.v1alpha1.SyncAggregatorSelectionData + 14, // 6: ethereum.validator.accounts.v2.SignRequest.contribution_and_proof:type_name -> ethereum.eth.v1alpha1.ContributionAndProof + 15, // 7: ethereum.validator.accounts.v2.SignRequest.block_bellatrix:type_name -> ethereum.eth.v1alpha1.BeaconBlockBellatrix + 16, // 8: ethereum.validator.accounts.v2.SignRequest.blinded_block_bellatrix:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockBellatrix + 17, // 9: ethereum.validator.accounts.v2.SignRequest.registration:type_name -> ethereum.eth.v1alpha1.ValidatorRegistrationV1 + 18, // 10: ethereum.validator.accounts.v2.SignRequest.block_capella:type_name -> ethereum.eth.v1alpha1.BeaconBlockCapella + 19, // 11: ethereum.validator.accounts.v2.SignRequest.blinded_block_capella:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockCapella + 20, // 12: ethereum.validator.accounts.v2.SignRequest.block_deneb:type_name -> ethereum.eth.v1alpha1.BeaconBlockDeneb + 21, // 13: ethereum.validator.accounts.v2.SignRequest.blinded_block_deneb:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockDeneb + 22, // 14: ethereum.validator.accounts.v2.SignRequest.block_electra:type_name -> ethereum.eth.v1alpha1.BeaconBlockElectra + 23, // 15: ethereum.validator.accounts.v2.SignRequest.blinded_block_electra:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockElectra + 24, // 16: ethereum.validator.accounts.v2.SignRequest.aggregate_attestation_and_proof_electra:type_name -> ethereum.eth.v1alpha1.AggregateAttestationAndProofElectra + 22, // 17: ethereum.validator.accounts.v2.SignRequest.block_fulu:type_name -> ethereum.eth.v1alpha1.BeaconBlockElectra + 25, // 18: ethereum.validator.accounts.v2.SignRequest.blinded_block_fulu:type_name -> ethereum.eth.v1alpha1.BlindedBeaconBlockFulu + 26, // 19: ethereum.validator.accounts.v2.SignRequest.block_gloas:type_name -> ethereum.eth.v1alpha1.BeaconBlockGloas + 27, // 20: ethereum.validator.accounts.v2.SignRequest.execution_payload_envelope:type_name -> ethereum.eth.v1alpha1.ExecutionPayloadEnvelope + 28, // 21: ethereum.validator.accounts.v2.SignRequest.payload_attestation_data:type_name -> ethereum.eth.v1alpha1.PayloadAttestationData + 29, // 22: ethereum.validator.accounts.v2.SignRequest.proposer_preference:type_name -> ethereum.eth.v1alpha1.ProposerPreferences + 30, // 23: ethereum.validator.accounts.v2.SignRequest.request_auth:type_name -> ethereum.eth.v1alpha1.RequestAuthV1 0, // 24: ethereum.validator.accounts.v2.SignResponse.status:type_name -> ethereum.validator.accounts.v2.SignResponse.Status 4, // 25: ethereum.validator.accounts.v2.ProposerOptionPayload.builder:type_name -> ethereum.validator.accounts.v2.BuilderConfig - 6, // 26: ethereum.validator.accounts.v2.ProposerSettingsPayload.proposer_config:type_name -> ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry - 3, // 27: ethereum.validator.accounts.v2.ProposerSettingsPayload.default_config:type_name -> ethereum.validator.accounts.v2.ProposerOptionPayload - 3, // 28: ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry.value:type_name -> ethereum.validator.accounts.v2.ProposerOptionPayload - 29, // [29:29] is the sub-list for method output_type - 29, // [29:29] is the sub-list for method input_type - 29, // [29:29] is the sub-list for extension type_name - 29, // [29:29] is the sub-list for extension extendee - 0, // [0:29] is the sub-list for field type_name + 5, // 26: ethereum.validator.accounts.v2.BuilderConfig.builders:type_name -> ethereum.validator.accounts.v2.BuilderEntry + 7, // 27: ethereum.validator.accounts.v2.ProposerSettingsPayload.proposer_config:type_name -> ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry + 3, // 28: ethereum.validator.accounts.v2.ProposerSettingsPayload.default_config:type_name -> ethereum.validator.accounts.v2.ProposerOptionPayload + 3, // 29: ethereum.validator.accounts.v2.ProposerSettingsPayload.ProposerConfigEntry.value:type_name -> ethereum.validator.accounts.v2.ProposerOptionPayload + 30, // [30:30] is the sub-list for method output_type + 30, // [30:30] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_proto_prysm_v1alpha1_validator_client_keymanager_proto_init() } @@ -1245,13 +1412,15 @@ func file_proto_prysm_v1alpha1_validator_client_keymanager_proto_init() { (*SignRequest_RequestAuth)(nil), } file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[2].OneofWrappers = []any{} + file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[3].OneofWrappers = []any{} + file_proto_prysm_v1alpha1_validator_client_keymanager_proto_msgTypes[4].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proto_prysm_v1alpha1_validator_client_keymanager_proto_rawDesc, NumEnums: 1, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/prysm/v1alpha1/validator-client/keymanager.proto b/proto/prysm/v1alpha1/validator-client/keymanager.proto index 688f1ce37bdb..cb2497d737dc 100644 --- a/proto/prysm/v1alpha1/validator-client/keymanager.proto +++ b/proto/prysm/v1alpha1/validator-client/keymanager.proto @@ -129,16 +129,53 @@ message ProposerOptionPayload { // BuilderConfig is a property of ProposerOptionPayload message BuilderConfig { + // enabled is legacy v1 (mev-boost) content; v2 settings ignore it. bool enabled = 1; uint64 gas_limit = 2 [ (ethereum.eth.ext.cast_type) = "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" ]; - repeated string relays = 3; + reserved 3; + reserved "relays"; // max_execution_payment is the maximum execution layer payment (in Gwei) the - // proposer will accept from a builder for the Gloas builder API. 0 means - // trustless-only; 2^64-1 means accept any amount. - uint64 max_execution_payment = 4 [ + // proposer will accept from a builder for the Gloas builder API. Explicit 0 + // means trustless-only; unset inherits; 2^64-1 means accept any amount. + optional uint64 max_execution_payment = 4 [ + (ethereum.eth.ext.cast_type) = + "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" + ]; + // builders is the per-key builder list from the keymanager validator config + // endpoint. Each entry may override the builder-level fallbacks below. + repeated BuilderEntry builders = 5; + // builders_set preserves an explicitly configured empty builders list + // ("use no builders"), which proto3 cannot distinguish from absent. + bool builders_set = 6; + optional uint64 min_bid = 7 [ + (ethereum.eth.ext.cast_type) = + "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" + ]; + optional uint64 builder_boost_factor = 8 [ + (ethereum.eth.ext.cast_type) = + "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" + ]; +} + +// BuilderEntry is a single builder in a proposer's per-key builder list. Fields +// left unset inherit from the enclosing BuilderConfig, then default_config. +message BuilderEntry { + string url = 1; + // pubkeys filters accepted bids to these builder keys; empty accepts any. + repeated bytes pubkeys = 2; + optional bytes auth_data = 3; + optional uint64 min_bid = 4 [ + (ethereum.eth.ext.cast_type) = + "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" + ]; + optional uint64 max_execution_payment = 5 [ + (ethereum.eth.ext.cast_type) = + "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" + ]; + optional uint64 builder_boost_factor = 6 [ (ethereum.eth.ext.cast_type) = "github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64" ]; diff --git a/testing/validator-mock/validator_service_mock.go b/testing/validator-mock/validator_service_mock.go index ebbf4ae4256c..9712922dfdfd 100644 --- a/testing/validator-mock/validator_service_mock.go +++ b/testing/validator-mock/validator_service_mock.go @@ -142,3 +142,17 @@ func (mr *MockValidatorServiceMockRecorder) SetProposerSettings(ctx, settings an mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProposerSettings", reflect.TypeOf((*MockValidatorService)(nil).SetProposerSettings), ctx, settings) } + +// UpdateProposerSettings mocks base method. +func (m *MockValidatorService) UpdateProposerSettings(ctx context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProposerSettings", ctx, mutate) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProposerSettings indicates an expected call of UpdateProposerSettings. +func (mr *MockValidatorServiceMockRecorder) UpdateProposerSettings(ctx, mutate any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProposerSettings", reflect.TypeOf((*MockValidatorService)(nil).UpdateProposerSettings), ctx, mutate) +} diff --git a/validator/client/BUILD.bazel b/validator/client/BUILD.bazel index 1c8640770802..c07eb2ee48dd 100644 --- a/validator/client/BUILD.bazel +++ b/validator/client/BUILD.bazel @@ -55,6 +55,7 @@ go_library( "//consensus-types/blocks:go_default_library", "//consensus-types/interfaces:go_default_library", "//consensus-types/primitives:go_default_library", + "//consensus-types/validator:go_default_library", "//container/slice:go_default_library", "//crypto/bls:go_default_library", "//crypto/hash:go_default_library", diff --git a/validator/client/propose.go b/validator/client/propose.go index a0411fde0bb4..c51ae3d51057 100644 --- a/validator/client/propose.go +++ b/validator/client/propose.go @@ -577,41 +577,31 @@ func (v *validator) SetGraffiti(ctx context.Context, pubkey [fieldparams.BLSPubk if graffiti == nil { return nil } - settings := &proposer.Settings{} - if v.proposerSettings != nil { - settings = v.proposerSettings.Clone() - } - if settings.ProposeConfig == nil { - settings.ProposeConfig = map[[48]byte]*proposer.Option{pubkey: {GraffitiConfig: &proposer.GraffitiConfig{Graffiti: string(graffiti)}}} - return v.SetProposerSettings(ctx, settings) - } - option, ok := settings.ProposeConfig[pubkey] - if !ok || option == nil { - settings.ProposeConfig[pubkey] = &proposer.Option{GraffitiConfig: &proposer.GraffitiConfig{ - Graffiti: string(graffiti), - }} - } else { - option.GraffitiConfig = &proposer.GraffitiConfig{ - Graffiti: string(graffiti), + return v.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if settings == nil { + // API-created settings carry no v1 content: v2 once gloas is scheduled. + settings = &proposer.Settings{Version: proposer.FreshSettingsVersion()} } - } - return v.SetProposerSettings(ctx, settings) // save the proposer settings + settings.UpsertProposeOption(pubkey).GraffitiConfig = &proposer.GraffitiConfig{Graffiti: string(graffiti)} + return settings, nil + }) } func (v *validator) DeleteGraffiti(ctx context.Context, pubKey [fieldparams.BLSPubkeyLength]byte) error { ctx, span := trace.StartSpan(ctx, "validator.DeleteGraffiti") defer span.End() - if v.proposerSettings == nil || v.proposerSettings.ProposeConfig == nil { - return errors.New("attempted to delete graffiti without proposer settings, graffiti will default to flag options") - } - ps := v.proposerSettings.Clone() - option, ok := ps.ProposeConfig[pubKey] - if !ok || option == nil { - return fmt.Errorf("graffiti not found in proposer settings for pubkey:%s", hexutil.Encode(pubKey[:])) - } - option.GraffitiConfig = nil - return v.SetProposerSettings(ctx, ps) // save the proposer settings + return v.UpdateProposerSettings(ctx, func(ps *proposer.Settings) (*proposer.Settings, error) { + if ps == nil || ps.ProposeConfig == nil { + return nil, errors.New("attempted to delete graffiti without proposer settings, graffiti will default to flag options") + } + option, ok := ps.ProposeConfig[pubKey] + if !ok || option == nil { + return nil, fmt.Errorf("graffiti not found in proposer settings for pubkey:%s", hexutil.Encode(pubKey[:])) + } + option.GraffitiConfig = nil + return ps, nil + }) } func blockLogFields(pubKey [fieldparams.BLSPubkeyLength]byte, blk interfaces.ReadOnlyBeaconBlock, sig []byte) logrus.Fields { diff --git a/validator/client/propose_test.go b/validator/client/propose_test.go index 2d69cbc33ab6..e6db9a489df7 100644 --- a/validator/client/propose_test.go +++ b/validator/client/propose_test.go @@ -1206,6 +1206,11 @@ func Test_validator_DeleteGraffiti(t *testing.T) { } func Test_validator_SetGraffiti(t *testing.T) { + // Fresh settings are stamped v2 only once the network schedules gloas. + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 100 + params.OverrideBeaconConfig(cfg) pubKey := [fieldparams.BLSPubkeyLength]byte{'a'} tests := []struct { name string @@ -1264,7 +1269,8 @@ func Test_validator_SetGraffiti(t *testing.T) { Graffiti: "specific graffiti", }, } - return &proposer.Settings{ProposeConfig: config} + // API-created settings carry no v1 content and start at v2. + return &proposer.Settings{Version: proposer.SchemaV2, ProposeConfig: config} }(), }, } diff --git a/validator/client/runner_test.go b/validator/client/runner_test.go index 8993394681e4..21d65347e541 100644 --- a/validator/client/runner_test.go +++ b/validator/client/runner_test.go @@ -13,6 +13,12 @@ import ( "time" "github.com/OffchainLabs/go-bitfield" + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + logTest "github.com/sirupsen/logrus/hooks/test" + "go.uber.org/mock/gomock" + "github.com/OffchainLabs/prysm/v7/async/event" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/params" @@ -30,12 +36,7 @@ import ( testing2 "github.com/OffchainLabs/prysm/v7/validator/db/testing" "github.com/OffchainLabs/prysm/v7/validator/keymanager" "github.com/OffchainLabs/prysm/v7/validator/keymanager/local" - "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - logTest "github.com/sirupsen/logrus/hooks/test" "go.opentelemetry.io/otel/trace" - "go.uber.org/mock/gomock" "google.golang.org/protobuf/types/known/emptypb" ) @@ -473,7 +474,6 @@ func TestRunnerPushesProposerSettings_ValidContext(t *testing.T) { BuilderConfig: &proposer.BuilderConfig{ Enabled: true, GasLimit: 60_000_000, - Relays: []string{"https://example.com"}, }, GraffitiConfig: &proposer.GraffitiConfig{ Graffiti: "foobar", diff --git a/validator/client/service.go b/validator/client/service.go index 268bf77aa1f8..a4d06769916c 100644 --- a/validator/client/service.go +++ b/validator/client/service.go @@ -308,6 +308,12 @@ func (v *ValidatorService) ProposerSettings() *proposer.Settings { return nil } +// UpdateProposerSettings atomically mutates the proposer settings on the +// underlying validator; see iface.Validator.UpdateProposerSettings. +func (v *ValidatorService) UpdateProposerSettings(ctx context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + return v.validator.UpdateProposerSettings(ctx, mutate) +} + // SetProposerSettings sets the proposer settings on the validator service as well as the underlying validator func (v *ValidatorService) SetProposerSettings(ctx context.Context, settings *proposer.Settings) error { // validator service proposer settings is only used for pass through from node -> validator service -> validator. diff --git a/validator/client/validator.go b/validator/client/validator.go index f80897ef8800..25e29f3d76c6 100644 --- a/validator/client/validator.go +++ b/validator/client/validator.go @@ -29,6 +29,7 @@ import ( "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/config/proposer" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" + validatortypes "github.com/OffchainLabs/prysm/v7/consensus-types/validator" "github.com/OffchainLabs/prysm/v7/crypto/bls" "github.com/OffchainLabs/prysm/v7/crypto/hash" "github.com/OffchainLabs/prysm/v7/encoding/bytesutil" @@ -93,7 +94,8 @@ type validator struct { startBalances map[[fieldparams.BLSPubkeyLength]byte]uint64 attestedSlotsByKeyByEpoch map[primitives.Epoch]map[[fieldparams.BLSPubkeyLength]byte]primitives.Slot web3SignerConfig *remoteweb3signer.SetupConfig - proposerSettings *proposer.Settings + proposerSettings *proposer.Settings // clone-then-swap under proposerSettingsMu; reads are lock-free + proposerSettingsMu sync.Mutex submittedPrefSlots map[primitives.Slot]bool connTracker connTracker // per push kind, the conn generation last confirmed pushed submittedAtts map[submittedAttKey]*submittedAtt @@ -809,6 +811,12 @@ func (v *validator) ProposerSettings() *proposer.Settings { // SetProposerSettings sets and saves the passed in proposer settings overriding the in memory one func (v *validator) SetProposerSettings(ctx context.Context, settings *proposer.Settings) error { + v.proposerSettingsMu.Lock() + defer v.proposerSettingsMu.Unlock() + return v.setProposerSettingsLocked(ctx, settings) +} + +func (v *validator) setProposerSettingsLocked(ctx context.Context, settings *proposer.Settings) error { ctx, span := trace.StartSpan(ctx, "validator.SetProposerSettings") defer span.End() @@ -822,6 +830,21 @@ func (v *validator) SetProposerSettings(ctx context.Context, settings *proposer. return nil } +// UpdateProposerSettings atomically mutates the proposer settings: mutate gets a +// deep copy (nil when unset) and returns what to persist, or nil for a no-op. +func (v *validator) UpdateProposerSettings(ctx context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + v.proposerSettingsMu.Lock() + defer v.proposerSettingsMu.Unlock() + next, err := mutate(v.proposerSettings.Clone()) + if err != nil { + return err + } + if next == nil { + return nil + } + return v.setProposerSettingsLocked(ctx, next) +} + // PushProposerSettings calls the prepareBeaconProposer RPC to set the fee recipient and also the register validator API if using a custom builder. func (v *validator) PushProposerSettings(ctx context.Context, slot primitives.Slot, forceFullPush bool) error { ctx, span := trace.StartSpan(ctx, "validator.PushProposerSettings") @@ -878,6 +901,12 @@ func (v *validator) PushProposerSettings(ctx context.Context, slot primitives.Sl }); err != nil { return err } + // Gloas preferences start being prepared one epoch ahead; v1 builder + // settings do not apply to them, so warn while there is time to act. + if ps := v.ProposerSettings(); currentEpoch+1 >= params.BeaconConfig().GloasForkEpoch && + ps.HasLegacyBuilderContent() && slots.IsEpochStart(slot) { + log.Warn("Proposer settings contain v1 builder fields, which do not apply to gloas; proposer preferences are prepared with defaults. Configure builders via v2 proposer settings.") + } } else { v.upgradeProposerSettingsToV2(ctx) } @@ -909,7 +938,7 @@ func (v *validator) PushProposerSettings(ctx context.Context, slot primitives.Sl v.connTracker.confirm(proposerPrefsPush, connGen) } - if reqs := v.buildBuilderPreferenceRequests(ctx, km, slot); len(reqs) > 0 { + if reqs := v.warmBuilderRequestAuths(ctx, km, slot); len(reqs) > 0 { delay := params.BeaconConfig().SlotDuration() / 2 time.AfterFunc(delay, func() { // Detached from the slot context, which may expire before the delay elapses. @@ -1217,15 +1246,16 @@ func (v *validator) buildProposerSettingsRequests( return prepareProposerReqs } -// upgradeProposerSettingsToV2 migrates v1 proposer settings to v2 and persists -// them. Callers must gate this on gloas-active so the pre-gloas registration -// path still sees BuilderConfig. +// upgradeProposerSettingsToV2 is idempotent post-fork cleanup: it scrubs dead v1 +// builder content transactionally; callers gate on gloas-active. func (v *validator) upgradeProposerSettingsToV2(ctx context.Context) { - ps := v.ProposerSettings() - if !ps.UpgradeToV2() { - return - } - if err := v.SetProposerSettings(ctx, ps); err != nil { + err := v.UpdateProposerSettings(ctx, func(ps *proposer.Settings) (*proposer.Settings, error) { + if !ps.UpgradeToV2() { + return nil, nil + } + return ps, nil + }) + if err != nil { log.WithError(err).Warn("Failed to persist v1->v2 proposer settings upgrade") } } @@ -1440,28 +1470,70 @@ func (v *validator) submittedPrefSlotsCount() int { return len(v.submittedPrefSlots) } -func (v *validator) builderConfigForKey(pk pubkey) ([]string, uint64, bool) { +// builderTarget is one resolved builder for a proposer: a URL plus the +// preferences to submit for it, with per-entry overrides already applied. +type builderTarget struct { + url string + authData []byte + pubkeys [][]byte + maxPayment uint64 + minBid *uint64 + boostFactor *uint64 +} + +// builderTargetsForKey resolves the configured builder list for pk; entries override +// config-level fallbacks. TODO(gloas): per-entry max_execution_payment, minBid, +// boost and pubkeys ride the beacon-APIs #630 wire. +func (v *validator) builderTargetsForKey(pk pubkey) []builderTarget { ps := v.ProposerSettings() if ps == nil { - return nil, 0, false + return nil } - var bc *proposer.BuilderConfig - if ps.DefaultConfig != nil { - bc = ps.DefaultConfig.BuilderConfig + bc := ps.EffectiveBuilderConfig(pk) + if bc == nil { + return nil } - if ps.ProposeConfig != nil { - if c, ok := ps.ProposeConfig[pk]; ok && c != nil && c.BuilderConfig != nil { - bc = c.BuilderConfig + fbMax := uint64(bc.EffectiveMaxExecutionPayment()) + fbMin := uint64Ptr(bc.MinBid) + fbBoost := uint64Ptr(bc.BuilderBoostFactor) + + targets := make([]builderTarget, 0, len(bc.Builders)) + seen := make(map[proposer.EntryIdentity]bool, len(bc.Builders)) + for _, e := range bc.Builders { + if e == nil || e.URL == "" { + continue } + // One request per entry: entries may share a url with different auth_data. + if seen[e.Identity()] { + continue + } + seen[e.Identity()] = true + t := builderTarget{url: e.URL, authData: e.EffectiveAuthData(), pubkeys: e.Pubkeys, maxPayment: fbMax, minBid: fbMin, boostFactor: fbBoost} + if e.MaxExecutionPayment != nil { + t.maxPayment = uint64(*e.MaxExecutionPayment) + } + if e.MinBid != nil { + t.minBid = uint64Ptr(e.MinBid) + } + if e.BuilderBoostFactor != nil { + t.boostFactor = uint64Ptr(e.BuilderBoostFactor) + } + targets = append(targets, t) } - if bc == nil { - return nil, 0, false + return targets +} + +func uint64Ptr(v *validatortypes.Uint64) *uint64 { + if v == nil { + return nil } - return bc.Relays, uint64(bc.MaxExecutionPayment), bc.Enabled + u := uint64(*v) + return &u } -// Resubmitted every push to repopulate a restarted beacon node, using cached auths to avoid re-signing. -func (v *validator) buildBuilderPreferenceRequests(ctx context.Context, km keymanager.IKeymanager, slot primitives.Slot) []*ethpb.SubmitBuilderPreferencesRequest { +// warmBuilderRequestAuths pre-signs request auths for upcoming proposal slots and +// returns the ahead-of-time preference submissions, rebuilt every push. +func (v *validator) warmBuilderRequestAuths(ctx context.Context, km keymanager.IKeymanager, slot primitives.Slot) []*ethpb.SubmitBuilderPreferencesRequest { if slots.ToEpoch(slot)+1 < params.BeaconConfig().GloasForkEpoch { return nil } @@ -1470,32 +1542,43 @@ func (v *validator) buildBuilderPreferenceRequests(ctx context.Context, km keyma return nil } v.pruneSignedRequestAuths(slot) - - reqs := v.builderPreferenceRequestsForDuties(ctx, km, slot, snap.currentDuties()) - return append(reqs, v.builderPreferenceRequestsForDuties(ctx, km, slot, snap.nextDuties())...) + reqs := v.warmBuilderRequestAuthsForDuties(ctx, km, slot, snap.currentDuties()) + return append(reqs, v.warmBuilderRequestAuthsForDuties(ctx, km, slot, snap.nextDuties())...) } -func (v *validator) builderPreferenceRequestsForDuties(ctx context.Context, km keymanager.IKeymanager, slot primitives.Slot, duties iter.Seq2[pubkey, *ethpb.ValidatorDuty]) []*ethpb.SubmitBuilderPreferencesRequest { +func (v *validator) warmBuilderRequestAuthsForDuties(ctx context.Context, km keymanager.IKeymanager, slot primitives.Slot, duties iter.Seq2[pubkey, *ethpb.ValidatorDuty]) []*ethpb.SubmitBuilderPreferencesRequest { var reqs []*ethpb.SubmitBuilderPreferencesRequest for pk, duty := range duties { - relays, maxPayment, enabled := v.builderConfigForKey(pk) - if !enabled || len(relays) == 0 { + targets := v.builderTargetsForKey(pk) + if len(targets) == 0 { continue } + // The v1 wire holds one max_execution_payment per validator (no builder + // identity), so the lowest configured value is submitted for every builder. + minPayment := targets[0].maxPayment + urls := make(map[string]bool, len(targets)) + for _, t := range targets { + if t.maxPayment < minPayment { + minPayment = t.maxPayment + } + urls[t.url] = true + } for _, proposalSlot := range duty.ProposerSlots { if proposalSlot <= slot { continue } - for _, relay := range relays { - signed, err := v.signRequestAuthCached(ctx, km, pk, relay, proposalSlot) + for url := range urls { + signed, err := v.signRequestAuthCached(ctx, km, pk, url, proposalSlot) if err != nil { log.WithError(err).Warn("Failed to sign builder request auth") continue } + // TODO(gloas): per-entry max_execution_payment, authData, minBid, boost + // and pubkeys need the beacon-APIs #630 inline wire's builder identity. reqs = append(reqs, ðpb.SubmitBuilderPreferencesRequest{ ValidatorPubkey: pk[:], Request: ðpb.BuilderPreferencesRequestV1{ - Preferences: ðpb.BuilderPreferencesV1{MaxExecutionPayment: primitives.Gwei(maxPayment)}, + Preferences: ðpb.BuilderPreferencesV1{MaxExecutionPayment: primitives.Gwei(minPayment)}, Auth: signed, }, }) @@ -1559,7 +1642,10 @@ func (v *validator) buildSignedRegReqs( defer span.End() var signedValRegRequests []*ethpb.SignedValidatorRegistrationV1 - if v.ProposerSettings() == nil { + // One snapshot for the whole batch: concurrent keymanager writes swap the + // settings pointer, and mixing objects would tear version vs content. + ps := v.ProposerSettings() + if ps == nil { return signedValRegRequests } // if the timestamp is pre-genesis, don't create registrations @@ -1567,8 +1653,12 @@ func (v *validator) buildSignedRegReqs( return signedValRegRequests } - if v.ProposerSettings().DefaultConfig != nil && v.ProposerSettings().DefaultConfig.FeeRecipientConfig == nil && v.ProposerSettings().DefaultConfig.BuilderConfig != nil { - log.Warn("Builder is `enabled` in default config but will be ignored because no fee recipient was provided!") + if ps.DefaultConfig != nil && ps.DefaultConfig.FeeRecipientConfig == nil && ps.DefaultConfig.BuilderConfig != nil { + if ps.Version == proposer.SchemaV2 { + log.Warn("Default builder config has no default fee recipient; only keys with their own fee recipient can register") + } else { + log.Warn("Builder is `enabled` in default config but will be ignored because no fee recipient was provided!") + } } statuses := v.statusCache() @@ -1579,44 +1669,14 @@ func (v *validator) buildSignedRegReqs( continue } - feeRecipient := common.HexToAddress(params.BeaconConfig().EthBurnAddressHex) - gasLimit := params.BeaconConfig().DefaultBuilderGasLimit - enabled := false - - if v.ProposerSettings().DefaultConfig != nil && v.ProposerSettings().DefaultConfig.FeeRecipientConfig != nil { - defaultConfig := v.ProposerSettings().DefaultConfig - feeRecipient = defaultConfig.FeeRecipientConfig.FeeRecipient // Use cli defaultBuilderConfig for fee recipient. - defaultBuilderConfig := defaultConfig.BuilderConfig - - if defaultBuilderConfig != nil && defaultBuilderConfig.Enabled { - gasLimit = uint64(defaultBuilderConfig.GasLimit) // Use cli config for gas limit. - enabled = true - } - } - - if v.ProposerSettings().ProposeConfig != nil { - config, ok := v.ProposerSettings().ProposeConfig[k] - if ok && config != nil && config.FeeRecipientConfig != nil { - feeRecipient = config.FeeRecipientConfig.FeeRecipient // Use file config for fee recipient. - builderConfig := config.BuilderConfig - if builderConfig != nil { - if builderConfig.Enabled { - gasLimit = uint64(builderConfig.GasLimit) // Use file config for gas limit. - enabled = true - } else { - enabled = false // Custom config can disable validator from register. - } - } - } - } - + feeRecipient, gasLimit, enabled := ps.RegistrationFor(k) if !enabled { continue } req := ðpb.ValidatorRegistrationV1{ FeeRecipient: feeRecipient[:], - GasLimit: gasLimit, + GasLimit: uint64(gasLimit), Timestamp: uint64(time.Now().UTC().Unix()), Pubkey: activePubkeys[i][:], } diff --git a/validator/client/validator_test.go b/validator/client/validator_test.go index d6b3691e597d..b89457c627d4 100644 --- a/validator/client/validator_test.go +++ b/validator/client/validator_test.go @@ -2955,10 +2955,10 @@ func TestValidator_buildProposerPreferences_GasLimitSources(t *testing.T) { name string gloasForkEpoch primitives.Epoch settings *proposer.Settings - needsDB bool wantGasLimit uint64 - // >0 asserts migration ran and persisted; 0 asserts shape preserved. - upgradedGasLimit validatorType.Uint64 + needsDB bool + // asserts the cutover ran and persisted v2 with builder content dropped. + wantMigrated bool }{ { name: "v2 top-level GasLimit wins over legacy BuilderConfig.GasLimit", @@ -2974,7 +2974,7 @@ func TestValidator_buildProposerPreferences_GasLimitSources(t *testing.T) { wantGasLimit: 55555555, }, { - name: "gloas active: v1 settings upgraded; BuilderConfig.GasLimit promoted to Option.GasLimit", + name: "gloas active: v1 settings cut over; builder gas limit dropped, chain default used", gloasForkEpoch: 0, settings: &proposer.Settings{ DefaultConfig: &proposer.Option{ @@ -2982,9 +2982,9 @@ func TestValidator_buildProposerPreferences_GasLimitSources(t *testing.T) { BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 42000000}, }, }, - needsDB: true, - wantGasLimit: 42000000, - upgradedGasLimit: validatorType.Uint64(42000000), + needsDB: true, + wantGasLimit: chainDefault, + wantMigrated: true, }, { // Migration must not fire while pre-gloas registration path still @@ -3069,7 +3069,7 @@ func TestValidator_buildProposerPreferences_GasLimitSources(t *testing.T) { require.Equal(t, tt.wantGasLimit, prefs[0].Message.TargetGasLimit) ps := v.ProposerSettings() - if tt.upgradedGasLimit == 0 { + if !tt.wantMigrated { require.Equal(t, tt.settings.Version, ps.Version) if tt.settings.DefaultConfig != nil && tt.settings.DefaultConfig.BuilderConfig != nil { require.NotNil(t, ps.DefaultConfig.BuilderConfig) @@ -3077,13 +3077,14 @@ func TestValidator_buildProposerPreferences_GasLimitSources(t *testing.T) { return } require.Equal(t, proposer.SchemaV2, ps.Version) - require.Equal(t, tt.upgradedGasLimit, ps.DefaultConfig.GasLimit) - require.NotNil(t, ps.DefaultConfig.BuilderConfig) + // The cutover drops v1 builder content including its gas limit. + require.Equal(t, validatorType.Uint64(0), ps.DefaultConfig.GasLimit) + require.IsNil(t, ps.DefaultConfig.BuilderConfig) dbps, err := v.db.ProposerSettings(t.Context()) require.NoError(t, err) require.Equal(t, proposer.SchemaV2, dbps.Version) - require.Equal(t, tt.upgradedGasLimit, dbps.DefaultConfig.GasLimit) + require.Equal(t, validatorType.Uint64(0), dbps.DefaultConfig.GasLimit) }) } } @@ -3337,7 +3338,7 @@ func TestValidator_buildSignedRegReqs_DefaultConfigDisabled(t *testing.T) { client := validatormock.NewMockValidatorClient(ctrl) signature := blsmock.NewMockSignature(ctrl) - signature.EXPECT().Marshal().Return([]byte{}) + signature.EXPECT().Marshal().Return([]byte{}).AnyTimes() v := validator{ signedValidatorRegistrations: map[[48]byte]*ethpb.SignedValidatorRegistrationV1{}, @@ -3405,11 +3406,61 @@ func TestValidator_buildSignedRegReqs_DefaultConfigDisabled(t *testing.T) { } actual := v.buildSignedRegReqs(ctx, pubkeys, signer, 0, false) - assert.Equal(t, 1, len(actual)) + assert.Equal(t, 2, len(actual)) assert.DeepEqual(t, feeRecipient1[:], actual[0].Message.FeeRecipient) assert.Equal(t, uint64(1111), actual[0].Message.GasLimit) assert.DeepEqual(t, pubkey1[:], actual[0].Message.Pubkey) + // Fee recipient and participation resolve independently: an explicitly + // enabled key without its own fee recipient registers with the default's. + assert.DeepEqual(t, defaultFeeRecipient[:], actual[1].Message.FeeRecipient) + assert.Equal(t, uint64(3333), actual[1].Message.GasLimit) + assert.DeepEqual(t, pubkey3[:], actual[1].Message.Pubkey) +} + +// Semantics are fork-keyed, not version-keyed: the same settings shape registers +// identically whatever the stored schema version says. +func TestValidator_buildSignedRegReqs_VersionGatesInheritance(t *testing.T) { + pubkey1 := pubkeyFromString(t, "0x111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111") + defaultFeeRecipient := feeRecipientFromString(t, "0xdddddddddddddddddddddddddddddddddddddddd") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := t.Context() + + signature := blsmock.NewMockSignature(ctrl) + signature.EXPECT().Marshal().Return([]byte{}).AnyTimes() + signer := func(_ context.Context, _ *validatorpb.SignRequest) (bls.Signature, error) { + return signature, nil + } + newSettings := func(version uint32) *proposer.Settings { + return &proposer.Settings{ + Version: version, + DefaultConfig: &proposer.Option{ + FeeRecipientConfig: &proposer.FeeRecipientConfig{FeeRecipient: defaultFeeRecipient}, + BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 9999}, + }, + ProposeConfig: map[[48]byte]*proposer.Option{ + // Per-key disable without a per-key fee recipient. + pubkey1: {BuilderConfig: &proposer.BuilderConfig{Enabled: false}}, + }, + } + } + v := validator{ + signedValidatorRegistrations: map[[48]byte]*ethpb.SignedValidatorRegistrationV1{}, + pubkeyToStatus: map[[48]byte]*validatorStatus{ + pubkey1: {publicKey: pubkey1[:], status: ðpb.ValidatorStatusResponse{Status: ethpb.ValidatorStatus_ACTIVE}}, + }, + } + pubkeys := [][fieldparams.BLSPubkeyLength]byte{pubkey1} + + // The per-key disable is honored regardless of the stored version stamp. + v.proposerSettings = newSettings(0) + assert.Equal(t, 0, len(v.buildSignedRegReqs(ctx, pubkeys, signer, 0, false))) + + v.proposerSettings = newSettings(proposer.SchemaV2) + v.signedValidatorRegistrations = map[[48]byte]*ethpb.SignedValidatorRegistrationV1{} + assert.Equal(t, 0, len(v.buildSignedRegReqs(ctx, pubkeys, signer, 0, false))) } func TestValidator_buildSignedRegReqs_DefaultConfigEnabled(t *testing.T) { @@ -3511,7 +3562,8 @@ func TestValidator_buildSignedRegReqs_DefaultConfigEnabled(t *testing.T) { assert.DeepEqual(t, pubkey1[:], actual[0].Message.Pubkey) assert.DeepEqual(t, defaultFeeRecipient[:], actual[1].Message.FeeRecipient) - assert.Equal(t, uint64(9999), actual[1].Message.GasLimit) + // The per-key builder gas limit outranks the default's builder value. + assert.Equal(t, uint64(3333), actual[1].Message.GasLimit) assert.DeepEqual(t, pubkey3[:], actual[1].Message.Pubkey) t.Run("mid epoch only pushes newly added key", func(t *testing.T) { @@ -3565,7 +3617,7 @@ func TestValidator_buildSignedRegReqs_V2Settings(t *testing.T) { FeeRecipient: defaultFeeRecipient, }, GasLimit: 8888, - BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 9999}, + BuilderConfig: &proposer.BuilderConfig{GasLimit: 9999, Builders: []*proposer.BuilderEntry{{URL: "https://default.example"}}}, }, ProposeConfig: map[[48]byte]*proposer.Option{ pubkey1: { @@ -3573,7 +3625,7 @@ func TestValidator_buildSignedRegReqs_V2Settings(t *testing.T) { FeeRecipient: feeRecipient1, }, GasLimit: 1111, - BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 7777}, + BuilderConfig: &proposer.BuilderConfig{GasLimit: 7777, Builders: []*proposer.BuilderEntry{{URL: "https://b.example"}}}, }, pubkey2: { FeeRecipientConfig: &proposer.FeeRecipientConfig{ @@ -3585,7 +3637,7 @@ func TestValidator_buildSignedRegReqs_V2Settings(t *testing.T) { FeeRecipientConfig: &proposer.FeeRecipientConfig{ FeeRecipient: feeRecipient2, }, - BuilderConfig: &proposer.BuilderConfig{Enabled: false}, + BuilderConfig: &proposer.BuilderConfig{Builders: []*proposer.BuilderEntry{}}, }, }, }, @@ -3609,11 +3661,12 @@ func TestValidator_buildSignedRegReqs_V2Settings(t *testing.T) { assert.Equal(t, 2, len(actual)) assert.DeepEqual(t, feeRecipient1[:], actual[0].Message.FeeRecipient) - assert.Equal(t, uint64(7777), actual[0].Message.GasLimit, "per-key builder gas limit, not top-level") + // v2 gas limits live on the option, where UpgradeToV2 and the gas-limit API write them. + assert.Equal(t, uint64(1111), actual[0].Message.GasLimit, "per-key option gas limit wins") assert.DeepEqual(t, pubkey1[:], actual[0].Message.Pubkey) assert.DeepEqual(t, feeRecipient2[:], actual[1].Message.FeeRecipient) - assert.Equal(t, uint64(9999), actual[1].Message.GasLimit, "default builder gas limit, not top-level") + assert.Equal(t, uint64(2222), actual[1].Message.GasLimit, "per-key option gas limit wins over default builder") assert.DeepEqual(t, pubkey2[:], actual[1].Message.Pubkey) } @@ -3999,6 +4052,92 @@ func TestGetAttestationData_PostElectraConcurrentAccess(t *testing.T) { } } +func TestBuilderTargetsForKey(t *testing.T) { + pk := pubkeyFromString(t, "0x111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111") + u64 := func(v uint64) *validatorType.Uint64 { u := validatorType.Uint64(v); return &u } + proposeConfig := map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + pk: {BuilderConfig: &proposer.BuilderConfig{ + Enabled: true, + MaxExecutionPayment: u64(500), + Builders: []*proposer.BuilderEntry{ + {URL: "https://a.example", MaxExecutionPayment: u64(100)}, + {URL: "https://b.example"}, + }, + }}, + } + + t.Run("targets resolve regardless of the stored version stamp", func(t *testing.T) { + v := &validator{proposerSettings: &proposer.Settings{Version: proposer.SchemaV1, ProposeConfig: proposeConfig}} + require.Equal(t, 2, len(v.builderTargetsForKey(pk))) + }) + + t.Run("explicit empty builders list produces no targets", func(t *testing.T) { + optedOut := map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + pk: {BuilderConfig: &proposer.BuilderConfig{Builders: []*proposer.BuilderEntry{}}}, + } + v := &validator{proposerSettings: &proposer.Settings{Version: proposer.SchemaV2, ProposeConfig: optedOut}} + require.Equal(t, 0, len(v.builderTargetsForKey(pk))) + }) + + t.Run("entries resolve with config-level fallbacks", func(t *testing.T) { + v := &validator{proposerSettings: &proposer.Settings{Version: proposer.SchemaV2, ProposeConfig: proposeConfig}} + targets := v.builderTargetsForKey(pk) + require.Equal(t, 2, len(targets)) + require.Equal(t, uint64(100), targets[0].maxPayment) + require.Equal(t, uint64(500), targets[1].maxPayment) + }) +} + +func TestWarmBuilderRequestAuthsForDuties_CollapsesSameURL(t *testing.T) { + v, _, validatorKey, finish := setup(t, false) + defer finish() + var pk [fieldparams.BLSPubkeyLength]byte + copy(pk[:], validatorKey.PublicKey().Marshal()) + u64 := func(val uint64) *validatorType.Uint64 { u := validatorType.Uint64(val); return &u } + v.proposerSettings = &proposer.Settings{ + Version: proposer.SchemaV2, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + pk: {BuilderConfig: &proposer.BuilderConfig{ + Enabled: true, + Builders: []*proposer.BuilderEntry{ + {URL: "https://a.example", AuthData: []byte{1}, MaxExecutionPayment: u64(200)}, + {URL: "https://a.example", AuthData: []byte{2}, MaxExecutionPayment: u64(100)}, + }, + }}, + }, + } + km, err := v.Keymanager() + require.NoError(t, err) + duties := func(yield func(pubkey, *ethpb.ValidatorDuty) bool) { + yield(pk, ðpb.ValidatorDuty{PublicKey: pk[:], ProposerSlots: []primitives.Slot{10}}) + } + reqs := v.warmBuilderRequestAuthsForDuties(t.Context(), km, 5, duties) + // Same-url entries collapse to one request carrying the safest (lowest) ceiling. + require.Equal(t, 1, len(reqs)) + require.Equal(t, primitives.Gwei(100), reqs[0].Request.Preferences.MaxExecutionPayment) + + t.Run("distinct urls all submit the lowest max_execution_payment", func(t *testing.T) { + // The beacon node holds one max_execution_payment per validator, so every + // url's submission carries the lowest entry until #630 adds builder identity. + v.proposerSettings = &proposer.Settings{ + Version: proposer.SchemaV2, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + pk: {BuilderConfig: &proposer.BuilderConfig{ + Builders: []*proposer.BuilderEntry{ + {URL: "https://a.example", MaxExecutionPayment: u64(0)}, + {URL: "https://b.example", MaxExecutionPayment: u64(250)}, + }, + }}, + }, + } + reqs := v.warmBuilderRequestAuthsForDuties(t.Context(), km, 5, duties) + require.Equal(t, 2, len(reqs)) + for _, r := range reqs { + require.Equal(t, primitives.Gwei(0), r.Request.Preferences.MaxExecutionPayment) + } + }) +} + func headValidator() *validator { return &validator{ head: newHeadTracker(), @@ -4083,3 +4222,54 @@ func TestProcessEvent_HeadV2_PayloadStatus(t *testing.T) { }) } } + +// The post-fork settings cleanup runs concurrently with keymanager writes; the +// transactional update must serialize them so no write is ever lost. +func TestValidator_UpdateProposerSettings_Concurrency(t *testing.T) { + ctx := t.Context() + db := dbTest.SetupDB(t, t.TempDir(), [][fieldparams.BLSPubkeyLength]byte{}, false) + v := &validator{ + db: db, + proposerSettings: &proposer.Settings{ + Version: proposer.SchemaV1, + DefaultConfig: &proposer.Option{ + BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 30_000_000}, + }, + }, + } + + const writers = 16 + errs := make(chan error, writers) + var wg sync.WaitGroup + for i := range writers { + key := [fieldparams.BLSPubkeyLength]byte{byte(i + 1)} + wg.Add(2) + go func() { + defer wg.Done() + errs <- v.UpdateProposerSettings(ctx, func(ps *proposer.Settings) (*proposer.Settings, error) { + if ps == nil { + ps = &proposer.Settings{Version: proposer.SchemaV2} + } + ps.UpsertProposeOption(key).GasLimit = 1 + return ps, nil + }) + }() + go func() { + defer wg.Done() + v.upgradeProposerSettingsToV2(ctx) + }() + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + ps := v.ProposerSettings() + require.NotNil(t, ps) + // Every keymanager write survived the concurrent cleanup passes. + require.Equal(t, writers, len(ps.ProposeConfig)) + require.Equal(t, proposer.SchemaV2, ps.Version) + // A final cleanup pass leaves nothing to scrub. + require.Equal(t, false, v.ProposerSettings().Clone().UpgradeToV2()) +} diff --git a/validator/db/convert_test.go b/validator/db/convert_test.go index 059a7172654d..bc888d9b4f07 100644 --- a/validator/db/convert_test.go +++ b/validator/db/convert_test.go @@ -97,7 +97,6 @@ func TestDB_ConvertDatabase(t *testing.T) { require.NoError(t, err, "could not save graffiti ordered index") // Save the proposer settings. - var relays []string = nil expectedProposerSettings := &proposer.Settings{} if withProposerSettings { @@ -110,7 +109,6 @@ func TestDB_ConvertDatabase(t *testing.T) { BuilderConfig: &proposer.BuilderConfig{ Enabled: true, GasLimit: 42, - Relays: relays, }, }, }, @@ -121,7 +119,6 @@ func TestDB_ConvertDatabase(t *testing.T) { BuilderConfig: &proposer.BuilderConfig{ Enabled: false, GasLimit: 43, - Relays: relays, }, }, } diff --git a/validator/db/filesystem/BUILD.bazel b/validator/db/filesystem/BUILD.bazel index e09134c5dca6..3b23b61d5790 100644 --- a/validator/db/filesystem/BUILD.bazel +++ b/validator/db/filesystem/BUILD.bazel @@ -55,6 +55,7 @@ go_test( "//config/proposer:go_default_library", "//consensus-types/blocks:go_default_library", "//consensus-types/primitives:go_default_library", + "//consensus-types/validator:go_default_library", "//crypto/bls:go_default_library", "//io/file:go_default_library", "//proto/prysm/v1alpha1:go_default_library", diff --git a/validator/db/filesystem/db.go b/validator/db/filesystem/db.go index 1ec43f30a282..9eac85b02ac4 100644 --- a/validator/db/filesystem/db.go +++ b/validator/db/filesystem/db.go @@ -239,23 +239,6 @@ func (s *Store) configuration() (*Configuration, error) { return nil, errors.Wrapf(err, "could not unmarshal %s", cleanedConfigFilePath) } - // yaml.Unmarshal converts nil array to empty array. - // To get the same behavior as the BoltDB implementation, we need to convert empty array to nil. - if config.ProposerSettings != nil && - config.ProposerSettings.DefaultConfig != nil && - config.ProposerSettings.DefaultConfig.Builder != nil && - len(config.ProposerSettings.DefaultConfig.Builder.Relays) == 0 { - config.ProposerSettings.DefaultConfig.Builder.Relays = nil - } - - if config.ProposerSettings != nil && config.ProposerSettings.ProposerConfig != nil { - for _, option := range config.ProposerSettings.ProposerConfig { - if option.Builder != nil && len(option.Builder.Relays) == 0 { - option.Builder.Relays = nil - } - } - } - return config, nil } diff --git a/validator/db/filesystem/proposer_settings_test.go b/validator/db/filesystem/proposer_settings_test.go index 1e3de64924ac..198c5e511427 100644 --- a/validator/db/filesystem/proposer_settings_test.go +++ b/validator/db/filesystem/proposer_settings_test.go @@ -5,8 +5,10 @@ import ( fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/proposer" + "github.com/OffchainLabs/prysm/v7/consensus-types/validator" validatorpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1/validator-client" "github.com/OffchainLabs/prysm/v7/testing/require" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" ) @@ -226,3 +228,48 @@ func TestStore_SaveProposerSettings(t *testing.T) { }) } } + +// v2 settings with a builders list must survive a filesystem round-trip: Version +// preserved and optional entry bytes normalized to nil (matching the bolt backend). +func TestStore_ProposerSettings_V2BuildersRoundTrip(t *testing.T) { + ctx := t.Context() + u64 := func(v uint64) *validator.Uint64 { u := validator.Uint64(v); return &u } + key := getPubkeyFromString(t, "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a") + + in := &proposer.Settings{ + Version: proposer.SchemaV2, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + key: { + FeeRecipientConfig: &proposer.FeeRecipientConfig{FeeRecipient: common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3")}, + GasLimit: 40000000, + BuilderConfig: &proposer.BuilderConfig{ + Enabled: true, + Builders: []*proposer.BuilderEntry{ + {URL: "https://builder-a.example", AuthData: []byte("secret"), MaxExecutionPayment: u64(1000000000)}, + {URL: "https://builder-b.example"}, + }, + }, + }, + }, + } + + store, err := NewStore(t.TempDir(), nil) + require.NoError(t, err) + require.NoError(t, store.SaveProposerSettings(ctx, in)) + + got, err := store.ProposerSettings(ctx) + require.NoError(t, err) + require.DeepEqual(t, in, got) + + // The entry without auth_data must load back as nil, not empty-non-nil. + require.IsNil(t, got.ProposeConfig[key].BuilderConfig.Builders[1].AuthData) + + // An explicit empty list (use no builders) must survive the round-trip + // rather than reloading as nil (inherit). + in.ProposeConfig[key].BuilderConfig.Builders = []*proposer.BuilderEntry{} + require.NoError(t, store.SaveProposerSettings(ctx, in)) + got, err = store.ProposerSettings(ctx) + require.NoError(t, err) + require.NotNil(t, got.ProposeConfig[key].BuilderConfig.Builders) + require.Equal(t, 0, len(got.ProposeConfig[key].BuilderConfig.Builders)) +} diff --git a/validator/rpc/BUILD.bazel b/validator/rpc/BUILD.bazel index bc658b488329..779103d9e9a8 100644 --- a/validator/rpc/BUILD.bazel +++ b/validator/rpc/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "handlers_health.go", "handlers_keymanager.go", "handlers_slashing.go", + "handlers_validator_config.go", "intercepter.go", "log.go", "server.go", @@ -36,6 +37,7 @@ go_library( "//cmd/validator/flags:go_default_library", "//config/features:go_default_library", "//config/fieldparams:go_default_library", + "//config/params:go_default_library", "//config/proposer:go_default_library", "//consensus-types/primitives:go_default_library", "//consensus-types/validator:go_default_library", @@ -95,6 +97,7 @@ go_test( "handlers_health_test.go", "handlers_keymanager_test.go", "handlers_slashing_test.go", + "handlers_validator_config_test.go", "intercepter_test.go", "server_test.go", ], diff --git a/validator/rpc/handlers_keymanager.go b/validator/rpc/handlers_keymanager.go index ae7ec07d85f9..562f8ed8f07d 100644 --- a/validator/rpc/handlers_keymanager.go +++ b/validator/rpc/handlers_keymanager.go @@ -626,54 +626,15 @@ func (s *Server) SetFeeRecipientByPubkey(w http.ResponseWriter, r *http.Request) return } feeRecipient := common.BytesToAddress(ethAddress) - settings := s.validatorService.ProposerSettings() - switch { - case settings == nil: - settings = &proposer.Settings{ - ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ - bytesutil.ToBytes48(pubkey): { - FeeRecipientConfig: &proposer.FeeRecipientConfig{ - FeeRecipient: feeRecipient, - }, - BuilderConfig: nil, - }, - }, - DefaultConfig: nil, - } - case settings.ProposeConfig == nil: - var builderConfig *proposer.BuilderConfig - if settings.DefaultConfig != nil && settings.DefaultConfig.BuilderConfig != nil { - builderConfig = settings.DefaultConfig.BuilderConfig.Clone() + if err := s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if settings == nil { + // API-created settings carry no v1 content: v2 once gloas is scheduled. + settings = &proposer.Settings{Version: proposer.FreshSettingsVersion()} } - settings.ProposeConfig = map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ - bytesutil.ToBytes48(pubkey): { - FeeRecipientConfig: &proposer.FeeRecipientConfig{ - FeeRecipient: feeRecipient, - }, - BuilderConfig: builderConfig, - }, - } - default: - proposerOption, found := settings.ProposeConfig[bytesutil.ToBytes48(pubkey)] - if found && proposerOption != nil { - proposerOption.FeeRecipientConfig = &proposer.FeeRecipientConfig{ - FeeRecipient: feeRecipient, - } - } else { - var builderConfig = &proposer.BuilderConfig{} - if settings.DefaultConfig != nil && settings.DefaultConfig.BuilderConfig != nil { - builderConfig = settings.DefaultConfig.BuilderConfig.Clone() - } - settings.ProposeConfig[bytesutil.ToBytes48(pubkey)] = &proposer.Option{ - FeeRecipientConfig: &proposer.FeeRecipientConfig{ - FeeRecipient: feeRecipient, - }, - BuilderConfig: builderConfig, - } - } - } - // save the settings - if err := s.validatorService.SetProposerSettings(ctx, settings); err != nil { + // A newly created option leaves BuilderConfig nil so the key inherits default_config. + settings.UpsertProposeOption(bytesutil.ToBytes48(pubkey)).FeeRecipientConfig = &proposer.FeeRecipientConfig{FeeRecipient: feeRecipient} + return settings, nil + }); err != nil { httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusInternalServerError) return } @@ -695,16 +656,16 @@ func (s *Server) DeleteFeeRecipientByPubkey(w http.ResponseWriter, r *http.Reque return } - settings := s.validatorService.ProposerSettings() - if settings != nil && settings.ProposeConfig != nil { + if err := s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if settings == nil || settings.ProposeConfig == nil { + return nil, nil + } proposerOption, found := settings.ProposeConfig[bytesutil.ToBytes48(pubkey)] if found { proposerOption.FeeRecipientConfig = nil } - } - - // save the settings - if err := s.validatorService.SetProposerSettings(ctx, settings); err != nil { + return settings, nil + }); err != nil { httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusInternalServerError) return } @@ -766,13 +727,16 @@ func (s *Server) SetGasLimit(w http.ResponseWriter, r *http.Request) { return } - settings := s.validatorService.ProposerSettings() - if err := settings.SetGasLimit(bytesutil.ToBytes48(pubkey), validator.Uint64(gasLimit)); err != nil { - httputil.HandleError(w, err.Error(), http.StatusInternalServerError) - return - } - - if err := s.validatorService.SetProposerSettings(ctx, settings); err != nil { + if err := s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if settings == nil { + // API-created settings carry no v1 content: v2 once gloas is scheduled. + settings = &proposer.Settings{Version: proposer.FreshSettingsVersion()} + } + if err := settings.SetGasLimit(bytesutil.ToBytes48(pubkey), validator.Uint64(gasLimit)); err != nil { + return nil, err + } + return settings, nil + }); err != nil { httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusInternalServerError) return } @@ -793,13 +757,19 @@ func (s *Server) DeleteGasLimit(w http.ResponseWriter, r *http.Request) { return } - settings := s.validatorService.ProposerSettings() - if !settings.ResetGasLimit(bytesutil.ToBytes48(pubkey)) { - httputil.HandleError(w, fmt.Sprintf("No gas limit found for pubkey %q", rawPubkey), http.StatusNotFound) + reset := false + if err := s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if !settings.ResetGasLimit(bytesutil.ToBytes48(pubkey)) { + return nil, nil + } + reset = true + return settings, nil + }); err != nil { + httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusBadRequest) return } - if err := s.validatorService.SetProposerSettings(ctx, settings); err != nil { - httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusBadRequest) + if !reset { + httputil.HandleError(w, fmt.Sprintf("No gas limit found for pubkey %q", rawPubkey), http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) diff --git a/validator/rpc/handlers_keymanager_test.go b/validator/rpc/handlers_keymanager_test.go index 40a1f90fca7d..517c4d422e43 100644 --- a/validator/rpc/handlers_keymanager_test.go +++ b/validator/rpc/handlers_keymanager_test.go @@ -947,50 +947,34 @@ func TestServer_SetGasLimit(t *testing.T) { wantErr string }{ { - name: "ProposerSettings is nil", + name: "nil settings create fresh v2 settings with the option-level value", pubkey: pubkey1, newGasLimit: 9999, proposerSettings: nil, - wantErr: "No proposer settings were found to update", + w: []*want{{pubkey1, 9999}}, }, { - name: "ProposerSettings.ProposeConfig is nil AND ProposerSettings.DefaultConfig is nil", + name: "empty settings accept the write at the option level", pubkey: pubkey1, newGasLimit: 9999, proposerSettings: &proposer.Settings{ ProposeConfig: nil, DefaultConfig: nil, }, - wantErr: "Gas limit changes only apply when builder is enabled", + w: []*want{{pubkey1, 9999}}, }, { - name: "ProposerSettings.ProposeConfig is nil AND ProposerSettings.DefaultConfig.BuilderConfig is nil", + name: "no builder config anywhere still accepts the write", pubkey: pubkey1, newGasLimit: 9999, proposerSettings: &proposer.Settings{ ProposeConfig: nil, - DefaultConfig: &proposer.Option{ - BuilderConfig: nil, - }, + DefaultConfig: &proposer.Option{BuilderConfig: nil}, }, - wantErr: "Gas limit changes only apply when builder is enabled", + w: []*want{{pubkey1, 9999}}, }, { - name: "ProposerSettings.ProposeConfig is defined for pubkey, BuilderConfig is nil AND ProposerSettings.DefaultConfig is nil", - pubkey: pubkey1, - newGasLimit: 9999, - proposerSettings: &proposer.Settings{ - ProposeConfig: map[[48]byte]*proposer.Option{ - bytesutil.ToBytes48(pubkey1): { - BuilderConfig: nil, - }, - }, - DefaultConfig: nil, - }, - wantErr: "Gas limit changes only apply when builder is enabled", - }, - { - name: "ProposerSettings.ProposeConfig is defined for pubkey, BuilderConfig is defined AND ProposerSettings.DefaultConfig is nil", + name: "disabled builder no longer gates the write", pubkey: pubkey1, newGasLimit: 9999, proposerSettings: &proposer.Settings{ @@ -1001,10 +985,10 @@ func TestServer_SetGasLimit(t *testing.T) { }, DefaultConfig: nil, }, - wantErr: "Gas limit changes only apply when builder is enabled", + w: []*want{{pubkey1, 9999}}, }, { - name: "ProposerSettings.ProposeConfig is NOT defined for pubkey, BuilderConfig is defined AND ProposerSettings.DefaultConfig is nil", + name: "option-level write wins over an existing builder-level value", pubkey: pubkey2, newGasLimit: 9999, proposerSettings: &proposer.Settings{ @@ -1018,33 +1002,21 @@ func TestServer_SetGasLimit(t *testing.T) { }, DefaultConfig: nil, }, - w: []*want{{ - pubkey2, - 9999, - }, - }, + w: []*want{{pubkey2, 9999}}, }, { - name: "ProposerSettings.ProposeConfig is defined for pubkey, BuilderConfig is nil AND ProposerSettings.DefaultConfig.BuilderConfig is defined", + name: "write for a key with no option creates one", pubkey: pubkey1, newGasLimit: 9999, proposerSettings: &proposer.Settings{ ProposeConfig: map[[48]byte]*proposer.Option{ - bytesutil.ToBytes48(pubkey2): { - BuilderConfig: nil, - }, + bytesutil.ToBytes48(pubkey2): {BuilderConfig: nil}, }, DefaultConfig: &proposer.Option{ - BuilderConfig: &proposer.BuilderConfig{ - Enabled: true, - }, + BuilderConfig: &proposer.BuilderConfig{Enabled: true}, }, }, - w: []*want{{ - pubkey1, - 9999, - }, - }, + w: []*want{{pubkey1, 9999}}, }, } for _, isSlashingProtectionMinimal := range [...]bool{false, true} { @@ -1057,8 +1029,14 @@ func TestServer_SetGasLimit(t *testing.T) { return tt.proposerSettings.Clone() }).AnyTimes() var written *proposer.Settings - vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { - written = s + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(tt.proposerSettings.Clone()) + if err != nil { + return err + } + if next != nil { + written = next + } return nil }).AnyTimes() _ = written @@ -1090,7 +1068,8 @@ func TestServer_SetGasLimit(t *testing.T) { } else { assert.Equal(t, http.StatusAccepted, w.Code) for _, wantObj := range tt.w { - assert.Equal(t, wantObj.gaslimit, uint64(written.ProposeConfig[bytesutil.ToBytes48(wantObj.pubkey)].BuilderConfig.GasLimit)) + require.NotNil(t, written) + assert.Equal(t, wantObj.gaslimit, uint64(written.GasLimit(bytesutil.ToBytes48(wantObj.pubkey)))) } } }) @@ -1124,13 +1103,28 @@ func TestServer_SetGasLimit_InvalidPubKey(t *testing.T) { } func TestServer_SetGasLimit_NilSettings(t *testing.T) { + // Fresh settings are stamped v2 only once the network schedules gloas. + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 100 + params.OverrideBeaconConfig(cfg) pubkey, err := hexutil.Decode("0xaf2e7ba294e03438ea819bd4033c6c1bf6b04320ee2075b77273c08d02f8a61bcc303c2c06bd3713cb442072ae591493") require.NoError(t, err) validatorDB := dbtest.SetupDB(t, t.TempDir(), [][fieldparams.BLSPubkeyLength]byte{}, false) vs := validatormock.NewMockValidatorService(gomock.NewController(t)) vs.EXPECT().RemoteSignerConfig().Return(nil).AnyTimes() - vs.EXPECT().ProposerSettings().Return(nil).AnyTimes() + var written *proposer.Settings + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(nil) + if err != nil { + return err + } + if next != nil { + written = next + } + return nil + }) s := &Server{validatorService: vs, db: validatorDB} body, err := json.Marshal(&SetGasLimitRequest{GasLimit: "9999"}) @@ -1141,8 +1135,11 @@ func TestServer_SetGasLimit_NilSettings(t *testing.T) { w.Body = &bytes.Buffer{} s.SetGasLimit(w, req) - assert.NotEqual(t, http.StatusAccepted, w.Code) - require.StringContains(t, "No proposer settings were found to update", w.Body.String()) + // Nil settings are created fresh at v2 with the option-level value. + assert.Equal(t, http.StatusAccepted, w.Code) + require.NotNil(t, written) + assert.Equal(t, proposer.SchemaV2, written.Version) + assert.Equal(t, validator.Uint64(9999), written.ProposeConfig[bytesutil.ToBytes48(pubkey)].GasLimit) } func TestServer_DeleteGasLimit(t *testing.T) { @@ -1262,13 +1259,19 @@ func TestServer_DeleteGasLimit(t *testing.T) { vs.EXPECT().ProposerSettings().DoAndReturn(func() *proposer.Settings { return tt.proposerSettings.Clone() }).AnyTimes() + // The handler always runs the transactional update; a 404 row's + // mutate simply declines to write. var written *proposer.Settings - if tt.wantError == nil { - vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { - written = s - return nil - }) - } + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(tt.proposerSettings.Clone()) + if err != nil { + return err + } + if next != nil { + written = next + } + return nil + }).AnyTimes() s := &Server{ validatorService: vs, db: validatorDB, @@ -1313,8 +1316,14 @@ func TestServer_GasLimit_V2Schema(t *testing.T) { vs.EXPECT().RemoteSignerConfig().Return(nil).AnyTimes() vs.EXPECT().ProposerSettings().Return(settings).AnyTimes() var written *proposer.Settings - vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { - written = s + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(settings.Clone()) + if err != nil { + return err + } + if next != nil { + written = next + } return nil }).AnyTimes() return &Server{ @@ -1360,7 +1369,7 @@ func TestServer_GasLimit_V2Schema(t *testing.T) { assert.Equal(t, "42424242", resp.Data.GasLimit) }) - t.Run("DeleteGasLimit resets per-validator GasLimit to chain default on v2", func(t *testing.T) { + t.Run("DeleteGasLimit unsets the per-validator GasLimit on v2", func(t *testing.T) { params.BeaconConfig().DefaultBuilderGasLimit = uint64(0xbbdd) s, written := setupServer(t, &proposer.Settings{ Version: 2, @@ -1375,7 +1384,12 @@ func TestServer_GasLimit_V2Schema(t *testing.T) { s.DeleteGasLimit(w, req) assert.Equal(t, http.StatusNoContent, w.Code) - assert.Equal(t, validator.Uint64(0xbbdd), written().ProposeConfig[bytesutil.ToBytes48(pubkey1)].GasLimit) + // Unset rather than pinned to today's chain default, so the key follows + // future default gas limit increases; reads resolve the chain default. + ps := written() + require.NotNil(t, ps) + assert.Equal(t, validator.Uint64(0), ps.ProposeConfig[bytesutil.ToBytes48(pubkey1)].GasLimit) + assert.Equal(t, validator.Uint64(0xbbdd), ps.GasLimit(bytesutil.ToBytes48(pubkey1))) }) t.Run("DeleteGasLimit returns 404 on v2 when no per-validator entry exists", func(t *testing.T) { @@ -1763,8 +1777,14 @@ func TestServer_FeeRecipientByPubkey(t *testing.T) { return tt.proposerSettings.Clone() }).AnyTimes() var written *proposer.Settings - vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { - written = s + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(tt.proposerSettings.Clone()) + if err != nil { + return err + } + if next != nil { + written = next + } return nil }).AnyTimes() _ = written @@ -1875,8 +1895,14 @@ func TestServer_DeleteFeeRecipientByPubkey(t *testing.T) { return tt.proposerSettings.Clone() }).AnyTimes() var written *proposer.Settings - vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { - written = s + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(tt.proposerSettings.Clone()) + if err != nil { + return err + } + if next != nil { + written = next + } return nil }).AnyTimes() _ = written diff --git a/validator/rpc/handlers_validator_config.go b/validator/rpc/handlers_validator_config.go new file mode 100644 index 000000000000..81236ec70bee --- /dev/null +++ b/validator/rpc/handlers_validator_config.go @@ -0,0 +1,147 @@ +package rpc + +import ( + "encoding/json" + "net/http" + + "github.com/OffchainLabs/prysm/v7/beacon-chain/rpc/eth/shared" + fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" + "github.com/OffchainLabs/prysm/v7/config/params" + "github.com/OffchainLabs/prysm/v7/config/proposer" + "github.com/OffchainLabs/prysm/v7/encoding/bytesutil" + "github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace" + "github.com/OffchainLabs/prysm/v7/network/httputil" +) + +// requireGloasScheduled rejects builders configuration on networks that never +// activate it; the endpoints are meaningless without a gloas fork epoch. +func requireGloasScheduled(w http.ResponseWriter) bool { + if params.GloasEnabled() { + return true + } + httputil.HandleError(w, "Builders configuration requires a network with the gloas fork scheduled", http.StatusNotImplemented) + return false +} + +// GetBuilders implements GET /eth/v1/validator/{pubkey}/builders: the key's config resolved against default_config, safe to re-submit. +func (s *Server) GetBuilders(w http.ResponseWriter, r *http.Request) { + _, span := trace.StartSpan(r.Context(), "validator.keymanagerAPI.GetBuilders") + defer span.End() + + if !requireGloasScheduled(w) { + return + } + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return + } + _, pubkey, ok := shared.HexFromRoute(w, r, "pubkey", fieldparams.BLSPubkeyLength) + if !ok { + return + } + + // The Effective* getters resolve unset values (no floor, neutral boost, trustless-only). + eff := s.validatorService.ProposerSettings().EffectiveBuilderConfig(bytesutil.ToBytes48(pubkey)) + if eff == nil { + eff = &proposer.BuilderConfig{} + } + out := builderConfigFromConsensus(eff) + // The resolved response always states a concrete builders list. + if out.Builders == nil { + out.Builders = []*BuilderEntry{} + } + httputil.WriteJson(w, &GetBuildersResponse{Data: out}) +} + +// SetBuilders implements POST /eth/v1/validator/{pubkey}/builders, replacing the +// key's builder config in full; fee recipient, gas limit and graffiti are untouched. +func (s *Server) SetBuilders(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "validator.keymanagerAPI.SetBuilders") + defer span.End() + + if !requireGloasScheduled(w) { + return + } + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return + } + _, pubkey, ok := shared.HexFromRoute(w, r, "pubkey", fieldparams.BLSPubkeyLength) + if !ok { + return + } + + var body BuilderConfig + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + httputil.HandleError(w, "Could not decode builder config: "+err.Error(), http.StatusBadRequest) + return + } + bc, err := body.ToConsensus() + if err != nil { + httputil.HandleError(w, err.Error(), http.StatusBadRequest) + return + } + + err = s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + if settings == nil { + settings = &proposer.Settings{Version: proposer.FreshSettingsVersion()} + } else { + // Stamp the storage format only: semantics are fork-keyed, so writing + // builders never drops v1 content or changes other keys' behavior. + settings.Version = proposer.SchemaV2 + } + if settings.ProposeConfig == nil { + settings.ProposeConfig = make(map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option) + } + key := bytesutil.ToBytes48(pubkey) + opt := settings.ProposeConfig[key] + if opt == nil { + opt = &proposer.Option{} + settings.ProposeConfig[key] = opt + } + opt.BuilderConfig = bc + return settings, nil + }) + if err != nil { + httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusAccepted) +} + +// DeleteBuilders implements DELETE /eth/v1/validator/{pubkey}/builders: the key +// follows the validator client defaults again; not the same as builders: []. +func (s *Server) DeleteBuilders(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "validator.keymanagerAPI.DeleteBuilders") + defer span.End() + + if !requireGloasScheduled(w) { + return + } + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return + } + _, pubkey, ok := shared.HexFromRoute(w, r, "pubkey", fieldparams.BLSPubkeyLength) + if !ok { + return + } + + err := s.validatorService.UpdateProposerSettings(ctx, func(settings *proposer.Settings) (*proposer.Settings, error) { + // Removing an absent configuration succeeds; the key already follows the defaults. + if settings == nil || settings.ProposeConfig == nil { + return nil, nil + } + opt, found := settings.ProposeConfig[bytesutil.ToBytes48(pubkey)] + if !found || opt == nil || opt.BuilderConfig == nil { + return nil, nil + } + opt.BuilderConfig = nil + return settings, nil + }) + if err != nil { + httputil.HandleError(w, "Could not set proposer settings: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/validator/rpc/handlers_validator_config_test.go b/validator/rpc/handlers_validator_config_test.go new file mode 100644 index 000000000000..58b643a87e66 --- /dev/null +++ b/validator/rpc/handlers_validator_config_test.go @@ -0,0 +1,371 @@ +package rpc + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" + "github.com/OffchainLabs/prysm/v7/config/params" + "github.com/OffchainLabs/prysm/v7/config/proposer" + "github.com/OffchainLabs/prysm/v7/consensus-types/validator" + "github.com/OffchainLabs/prysm/v7/testing/require" + validatormock "github.com/OffchainLabs/prysm/v7/testing/validator-mock" + "github.com/OffchainLabs/prysm/v7/validator/keymanager/derived" + mocks "github.com/OffchainLabs/prysm/v7/validator/testing" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "go.uber.org/mock/gomock" +) + +// setupConfigServer builds a Server with a derived keymanager holding numKeys +// recovered accounts, and returns the server plus the known validating pubkeys. +// The builders endpoints require a gloas-scheduled network, so one is configured. +func setupConfigServer(t *testing.T, numKeys int) (*Server, [][48]byte) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 100 + params.OverrideBeaconConfig(cfg) + ctx := t.Context() + srv := setupServerWithWallet(t) + km, err := srv.validatorService.Keymanager() + require.NoError(t, err) + dr, ok := km.(*derived.Keymanager) + require.Equal(t, true, ok) + require.NoError(t, dr.RecoverAccountsFromMnemonic(ctx, mocks.TestMnemonic, derived.DefaultMnemonicLanguage, "", numKeys)) + keys, err := dr.FetchValidatingPublicKeys(ctx) + require.NoError(t, err) + require.Equal(t, numKeys, len(keys)) + // The CRUD tests exercise POST-then-GET round-trips, so the mocked service + // emulates the real store: reads return the stored settings, writes swap them. + vs, ok := srv.validatorService.(*validatormock.MockValidatorService) + require.Equal(t, true, ok) + var stored *proposer.Settings + vs.EXPECT().ProposerSettings().DoAndReturn(func() *proposer.Settings { + return stored + }).AnyTimes() + vs.EXPECT().SetProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *proposer.Settings) error { + stored = s + return nil + }).AnyTimes() + vs.EXPECT().UpdateProposerSettings(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error { + next, err := mutate(stored.Clone()) + if err != nil { + return err + } + if next != nil { + stored = next + } + return nil + }).AnyTimes() + return srv, keys +} + +func postBuilders(t *testing.T, s *Server, pubkey, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/eth/v1/validator/"+pubkey+"/builders", bytes.NewBufferString(body)) + req.SetPathValue("pubkey", pubkey) + w := httptest.NewRecorder() + w.Body = &bytes.Buffer{} + s.SetBuilders(w, req) + return w +} + +func getBuilders(t *testing.T, s *Server, pubkey string) (*httptest.ResponseRecorder, *BuilderConfig) { + req := httptest.NewRequest(http.MethodGet, "/eth/v1/validator/"+pubkey+"/builders", nil) + req.SetPathValue("pubkey", pubkey) + w := httptest.NewRecorder() + w.Body = &bytes.Buffer{} + s.GetBuilders(w, req) + cfg := &BuilderConfig{} + if w.Code == http.StatusOK { + resp := &GetBuildersResponse{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), resp)) + require.NotNil(t, resp.Data) + cfg = resp.Data + } + return w, cfg +} + +func deleteBuilders(t *testing.T, s *Server, pubkey string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/eth/v1/validator/"+pubkey+"/builders", nil) + req.SetPathValue("pubkey", pubkey) + w := httptest.NewRecorder() + w.Body = &bytes.Buffer{} + s.DeleteBuilders(w, req) + return w +} + +func TestServer_SetBuilders(t *testing.T) { + t.Run("round trip via GET", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + body := `{"min_bid":"5","builder_boost_factor":"120",` + + `"builders":[{"url":"https://b.example","auth_data":"0x0102","max_execution_payment":"1000"}]}` + + w := postBuilders(t, srv, pk, body) + require.Equal(t, http.StatusAccepted, w.Code) + + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, "5", *cfg.MinBid) + require.Equal(t, "120", *cfg.BuilderBoostFactor) + require.Equal(t, 1, len(cfg.Builders)) + require.Equal(t, "https://b.example", cfg.Builders[0].Url) + require.Equal(t, "1000", *cfg.Builders[0].MaxExecutionPayment) + // Resolved: the entry omitted min_bid/boost, so GET fills them from the defaults. + require.Equal(t, "5", *cfg.Builders[0].MinBid) + require.Equal(t, "120", *cfg.Builders[0].BuilderBoostFactor) + }) + + t.Run("full replace", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, + `{"builders":[{"url":"https://a.example"},{"url":"https://b.example"}]}`).Code) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, + `{"builders":[{"url":"https://c.example"}]}`).Code) + + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, 1, len(cfg.Builders)) + require.Equal(t, "https://c.example", cfg.Builders[0].Url) + }) + + t.Run("empty array is use-none", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, `{"builders":[]}`).Code) + + _, cfg := getBuilders(t, srv, pk) + require.NotNil(t, cfg.Builders) + require.Equal(t, 0, len(cfg.Builders)) + }) + + t.Run("preserves another key's use-none marker", func(t *testing.T) { + srv, keys := setupConfigServer(t, 2) + pkA := hexutil.Encode(keys[0][:]) + pkB := hexutil.Encode(keys[1][:]) + require.NoError(t, srv.validatorService.SetProposerSettings(t.Context(), &proposer.Settings{ + Version: proposer.SchemaV2, + DefaultConfig: &proposer.Option{ + BuilderConfig: &proposer.BuilderConfig{Builders: []*proposer.BuilderEntry{{URL: "https://default.example"}}}, + }, + })) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pkA, `{"builders":[]}`).Code) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pkB, `{"builders":[{"url":"https://b.example"}]}`).Code) + + _, cfg := getBuilders(t, srv, pkA) + require.Equal(t, 0, len(cfg.Builders)) + }) + + t.Run("gloas-only preferences keep the key's pre-fork registration", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + recipient := common.HexToAddress("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3") + require.NoError(t, srv.validatorService.SetProposerSettings(t.Context(), &proposer.Settings{ + Version: proposer.SchemaV1, + DefaultConfig: &proposer.Option{ + FeeRecipientConfig: &proposer.FeeRecipientConfig{FeeRecipient: recipient}, + BuilderConfig: &proposer.BuilderConfig{Enabled: true}, + }, + })) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, `{"min_bid":"5"}`).Code) + + // The POST expressed no registration choice, so the enabled default still applies. + _, _, enabled := srv.validatorService.ProposerSettings().RegistrationFor(keys[0]) + require.Equal(t, true, enabled) + + // An explicit empty list is a registration choice: it opts the key out. + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, `{"builders":[]}`).Code) + _, _, enabled = srv.validatorService.ProposerSettings().RegistrationFor(keys[0]) + require.Equal(t, false, enabled) + }) + + t.Run("upgrades v1 settings in place", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + require.NoError(t, srv.validatorService.SetProposerSettings(t.Context(), &proposer.Settings{ + Version: proposer.SchemaV1, + ProposeConfig: map[[fieldparams.BLSPubkeyLength]byte]*proposer.Option{ + keys[0]: {BuilderConfig: &proposer.BuilderConfig{Enabled: true, GasLimit: 999}}, + }, + })) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, `{"builders":[{"url":"https://a.example"}]}`).Code) + + got := srv.validatorService.ProposerSettings() + // Builder lists are v2 content: POST migrates the schema in place. + require.Equal(t, proposer.SchemaV2, got.Version) + opt := got.ProposeConfig[keys[0]] + // The v1 builder gas limit is dropped with the rest of the v1 builder content. + require.Equal(t, validator.Uint64(0), opt.GasLimit) + require.Equal(t, 1, len(opt.BuilderConfig.Builders)) + }) + + t.Run("builder_pubkeys ride along as the entry's response filter", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + bpk := "0x" + strings.Repeat("ab", 48) + bpk2 := "0x" + strings.Repeat("cd", 48) + body := `{"builders":[{"url":"https://a.example","builder_pubkeys":["` + bpk + `","` + bpk2 + `"]},{"url":"https://b.example"}]}` + + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, body).Code) + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, 2, len(cfg.Builders)) + require.Equal(t, "https://a.example", cfg.Builders[0].Url) + require.DeepEqual(t, []string{bpk, bpk2}, cfg.Builders[0].BuilderPubkeys) + // Omitted builder_pubkeys resolves to the empty list. + require.NotNil(t, cfg.Builders[1].BuilderPubkeys) + require.Equal(t, 0, len(cfg.Builders[1].BuilderPubkeys)) + }) + + t.Run("rejects invalid input", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + bpk := "0x" + strings.Repeat("ab", 48) + + longURL := "https://a.example/" + strings.Repeat("x", 2048) + cases := map[string]struct { + body, contains string + }{ + "entry without url": {`{"builders":[{"min_bid":"1"}]}`, "url is required"}, + "pubkey-only entry": {`{"builders":[{"builder_pubkeys":["` + bpk + `"]}]}`, "url is required"}, + "same url and auth_data": {`{"builders":[{"url":"https://a"},{"url":"https://a"}]}`, "share the same url and auth_data"}, + "omitted auth_data collides with its derived value": {`{"builders":[{"url":"https://a"},{"url":"https://a","auth_data":"` + hexutil.Encode([]byte("https://a")) + `"}]}`, "share the same url and auth_data"}, + "invalid url": {`{"builders":[{"url":"not a url"}]}`, "url is not a valid URL"}, + "url too long": {`{"builders":[{"url":"` + longURL + `"}]}`, "url exceeds 2048 bytes"}, + "invalid builder_pubkeys entry": {`{"builders":[{"url":"https://a","builder_pubkeys":["0x1234"]}]}`, "builder_pubkeys contains an invalid BLS public key"}, + "invalid auth_data hex": {`{"builders":[{"url":"https://a","auth_data":"0xzz"}]}`, "auth_data is not valid hex"}, + "auth_data too long": {`{"builders":[{"url":"https://a","auth_data":"0x` + strings.Repeat("ab", 4097) + `"}]}`, "auth_data must be 1 to 4096 bytes"}, + "auth_data empty": {`{"builders":[{"url":"https://a","auth_data":"0x"}]}`, "auth_data must be 1 to 4096 bytes"}, + "too many builder_pubkeys": {`{"builders":[{"url":"https://a","builder_pubkeys":[` + strings.TrimSuffix(strings.Repeat(`"`+bpk+`",`, 65), ",") + `]}]}`, "builder_pubkeys exceeds 64 keys"}, + "non-numeric min_bid": {`{"min_bid":"abc","builders":[]}`, "min_bid is not a valid uint64"}, + "null entry": {`{"builders":[null]}`, "builders[0] is null"}, + "non-numeric entry max": {`{"builders":[{"url":"https://a"},{"url":"https://b","max_execution_payment":"x"}]}`, "builders[1].max_execution_payment is not a valid uint64"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w := postBuilders(t, srv, pk, tc.body) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, true, strings.Contains(w.Body.String(), tc.contains), "body: %s", w.Body.String()) + }) + } + }) + + t.Run("same url with distinct auth_data is a legal pair", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + body := `{"builders":[{"url":"https://a","auth_data":"0x01"},{"url":"https://a","auth_data":"0x02"}]}` + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, body).Code) + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, 2, len(cfg.Builders)) + }) + + t.Run("max entries", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + entries := make([]string, 0, maxBuilderEntries+1) + for i := 0; i <= maxBuilderEntries; i++ { + entries = append(entries, `{"url":"https://b`+strconv.Itoa(i)+`.example"}`) + } + body := `{"builders":[` + strings.Join(entries, ",") + `]}` + w := postBuilders(t, srv, pk, body) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, true, strings.Contains(w.Body.String(), "exceeds 64 entries")) + }) + + t.Run("validator service nil", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + srv.validatorService = nil + w := postBuilders(t, srv, hexutil.Encode(keys[0][:]), `{"builders":[]}`) + require.Equal(t, http.StatusServiceUnavailable, w.Code) + }) +} + +func TestServer_GetBuilders(t *testing.T) { + // GET is fully resolved: omitted auth_data becomes the url's UTF-8 bytes, and + // unset values become the runtime fallbacks (no floor, neutral boost, trustless-only). + t.Run("nil proposer settings resolve to runtime defaults", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + // No settings were ever created: the nil-receiver chain must still + // produce a fully resolved response rather than panic or error. + w, cfg := getBuilders(t, srv, pk) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "0", *cfg.MinBid) + require.Equal(t, "100", *cfg.BuilderBoostFactor) + require.NotNil(t, cfg.Builders) + require.Equal(t, 0, len(cfg.Builders)) + }) + + t.Run("resolves omitted values", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, + `{"builders":[{"url":"https://a.example"}]}`).Code) + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, "0", *cfg.MinBid) + require.Equal(t, "100", *cfg.BuilderBoostFactor) + require.Equal(t, 1, len(cfg.Builders)) + require.Equal(t, hexutil.Encode([]byte("https://a.example")), *cfg.Builders[0].AuthData) + require.Equal(t, "0", *cfg.Builders[0].MinBid) + require.Equal(t, "100", *cfg.Builders[0].BuilderBoostFactor) + require.Equal(t, "0", *cfg.Builders[0].MaxExecutionPayment) + }) + + t.Run("resolves default_config for an unconfigured key", func(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + require.NoError(t, srv.validatorService.SetProposerSettings(t.Context(), &proposer.Settings{ + Version: proposer.SchemaV2, + DefaultConfig: &proposer.Option{ + BuilderConfig: &proposer.BuilderConfig{Builders: []*proposer.BuilderEntry{{URL: "https://default.example"}}}, + }, + })) + + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, 1, len(cfg.Builders)) + require.Equal(t, "https://default.example", cfg.Builders[0].Url) + }) +} + +func TestServer_Builders_RequireGloasScheduled(t *testing.T) { + // The default test config has no gloas fork epoch: all three endpoints refuse. + srv := &Server{} + pk := "0x" + strings.Repeat("ab", 48) + for name, w := range map[string]*httptest.ResponseRecorder{ + "get": getBuildersRecorder(srv, pk), + "post": postBuilders(t, srv, pk, `{"builders":[]}`), + "delete": deleteBuilders(t, srv, pk), + } { + require.Equal(t, http.StatusNotImplemented, w.Code, "endpoint: %s", name) + require.Equal(t, true, strings.Contains(w.Body.String(), "gloas fork scheduled"), "endpoint: %s", name) + } +} + +func getBuildersRecorder(s *Server, pubkey string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/eth/v1/validator/"+pubkey+"/builders", nil) + req.SetPathValue("pubkey", pubkey) + w := httptest.NewRecorder() + w.Body = &bytes.Buffer{} + s.GetBuilders(w, req) + return w +} + +func TestServer_DeleteBuilders(t *testing.T) { + srv, keys := setupConfigServer(t, 1) + pk := hexutil.Encode(keys[0][:]) + + // Removing an absent configuration succeeds. + require.Equal(t, http.StatusNoContent, deleteBuilders(t, srv, pk).Code) + + require.Equal(t, http.StatusAccepted, postBuilders(t, srv, pk, `{"builders":[{"url":"https://a"}]}`).Code) + require.Equal(t, http.StatusNoContent, deleteBuilders(t, srv, pk).Code) + + // After delete the key follows defaults (no per-key builders). + _, cfg := getBuilders(t, srv, pk) + require.Equal(t, 0, len(cfg.Builders)) +} diff --git a/validator/rpc/server.go b/validator/rpc/server.go index 3df47bd5d6b9..39b7e76539e4 100644 --- a/validator/rpc/server.go +++ b/validator/rpc/server.go @@ -33,6 +33,7 @@ type ValidatorService interface { RemoteSignerConfig() *remoteweb3signer.SetupConfig ProposerSettings() *proposer.Settings SetProposerSettings(ctx context.Context, settings *proposer.Settings) error + UpdateProposerSettings(ctx context.Context, mutate func(*proposer.Settings) (*proposer.Settings, error)) error Graffiti(ctx context.Context, pubKey [fieldparams.BLSPubkeyLength]byte) ([]byte, error) SetGraffiti(ctx context.Context, pubKey [fieldparams.BLSPubkeyLength]byte, graffiti []byte) error DeleteGraffiti(ctx context.Context, pubKey [fieldparams.BLSPubkeyLength]byte) error @@ -221,6 +222,9 @@ func (s *Server) InitializeRoutes() error { s.router.HandleFunc("GET /eth/v1/validator/{pubkey}/graffiti", s.GetGraffiti) s.router.HandleFunc("POST /eth/v1/validator/{pubkey}/graffiti", s.SetGraffiti) s.router.HandleFunc("DELETE /eth/v1/validator/{pubkey}/graffiti", s.DeleteGraffiti) + s.router.HandleFunc("GET /eth/v1/validator/{pubkey}/builders", s.GetBuilders) + s.router.HandleFunc("POST /eth/v1/validator/{pubkey}/builders", s.SetBuilders) + s.router.HandleFunc("DELETE /eth/v1/validator/{pubkey}/builders", s.DeleteBuilders) // auth endpoint s.router.HandleFunc("GET "+api.WebUrlPrefix+"initialize", s.Initialize) diff --git a/validator/rpc/structs.go b/validator/rpc/structs.go index 77bfabff8f94..30460f95765d 100644 --- a/validator/rpc/structs.go +++ b/validator/rpc/structs.go @@ -2,16 +2,20 @@ package rpc import ( "fmt" + "net/url" "strconv" "github.com/OffchainLabs/prysm/v7/api/server" "github.com/OffchainLabs/prysm/v7/api/server/structs" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" + "github.com/OffchainLabs/prysm/v7/config/proposer" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" + "github.com/OffchainLabs/prysm/v7/consensus-types/validator" "github.com/OffchainLabs/prysm/v7/encoding/bytesutil" eth "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v7/validator/keymanager" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/pkg/errors" ) // local keymanager api @@ -109,6 +113,173 @@ type GraffitiData struct { Graffiti string `json:"graffiti"` } +type GetBuildersResponse struct { + Data *BuilderConfig `json:"data"` +} + +// BuilderConfig is the keymanager-APIs #88 wire form: integers are decimal +// strings, bytes 0x-hex. Builders nil = inherit, [] = use none (kept by no omitempty). +type BuilderConfig struct { + MinBid *string `json:"min_bid,omitempty"` + BuilderBoostFactor *string `json:"builder_boost_factor,omitempty"` + Builders []*BuilderEntry `json:"builders"` +} + +type BuilderEntry struct { + Url string `json:"url"` + AuthData *string `json:"auth_data,omitempty"` + BuilderPubkeys []string `json:"builder_pubkeys"` + MaxExecutionPayment *string `json:"max_execution_payment,omitempty"` + MinBid *string `json:"min_bid,omitempty"` + BuilderBoostFactor *string `json:"builder_boost_factor,omitempty"` +} + +const ( + maxBuilderEntries = 64 // MAX_BUILDER_ENTRIES + maxBuilderURLSize = 2048 // MAX_BUILDER_URL_SIZE + maxAuthDataSize = 4096 // MAX_DATA_SIZE + maxBuilderPubkeys = 64 // MAX_BUILDER_PUBKEYS +) + +func builderConfigFromConsensus(bc *proposer.BuilderConfig) *BuilderConfig { + out := &BuilderConfig{ + MinBid: new(strconv.FormatUint(uint64(bc.EffectiveMinBid()), 10)), + BuilderBoostFactor: new(strconv.FormatUint(uint64(bc.EffectiveBuilderBoostFactor()), 10)), + } + if bc.Builders != nil { + out.Builders = make([]*BuilderEntry, 0, len(bc.Builders)) + for _, b := range bc.Builders { + out.Builders = append(out.Builders, builderEntryFromConsensus(b, bc)) + } + } + return out +} + +func builderEntryFromConsensus(be *proposer.BuilderEntry, bc *proposer.BuilderConfig) *BuilderEntry { + out := &BuilderEntry{ + // Omitted builder_pubkeys resolves to the empty list (accept any builder). + BuilderPubkeys: make([]string, 0, len(be.Pubkeys)), + MaxExecutionPayment: new(strconv.FormatUint(uint64(be.EffectiveMaxExecutionPayment(bc)), 10)), + MinBid: new(strconv.FormatUint(uint64(be.EffectiveMinBid(bc)), 10)), + BuilderBoostFactor: new(strconv.FormatUint(uint64(be.EffectiveBuilderBoostFactor(bc)), 10)), + } + if be.URL != "" { + out.Url = be.URL + out.AuthData = new(hexutil.Encode(be.EffectiveAuthData())) + } + for _, pk := range be.Pubkeys { + out.BuilderPubkeys = append(out.BuilderPubkeys, hexutil.Encode(pk)) + } + return out +} + +func (in *BuilderConfig) ToConsensus() (*proposer.BuilderConfig, error) { + bc := &proposer.BuilderConfig{} + if in.MinBid != nil { + v, err := parseUint(*in.MinBid, "min_bid") + if err != nil { + return nil, err + } + bc.MinBid = &v + } + if in.BuilderBoostFactor != nil { + v, err := parseUint(*in.BuilderBoostFactor, "builder_boost_factor") + if err != nil { + return nil, err + } + bc.BuilderBoostFactor = &v + } + if in.Builders == nil { + return bc, nil + } + if len(in.Builders) > maxBuilderEntries { + return nil, errors.Errorf("builders exceeds %d entries", maxBuilderEntries) + } + // Non-nil (possibly empty) list means "use exactly these builders", not "inherit". + // Omitted auth_data compares as its derived value, so it collides with the explicit form. + bc.Builders = make([]*proposer.BuilderEntry, 0, len(in.Builders)) + seen := make(map[proposer.EntryIdentity]bool, len(in.Builders)) + for i, entry := range in.Builders { + if entry == nil { + return nil, errors.Errorf("builders[%d] is null", i) + } + be, err := entry.ToConsensus(i) + if err != nil { + return nil, err + } + if seen[be.Identity()] { + return nil, errors.Errorf("builders[%d]: two entries share the same url and auth_data", i) + } + seen[be.Identity()] = true + bc.Builders = append(bc.Builders, be) + } + return bc, nil +} + +func (in *BuilderEntry) ToConsensus(i int) (*proposer.BuilderEntry, error) { + be := &proposer.BuilderEntry{} + if in.Url == "" { + return nil, errors.Errorf("builders[%d].url is required", i) + } + if len(in.Url) > maxBuilderURLSize { + return nil, errors.Errorf("builders[%d].url exceeds %d bytes", i, maxBuilderURLSize) + } + if u, err := url.Parse(in.Url); err != nil || u.Scheme == "" || u.Host == "" { + return nil, errors.Errorf("builders[%d].url is not a valid URL", i) + } + be.URL = in.Url + if len(in.BuilderPubkeys) > maxBuilderPubkeys { + return nil, errors.Errorf("builders[%d].builder_pubkeys exceeds %d keys", i, maxBuilderPubkeys) + } + for _, raw := range in.BuilderPubkeys { + pk, err := hexutil.Decode(raw) + if err != nil || len(pk) != fieldparams.BLSPubkeyLength { + return nil, errors.Errorf("builders[%d].builder_pubkeys contains an invalid BLS public key", i) + } + be.Pubkeys = append(be.Pubkeys, pk) + } + if in.AuthData != nil { + ad, err := hexutil.Decode(*in.AuthData) + if err != nil { + return nil, errors.Errorf("builders[%d].auth_data is not valid hex", i) + } + if len(ad) == 0 || len(ad) > maxAuthDataSize { + return nil, errors.Errorf("builders[%d].auth_data must be 1 to %d bytes", i, maxAuthDataSize) + } + be.AuthData = ad + } + if in.MaxExecutionPayment != nil { + v, err := parseUint(*in.MaxExecutionPayment, fmt.Sprintf("builders[%d].max_execution_payment", i)) + if err != nil { + return nil, err + } + be.MaxExecutionPayment = &v + } + if in.MinBid != nil { + v, err := parseUint(*in.MinBid, fmt.Sprintf("builders[%d].min_bid", i)) + if err != nil { + return nil, err + } + be.MinBid = &v + } + if in.BuilderBoostFactor != nil { + v, err := parseUint(*in.BuilderBoostFactor, fmt.Sprintf("builders[%d].builder_boost_factor", i)) + if err != nil { + return nil, err + } + be.BuilderBoostFactor = &v + } + return be, nil +} + +func parseUint(s, field string) (validator.Uint64, error) { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, errors.Errorf("%s is not a valid uint64", field) + } + return validator.Uint64(v), nil +} + type BeaconStatusResponse struct { BeaconNodeEndpoint string `json:"beacon_node_endpoint"` Connected bool `json:"connected"`