Skip to content

Add GET/POST/DELETE /eth/v1/validator/{pubkey}/builders keymanager endpoints - #17261

Open
james-prysm wants to merge 29 commits into
developfrom
km88-proposer-settings
Open

Add GET/POST/DELETE /eth/v1/validator/{pubkey}/builders keymanager endpoints#17261
james-prysm wants to merge 29 commits into
developfrom
km88-proposer-settings

Conversation

@james-prysm

@james-prysm james-prysm commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

Feature

What does this PR do? Why is it needed?

Adds the per-key builder configuration endpoints from [keymanager-APIs #88](ethereum/keymanager-APIs#88) and the v2 proposer-settings schema that backs them. This is how an operator tells the validator client which external builders each key may source gloas blocks from, and under what per-builder limits.

New endpointsGET/POST/DELETE /eth/v1/validator/{pubkey}/builders:

  • POST replaces the key's builder configuration in full. enabled is required; each entry requires a url; no two entries may share (url, auth_data) (an omitted auth_data compares as its derived value, the UTF-8 bytes of the url). Violations are rejected with 400 rather than partially stored.
  • GET returns the configuration fully resolved: omitted entry fields are filled with the values that will be used (key defaults, then validator client config, then runtime fallbacks — no floor, neutral boost, trustless-only payment ceiling), so re-submitting a GET response pins those values.
  • DELETE removes the key's configuration so it follows the validator client again (204, also when nothing was set). This differs from enabled: false, which is a stored statement that the key sources no builder bids at all.
  • The builders list is tri-state: omitted = use the validator client's builders, [] = use none (p2p bids only), a list = use exactly these. The use-none marker survives persistence via a proto presence field.

v2 proposer settings ("version": 2): gas limit moves to the option level (a validator property, not a builder property), builder configs gain the builders[] entry list plus min_bid/builder_boost_factor defaults, and per-key fields resolve with field-level inheritance from default_config. v1 settings keep their exact object-level semantics until migrated. The unused relays field is removed.

Timeline across the fork

  • Before gloas: v1 settings behave byte-for-byte as on develop (registration, fee recipients, gas limits). The new endpoints are live; POST /builders migrates the node's settings to v2 in place (builder lists are v2 content, so writing them is the opt-in). v1 settings that contain builder content log a deprecation warning at startup; fee-recipient/graffiti-only settings never do.
  • One epoch before gloas: for keys with v2 builder configs, the VC starts pre-signing RequestAuths and submitting ahead-of-time builder preferences for upcoming proposal slots, so preferences are in place at the beacon node for the fork's first slot. v1 settings have nothing legitimate to warm and stay inert.
  • At and after gloas: any remaining v1 settings migrate automatically on the first push cycle of the fork epoch — idempotent and value-preserving (gas limits hoisted to the option level and resolving to the same values; an explicit max_execution_payment: 0 survives). mev-boost registration naturally ends. Keys whose migrated config has no builders entries solicit no builder bids and produce blocks locally: safe, but outside the builder market until the operator opts in.

Design decisions and tradeoffs

  1. Version follows content. POST /builders upgrades to v2 immediately (explicit, authenticated opt-in); the fork is the automatic backstop for everyone else; fee recipient and graffiti are schema-neutral and never touch the version. Tradeoff: one pre-fork POST switches the whole node to v2 semantics — accepted as an explicit admin action; a store-without-upgrade variant was tried and rejected as a worse contract (content sitting in version limbo until the fork).
  2. Migration carries nothing into the builder market. relays/--enable-builder do not translate into builder entries; participation post-gloas requires an explicitly configured list. Tradeoff: do-nothing operators lose builder revenue but never miss a proposal — the right default when the fork changes who is trusted with payments.
  3. --enable-builder keeps its v1 meaning: it forces the default builder toggle on, even over an explicit false in the default config. Per-key enabled: false still opts a key out (the spec's escape hatch). Tradeoff: flag-over-config at the default level, chosen for continuity — a v1 file plus the flag produces the same result before and after migration.
  4. File/URL settings stay authoritative wholesale: restarting with a file resets per-key state, exactly as today, because there is no other reliable way to clear the DB. Tradeoff: API writes are ephemeral for file users (matching the existing fee-recipient endpoints); the reset path stays predictable.
  5. Current preferences wire is builder-URL-keyed, so same-url entries collapse to the lowest (safest) payment ceiling, and per-entry min_bid/builder_boost_factor/builder_pubkey/custom auth_data are stored and returned by GET but only take effect with the beacon-APIs Updates pubkey from bytes32 to bytes #630 inline produce wire (marked with TODO(gloas) at both the producer and consumer).

Testing plan (kurtosis)

Config (gloas-km88.yml), gloas at epoch 3 to leave pre-fork time for API calls:

participants:
  - el_type: geth
    el_image: ethpandaops/geth:bal-devnet-6
    cl_type: prysm
    cl_image: gcr.io/offchainlabs/prysm/beacon-chain:latest
    vc_image: gcr.io/offchainlabs/prysm/validator:latest
    supernode: true
    count: 2
    cl_extra_params:
      - --verbosity=debug
    vc_extra_params:
      - --verbosity=debug
      - --http-host=0.0.0.0
      - --http-port=7500

network_params:
  fulu_fork_epoch: 0
  gloas_fork_epoch: 3
  seconds_per_slot: 6
  genesis_delay: 40

additional_services:
  - dora

Setup: kurtosis run --enclave km88 github.com/ethpandaops/ethereum-package --args-file gloas-km88.yml, then from inside the first VC container grab a validating pubkey (PK) and the keymanager token (TOKEN, from the wallet dir's auth-token file). All calls below run inside that container against http://127.0.0.1:7500.

A — API CRUD and resolution (pre-fork, epoch 0–2):

  1. GET /eth/v1/validator/$PK/builders → 200 with enabled: false, builders: [], resolved fallbacks (min_bid "0", builder_boost_factor "100").
  2. POST {"enabled":true,"min_bid":"1000","builders":[{"url":"https://builder-a.example","max_execution_payment":"250000000"}]} → 202.
  3. GET again → entry returned with auth_data resolved to the hex of the url bytes, entry min_bid "1000" inherited from the key default.
  4. POST a duplicate (url, auth_data) pair and an entry without url → both 400, config unchanged.
  5. DELETE → 204; GET shows defaults again; second DELETE → 204.

Which issue(s) does this PR fix?

addresse ethereum/keymanager-APIs#88

Other notes for review

Acknowledgements

  • I have read CONTRIBUTING.md.
  • I have included a uniquely named changelog fragment file.
  • I have added a description with sufficient context for reviewers to understand this PR.
  • I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).

@james-prysm james-prysm changed the title initial commit implementing https://github.com/ethereum/keymanager-AP… Add GET/POST/DELETE /eth/v1/validator/{pubkey}/builders keymanager endpoints Jul 29, 2026
];
repeated string relays = 3;
reserved 3;
reserved "relays";

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.

for the reviewer, let me know what you think if we really need this value, i think removing it outright is a breaking change possibly but it's never been used before so not sure if i can just remove it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it's OK to keep it

@james-prysm
james-prysm marked this pull request as ready for review August 7, 2026 21:24
@james-prysm

Copy link
Copy Markdown
Contributor Author

pr changes how bid pathing works and affects #17124

return nil, errors.Wrapf(err, "could not unmarshal %s", cleanedConfigFilePath)
}

