Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4bf41f6
poc jit builder settings
james-prysm Jul 20, 2026
204cd18
poc additional builder changes
james-prysm Jul 20, 2026
43b7544
fixing auth bug
james-prysm Jul 20, 2026
ef15d45
fixing implicit builder activation and also fixing inheritance of set…
james-prysm Jul 20, 2026
6388b98
self review
james-prysm Jul 20, 2026
c5975aa
reverting builder preference removal
james-prysm Jul 21, 2026
10d4b2e
pocs implementing changes from https://github.com/ethereum/beacon-API…
james-prysm Jul 21, 2026
f7c4e12
handle duplicate urls correctly
james-prysm Jul 21, 2026
feb11d0
error was handled incorrectly updating
james-prysm Jul 21, 2026
f207282
Merge branch 'develop' into jit-builder-settings
james-prysm Jul 21, 2026
9745127
updating error
james-prysm Jul 21, 2026
97eba94
copilot suggestions
james-prysm Jul 21, 2026
47d3aba
make sure builder boost is per builder
james-prysm Jul 21, 2026
93ef8e5
Merge branch 'develop' into jit-builder-settings
james-prysm Jul 21, 2026
b788882
regenerate validator mocks
james-prysm Jul 21, 2026
b67300f
fixing bugs such as dedupe builders on settings, adding missed builde…
james-prysm Jul 22, 2026
c256348
fixing issues around enable builder, api endpoint and making sure emp…
james-prysm Jul 22, 2026
bce03d7
wip
james-prysm Jul 24, 2026
ef51585
keymanager api from 87 to https://github.com/ethereum/keymanager-API…
james-prysm Jul 27, 2026
967744a
adding builder api changes
james-prysm Jul 27, 2026
d65b72b
update based on 88's feedback
james-prysm Jul 28, 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
4 changes: 2 additions & 2 deletions api/client/builder/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func (c *Client) doWithStatus(ctx context.Context, method string, path string, b

u := c.baseURL.ResolveReference(&url.URL{Path: path})

span.SetAttributes(trace.StringAttribute("url", u.String()),
span.SetAttributes(trace.StringAttribute("url", logs.MaskCredentialsLogging(u.String())),
trace.StringAttribute("method", method))

req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
Expand Down Expand Up @@ -794,7 +794,7 @@ func unexpectedStatusErr(response *http.Response, expected []int) error {
} else {
body = "response body:\n" + string(bodyBytes)
}
msg := fmt.Sprintf("expected=%v, got=%d, url=%s, body=%s", expected, response.StatusCode, response.Request.URL, body)
msg := fmt.Sprintf("expected=%v, got=%d, url=%s, body=%s", expected, response.StatusCode, logs.MaskCredentialsLogging(response.Request.URL.String()), body)

var sentinel error
switch response.StatusCode {
Expand Down
60 changes: 33 additions & 27 deletions api/client/builder/client_gloas.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/OffchainLabs/prysm/v7/api"
"github.com/OffchainLabs/prysm/v7/api/server/structs"
Expand Down Expand Up @@ -57,34 +59,38 @@ func (c *Client) getExecutionPayloadBid(
auth *ethpb.SignedRequestAuthV1,
ssz bool,
) (*ethpb.SignedExecutionPayloadBid, error) {
accept := api.JsonMediaType
// builder-specs #165: the signed auth body is required and RequestAuthV1 is not
// fork-versioned, so no Eth-Consensus-Version header is sent on the request.
if auth == nil {
return nil, errors.New("request auth is required for the bid request")
}
accept, contentType := api.JsonMediaType, api.JsonMediaType
if ssz {
accept = api.OctetStreamMediaType
accept, contentType = api.OctetStreamMediaType, api.OctetStreamMediaType
}
var body []byte
var err error
if ssz {
body, err = auth.MarshalSSZ()
} else {
body, err = json.Marshal(structs.SignedRequestAuthFromConsensus(auth))
}
if err != nil {
return nil, errors.Wrap(err, "could not encode SignedRequestAuthV1")
}
now := time.Now()
opts := []reqOption{func(r *http.Request) {
r.Header.Set("Accept", accept)
r.Header.Set(api.VersionHeader, version.String(version.Gloas))
}}
if auth != nil {
var err error
contentType := api.JsonMediaType
if ssz {
contentType = api.OctetStreamMediaType
body, err = auth.MarshalSSZ()
if err != nil {
return nil, errors.Wrap(err, "could not ssz encode SignedRequestAuthV1")
}
} else {
body, err = json.Marshal(structs.SignedRequestAuthFromConsensus(auth))
if err != nil {
return nil, errors.Wrap(err, "could not json encode SignedRequestAuthV1")
r.Header.Set("Content-Type", contentType)
r.Header.Set(api.DateMillisecondsHeader, strconv.FormatInt(now.UnixMilli(), 10))
if deadline, ok := ctx.Deadline(); ok {
ms := deadline.Sub(now).Milliseconds()
if ms < 0 {
ms = 0
}
r.Header.Set(api.TimeoutMillisecondsHeader, strconv.FormatInt(ms, 10))
}
opts = append(opts, func(r *http.Request) {
r.Header.Set("Content-Type", contentType)
})
}
}}

path := executionPayloadBidPath(slot, parentHash, parentRoot, proposerPubkey)
raw, status, header, err := c.doWithStatus(ctx, http.MethodPost, path, bytes.NewReader(body), []int{http.StatusOK, http.StatusNoContent}, opts...)
Expand All @@ -94,9 +100,9 @@ func (c *Client) getExecutionPayloadBid(
if status == http.StatusNoContent {
return nil, nil
}
contentType := header.Get("Content-Type")
respContentType := header.Get("Content-Type")
switch {
case strings.Contains(contentType, api.JsonMediaType):
case strings.Contains(respContentType, api.JsonMediaType):
resp := &struct {
Data *structs.SignedExecutionPayloadBid `json:"data"`
}{}
Expand All @@ -107,14 +113,14 @@ func (c *Client) getExecutionPayloadBid(
return nil, errors.New("nil data in json SignedExecutionPayloadBid response")
}
return resp.Data.ToConsensus()
case strings.Contains(contentType, api.OctetStreamMediaType):
case strings.Contains(respContentType, api.OctetStreamMediaType):
bid := &ethpb.SignedExecutionPayloadBid{}
if err := bid.UnmarshalSSZ(raw); err != nil {
return nil, errors.Wrap(err, "could not ssz decode SignedExecutionPayloadBid")
}
return bid, nil
default:
return nil, errors.Errorf("builder returned status %d with unexpected Content-Type %q: %s", status, contentType, bodySnippet(raw))
return nil, errors.Errorf("builder returned status %d with unexpected Content-Type %q: %s", status, respContentType, bodySnippet(raw))
}
}

Expand Down Expand Up @@ -179,8 +185,8 @@ func jsonSignedBeaconBlock(sb interfaces.ReadOnlySignedBeaconBlock) ([]byte, err
return json.Marshal(jsonBlock)
}

// SubmitBuilderPreferences submits a proposer's per-builder preferences ahead of the bid request.
// If the builder rejects the SSZ request, it retries once using JSON.
// SubmitBuilderPreferences submits a proposer's per-builder preferences ahead of the bid request
// (builder-specs preferences channel; currently unwired). Falls back to JSON if SSZ is rejected.
func (c *Client) SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error {
if req == nil {
return errors.Wrap(errMalformedRequest, "nil builder preferences request")
Expand Down
41 changes: 36 additions & 5 deletions api/client/builder/client_gloas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package builder

import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"time"

"github.com/OffchainLabs/prysm/v7/api"
"github.com/OffchainLabs/prysm/v7/api/server/structs"
Expand Down Expand Up @@ -59,6 +61,35 @@ func gloasBidClient(t *testing.T, status int, contentType string, body []byte) *
return &Client{hc: hc, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}}
}

// builder-specs #165: auth is required, and the bid request carries Date-Milliseconds
// and X-Timeout-Ms but not Eth-Consensus-Version.
func TestClient_GetExecutionPayloadBid_AuthAndHeaders(t *testing.T) {
var pubkey [48]byte
var parentHash, parentRoot [32]byte

c := &Client{hc: &http.Client{}, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}}
_, err := c.GetExecutionPayloadBid(t.Context(), 1, parentHash, parentRoot, pubkey, nil)
require.ErrorContains(t, "request auth is required", err)

var got http.Header
hc := &http.Client{Transport: roundtrip(func(r *http.Request) (*http.Response, error) {
got = r.Header
return &http.Response{StatusCode: http.StatusNoContent, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(nil)), Request: r}, nil
})}
c = &Client{hc: hc, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}}
ctx, cancel := context.WithTimeout(t.Context(), 800*time.Millisecond)
defer cancel()
_, err = c.GetExecutionPayloadBid(ctx, 1, parentHash, parentRoot, pubkey, testBidAuth())
require.NoError(t, err)
require.NotEqual(t, "", got.Get(api.DateMillisecondsHeader))
require.NotEqual(t, "", got.Get(api.TimeoutMillisecondsHeader))
require.Equal(t, "", got.Get(api.VersionHeader))
}

func testBidAuth() *eth.SignedRequestAuthV1 {
return &eth.SignedRequestAuthV1{Message: &eth.RequestAuthV1{Data: []byte{1}, Slot: 1}, Signature: make([]byte, 96)}
}

func TestClient_GetExecutionPayloadBid(t *testing.T) {
ctx := t.Context()
slot := primitives.Slot(123)
Expand All @@ -72,7 +103,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
}{Data: structs.SignedExecutionPayloadBidFromConsensus(want)})
require.NoError(t, err)
c := gloasBidClient(t, http.StatusOK, api.JsonMediaType, body)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testBidAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, want.Message.Slot, got.Message.Slot)
Expand All @@ -84,7 +115,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
body, err := want.MarshalSSZ()
require.NoError(t, err)
c := gloasBidClient(t, http.StatusOK, api.OctetStreamMediaType, body)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testBidAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, want.Message.Value, got.Message.Value)
Expand All @@ -93,15 +124,15 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {

t.Run("no bid", func(t *testing.T) {
c := gloasBidClient(t, http.StatusNoContent, "", nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testBidAuth())
require.NoError(t, err)
require.IsNil(t, got)
})

t.Run("unexpected content type errors with status and body", func(t *testing.T) {
html := []byte("<!doctype html><html><head><title>Buildoor</title></head></html>")
c := gloasBidClient(t, http.StatusOK, "text/html; charset=utf-8", html)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testBidAuth())
require.IsNil(t, got)
require.ErrorContains(t, "unexpected Content-Type", err)
require.ErrorContains(t, "text/html", err)
Expand Down Expand Up @@ -161,7 +192,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
}),
}
c := &Client{hc: hc, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}, sszEnabled: true}
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testBidAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, 2, reqCount)
Expand Down
18 changes: 18 additions & 0 deletions api/client/builder/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"testing"

