Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4c09570
initial commit implementing https://github.com/ethereum/keymanager-AP…
james-prysm Jul 29, 2026
27698ca
gaz
james-prysm Jul 30, 2026
f48d53b
Merge branch 'develop' into km88-proposer-settings
james-prysm Jul 31, 2026
602d824
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 3, 2026
f4c3354
updating based on removal of p2p boost config
james-prysm Aug 3, 2026
df8afee
self review
james-prysm Aug 5, 2026
cc92577
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 5, 2026
ea814aa
make changelog more concise
james-prysm Aug 6, 2026
4f25f60
rolling back some flag overrides
james-prysm Aug 7, 2026
40486bd
reverting change
james-prysm Aug 7, 2026
aa6247b
clarifying changelog
james-prysm Aug 7, 2026
586904e
self review on comments
james-prysm Aug 7, 2026
ba99fc3
moving functions around after self review
james-prysm Aug 7, 2026
d9b6989
more bug fixes and missing items after self review
james-prysm Aug 7, 2026
0d863aa
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 7, 2026
3429854
Update validator/rpc/structs.go
james-prysm Aug 10, 2026
29ea6a0
Update validator/rpc/handlers_validator_config.go
james-prysm Aug 10, 2026
2a365bc
remove proxy that is no longer used
james-prysm Aug 10, 2026
e68f2fe
remove enabled tag and use builders tag instead
james-prysm Aug 11, 2026
4ea3e1a
gaz
james-prysm Aug 11, 2026
9698cf1
self review points, don't migrate gas limit in v1 and take in in v2 i…
james-prysm Aug 11, 2026
bf4fd8c
fixing test order
james-prysm Aug 11, 2026
3cd047b
adding gloas gates for endpoints, fixing bugs after self review, make…
james-prysm Aug 11, 2026
da52e2e
gaz
james-prysm Aug 11, 2026
1526ec2
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 12, 2026
ae648dc
jun's comments
james-prysm Aug 12, 2026
3234435
adding test
james-prysm Aug 12, 2026
57a9174
terence's comments
james-prysm Aug 13, 2026
934cc8d
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 13, 2026
1ebba34
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 13, 2026
25d6063
adding test
james-prysm Aug 13, 2026
7a95158
Merge branch 'develop' into km88-proposer-settings
james-prysm Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelog/james-prysm_keymanager-builders-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
### 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`): builder fields a key does not set inherit from `default_config`; v2 has no `enabled` field. A settings source without a `version` that contains v2 builder fields (`builders`, `min_bid`, `builder_boost_factor`, `max_execution_payment`) is treated as version 2 automatically. Proposer-settings semantics are keyed on the gloas fork, not the schema version: before the fork the legacy mev-boost rules apply (with a resolved nonempty `builders` list also opting a key in, and an explicit empty list opting it out), and from the fork on only v2 content is read. Writing builders via the keymanager API never changes other keys' behavior or drops v1 content.
- 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.

### 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.
3 changes: 2 additions & 1 deletion cmd/prysmctl/validator/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions cmd/prysmctl/validator/proposer_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion config/proposer/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,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",
Expand Down
98 changes: 98 additions & 0 deletions config/proposer/effective_builder_config_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
126 changes: 86 additions & 40 deletions config/proposer/loader/loader.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package loader

import (
"encoding/json"
"fmt"
"strconv"

Expand Down Expand Up @@ -132,13 +131,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("proposerKeys", len(dbSettings.ProposerConfig)).
Comment thread
syjn99 marked this conversation as resolved.
Outdated
Debug("Loaded proposer settings from DB")
}

// start to process based on load method
Expand Down Expand Up @@ -186,18 +181,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
Expand Down Expand Up @@ -231,6 +214,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
}
Expand All @@ -243,6 +228,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
}
Expand Down Expand Up @@ -271,8 +258,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
}

Expand All @@ -286,11 +272,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) {
Comment thread
syjn99 marked this conversation as resolved.
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

Expand All @@ -304,17 +347,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)
Expand All @@ -331,25 +369,33 @@ 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)

// --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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we can change this in a subsequent pr ( or if there's something temporary we can do it here too)

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)
}

if gasLimitOnly == nil {
Expand Down Expand Up @@ -383,7 +429,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
}
Expand Down
Loading
Loading