// yaml.Unmarshal converts nil array to empty array.

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 never used the relay field to begin with so just get rid of it

Comment thread validator/rpc/handlers_validator_config.go Outdated
Comment thread validator/rpc/handlers_validator_config.go Outdated
Comment thread validator/rpc/structs.go Outdated
@syjn99
syjn99 self-requested a review August 10, 2026 12:22
james-prysm and others added 2 commits August 10, 2026 07:01
Co-authored-by: Jun Song <87601811+syjn99@users.noreply.github.com>
Co-authored-by: Jun Song <87601811+syjn99@users.noreply.github.com>
@james-prysm

Copy link
Copy Markdown
Contributor Author

for the reviewer, lets leave the enable-builder flag fixes/changes to a separate pr, the ux is akward here but i don't want to clutter this pr with more

Comment thread config/proposer/loader/loader.go Outdated
promote(opt)
}
if dropped {
log.Warn("v1 builder settings do not apply to the v2 schema and were replaced with defaults; provide v2 proposer settings to configure builders")

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.

i'm choosing to force promote those who didn't update their configs themselves, what do you guys think?


// v2 has no enabled toggle: participation follows the configured builders
// list, so --enable-builder has nothing to force on.
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)

return targets
}

func uint64Ptr(v *validatortypes.Uint64) *uint64 {

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.

maybe i should move this? not sure if this type should live here

Comment thread validator/rpc/structs.go
return be, nil
}