"github.com/OffchainLabs/go-bitfield"
Expand Down Expand Up @@ -1797,6 +1798,23 @@ func TestErrorMessage_unexpectedStatusErr(t *testing.T) {
}
}

// A builder URL carrying credentials as userinfo or a query token must never leak
// into the error message; MaskCredentialsLogging masks both (url.Redacted did not).
func TestUnexpectedStatusErr_RedactsCredentials(t *testing.T) {
const secret = "supersecrettoken"
req := &http.Request{URL: &url.URL{
Scheme: "https",
User: url.User(secret),
Host: "builder.example",
Path: "/eth/v1/builder/header",
RawQuery: "api_key=" + secret,
}}
resp := &http.Response{Request: req, StatusCode: http.StatusBadGateway, Body: io.NopCloser(bytes.NewReader([]byte("upstream down")))}
err := unexpectedStatusErr(resp, []int{http.StatusOK})
require.NotNil(t, err)
require.Equal(t, false, strings.Contains(err.Error(), secret), "error leaked credentials: %s", err.Error())
}

// isSSZRejection keys off the HTTP status, independent of the error body format, so a builder
// returning plain text (Commit Boost) is detected the same as a spec-compliant JSON body.
func TestUnexpectedStatusErr_SSZRejectionByStatus(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions api/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import "net/http"

const (
VersionHeader = "Eth-Consensus-Version"
EthBuilderUrlHeader = "Eth-Builder-Url"
ExecutionPayloadBlindedHeader = "Eth-Execution-Payload-Blinded"
ExecutionPayloadValueHeader = "Eth-Execution-Payload-Value"
ConsensusBlockValueHeader = "Eth-Consensus-Block-Value"
ExecutionPayloadIncludedHeader = "Eth-Execution-Payload-Included"
BlobDataIncludedHeader = "Eth-Blob-Data-Included"
DateMillisecondsHeader = "Date-Milliseconds"
TimeoutMillisecondsHeader = "X-Timeout-Ms"
JsonMediaType = "application/json"
OctetStreamMediaType = "application/octet-stream"
EventStreamMediaType = "text/event-stream"
Expand Down
15 changes: 15 additions & 0 deletions api/rest/rest_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ func TestPostSSZ_NonJSONErrorBodyIsTyped(t *testing.T) {
require.Equal(t, http.StatusUnsupportedMediaType, errJson.Code)
}

// PostSSZ always sends an SSZ (octet-stream) request body.
func TestPostSSZ_ContentType(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Content-Type")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewHandler(http.Client{}, srv.URL)

_, _, err := c.PostSSZ(context.Background(), "/eth/v1/test", nil, bytes.NewBuffer([]byte{0x01}))
require.NoError(t, err)
require.Equal(t, api.OctetStreamMediaType, got)
}

func TestGetSSZ_NonJSONErrorBodyIsTyped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
Expand Down
32 changes: 14 additions & 18 deletions beacon-chain/builder/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ type BlockBuilder interface {
SubmitBlindedBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock) (interfaces.ExecutionData, v1.BlobsBundler, error)
SubmitBlindedBlockPostFulu(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock) error
GetHeader(ctx context.Context, slot primitives.Slot, parentHash [32]byte, pubKey [48]byte) (builder.SignedBid, error)
GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, auths []*ethpb.SignedRequestAuthV1) ([]PayloadBid, error)
GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, authsByURL map[string]*ethpb.SignedRequestAuthV1) ([]PayloadBid, error)
SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block interfaces.ReadOnlySignedBeaconBlock) error
SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error
SubmitBuilderPreferences(ctx context.Context, builderURL string, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error
RegisterValidator(ctx context.Context, reg []*ethpb.SignedValidatorRegistrationV1) error
RegistrationByValidatorID(ctx context.Context, id primitives.ValidatorIndex) (*ethpb.ValidatorRegistrationV1, error)
Configured() bool
Expand Down Expand Up @@ -163,19 +163,15 @@ func (s *Service) SubmitBlindedBlockPostFulu(ctx context.Context, b interfaces.R
}

// Builders are queried concurrently, a failing builder drops only its own bid.
func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, auths []*ethpb.SignedRequestAuthV1) ([]PayloadBid, error) {
func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, authsByURL map[string]*ethpb.SignedRequestAuthV1) ([]PayloadBid, error) {
ctx, span := trace.StartSpan(ctx, "builder.GetExecutionPayloadBid")
defer span.End()

byURL := make(map[string]*ethpb.SignedRequestAuthV1, len(auths))
urls := make([]string, 0, len(auths))
for _, a := range auths {
url := string(a.GetMessage().GetData())
if url == "" {
continue
}
if _, ok := byURL[url]; !ok {
byURL[url] = a
// Per builder-specs 5078eab the signed auth data is opaque, so the builder URL
// is provided explicitly by the caller rather than decoded from the auth.
urls := make([]string, 0, len(authsByURL))
for url := range authsByURL {
if url != "" {
urls = append(urls, url)
}
}
Expand All @@ -197,7 +193,7 @@ func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Sl
log.WithError(err).WithField("builder", logs.MaskCredentialsLogging(url)).Warn("Could not get builder client")
return
}
bid, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, proposerPubkey, byURL[url])
bid, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, proposerPubkey, authsByURL[url])
if err != nil {
log.WithError(err).WithField("builder", logs.MaskCredentialsLogging(url)).Warn("Could not get builder execution payload bid")
return
Expand Down Expand Up @@ -230,15 +226,15 @@ func (s *Service) SubmitSignedBeaconBlock(ctx context.Context, builderURL string
return c.SubmitSignedBeaconBlock(ctx, b)
}

// Routed to the builder named in the signed request auth.
func (s *Service) SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error {
// SubmitBuilderPreferences forwards a proposer's signed preferences to the builder
// at builderURL (builder-specs preferences channel), ahead of the proposal slot.
func (s *Service) SubmitBuilderPreferences(ctx context.Context, builderURL string, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error {
ctx, span := trace.StartSpan(ctx, "builder.SubmitBuilderPreferences")
defer span.End()
url := string(req.GetAuth().GetMessage().GetData())
if url == "" {
if builderURL == "" {
return errors.New("builder preferences missing builder url")
}
c, err := s.clientFor(url)
c, err := s.clientFor(builderURL)
if err != nil {
tracing.AnnotateError(span, err)
return err
Expand Down
Loading
Loading