func parseUint(s, field string) (validator.Uint64, error) {

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.

maybe i should move this somewhere else as a helper?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it's harmless to use strconv.ParseUint(s, 10, 64) every time, and decide the caller to create a new error with field.

… sure we only use gas limits on explicitly set upgraded settings
@james-prysm

Copy link
Copy Markdown
Contributor Author

i explicitly gated the new endpoints in this pr until gloas is set, we can decide in a follow up if we want that vs allowing it on release that focuses mroe on the fork transition aspects ( will update pr description)

Comment thread validator/rpc/structs.go
type BuilderEntry struct {
Url string `json:"url"`
AuthData *string `json:"auth_data,omitempty"`
BuilderPubkeys []string `json:"builder_pubkeys"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The builder BLS public keys this entry accepts bids from. Empty or omitted accepts any builder; otherwise a bid not signed by one of them MUST NOT be accepted.

Do we have to make this field required?

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.

I think post requests accept it omitted and only get is required

Comment thread validator/db/kv/proposer_settings_test.go
];
repeated string relays = 3;
reserved 3;
reserved "relays";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it's OK to keep it

Comment thread config/proposer/settings.go Outdated
Comment thread config/proposer/loader/loader.go Outdated
Comment thread validator/rpc/handlers_validator_config.go
Comment thread config/proposer/settings.go

@syjn99 syjn99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One other comment is that we should rewrite our documentation that mentions about the version system of proposer settings. Also would be great if we can restructurize the docs - this guide is under fee recipient section.

Comment thread config/proposer/loader/loader.go
Comment thread validator/rpc/server.go Outdated
walletDir string
jwtSecret []byte
grpcHeaders []string
proposerSettingsLock sync.Mutex

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we really need the serialization for read and write, why don't we use atomic.Pointer[proposer.Settings] in validator struct instead of the Server managing the lock? Or we might add a lock in validator struct.

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.

I don't think atomic.Pointer is enough in this case i think we need s lock anyways. probably something to deal with in a followup pr though

@@ -129,16 +129,53 @@ message ProposerOptionPayload {

// BuilderConfig is a property of ProposerOptionPayload
message BuilderConfig {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe marking each fields as deprecated like: [ deprecated = true ]?

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.

post gloas we can probably just delete those fields, not sure if marking deprecated as true works if still used in v1

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit, to make this method "upsert" on the ps, I'd pass a callback function as an argument and let it mutate the Option: mutate func(*Option) *Option so it'll look like:

	settings.UpsertProposeOption(bytesutil.ToBytes48(pubkey), func(opt *proposer.Option) {
		opt.FeeRecipientConfig = &proposer.FeeRecipientConfig{FeeRecipient: feeRecipient}
	})

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.

hmm looking at it, it doesn't seem to remove any duplication, is there another reason to do this? ( i haven't set this up yet)

(ethereum.eth.ext.cast_type) =
"github.com/OffchainLabs/prysm/v7/consensus-types/validator.Uint64"
];
optional uint64 max_execution_payment = 5 [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maxExecutionPayments sync.Map // validator pubkey [48]byte -> max execution payment (Gwei uint64).

Seems like we still has a map from pubkey -> max_execution_payment value. So does this mean per-entry max_execution_payment is not enforced?

@james-prysm

Copy link
Copy Markdown
Contributor Author

One other comment is that we should rewrite our documentation that mentions about the version system of proposer settings. Also would be great if we can restructurize the docs - this guide is under fee recipient section.

yes that's a needed step i think it should be part of a followup pr as even this pr has some tradeoffs we still need to address in a followup

Comment thread config/proposer/settings.go Outdated
// UpgradeToV2 is the v1 cutover: v1 builder configs, including their gas limits,
// do not apply to gloas and are dropped. Returns true if anything changed.
func (ps *Settings) UpgradeToV2() bool {
if ps == nil || ps.isV2() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this means v1 builder content that ends up under a v2 stamp is never cleaned up

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.

Comment thread config/proposer/settings.go Outdated
if opt.GasLimit == 0 {
opt.GasLimit = opt.BuilderConfig.GasLimit
}
opt.BuilderConfig = nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this also drops an explicit --suggested-gas-limit

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.

I think we should handle this in a sebsequent pr, i'm afraid of using this to override the schedule numbers and should think more about it, let me see what I can do to make sure it's cut off for now

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.

Comment thread validator/client/validator.go Outdated
}
// Pointer changed = a keymanager write landed after our snapshot; swapping
// our stale clone would erase it. This cleanup simply reruns next cycle.
if v.ProposerSettings() != snapshot {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think there's a race, the keymanager handlers serialize on the server's proposerSettingsLock but this path takes no lock

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.

crap i think you're right let me see how i can fix this

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.

57a9174#diff-22d828bd0d0814ea79cf0313093e66de9c290b6e89166ae373e49d5da8aff21a passed in a mutation function i think it fixes it but will double check again

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Unassigned

Development

Successfully merging this pull request may close these issues.

3 participants